svamp-cli 0.2.224 → 0.2.225

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,781 +0,0 @@
1
- import { spawn } from 'child_process';
2
- import * as crypto from 'crypto';
3
- import * as fs from 'fs';
4
- import * as http from 'http';
5
- import * as net from 'net';
6
- import * as path from 'path';
7
- import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-C-9ZtEi4.mjs';
8
- import 'os';
9
- import 'fs/promises';
10
- import 'url';
11
- import 'node:crypto';
12
- import 'node:fs';
13
- import 'node:child_process';
14
- import 'util';
15
- import 'node:path';
16
- import 'node:events';
17
- import 'node:os';
18
- import '@agentclientprotocol/sdk';
19
- import '@modelcontextprotocol/sdk/client/index.js';
20
- import '@modelcontextprotocol/sdk/client/stdio.js';
21
- import '@modelcontextprotocol/sdk/types.js';
22
- import 'zod';
23
- import 'node:fs/promises';
24
- import 'node:util';
25
-
26
- function generateLinkToken() {
27
- return crypto.randomBytes(11).toString("hex");
28
- }
29
- const DNS_LABEL_MAX = 63;
30
- function buildLinkSubdomain(subdomainSafe, linkToken) {
31
- const prefix = "static-";
32
- const room = Math.max(1, DNS_LABEL_MAX - prefix.length - 1 - linkToken.length);
33
- const namePart = subdomainSafe.slice(0, room).replace(/-+$/, "") || "s";
34
- return `${prefix}${namePart}-${linkToken}`;
35
- }
36
- function findFreePort() {
37
- return new Promise((resolve, reject) => {
38
- const srv = net.createServer();
39
- srv.listen(0, "127.0.0.1", () => {
40
- const addr = srv.address();
41
- srv.close(() => resolve(addr.port));
42
- });
43
- srv.on("error", reject);
44
- });
45
- }
46
- async function tryReservePort(preferred) {
47
- if (!preferred || preferred <= 0) return findFreePort();
48
- return new Promise((resolve) => {
49
- const srv = net.createServer();
50
- srv.once("error", () => {
51
- resolve(findFreePort());
52
- });
53
- srv.listen(preferred, "127.0.0.1", () => {
54
- srv.close(() => resolve(preferred));
55
- });
56
- });
57
- }
58
- const MOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
59
- function validateMountName(name) {
60
- if (!name || name.length > 128) {
61
- throw new Error("Mount name must be 1\u2013128 characters");
62
- }
63
- if (!MOUNT_NAME_RE.test(name)) {
64
- throw new Error("Mount name must start with alphanumeric and contain only alphanumeric, hyphens, dots, or underscores");
65
- }
66
- }
67
- class ServeManager {
68
- mounts = /* @__PURE__ */ new Map();
69
- port = 0;
70
- // auth proxy public port
71
- caddyPort = 0;
72
- // Caddy internal port
73
- /** Last-used ports loaded from disk — used as preferred values on next start
74
- * so external configs (tunnels, cron, bookmarks) survive daemon restarts. */
75
- persistedPort;
76
- persistedCaddyPort;
77
- /** Per-mount frpc tunnel — each mount gets its own subdomain `static-<name>-<hash>`. */
78
- mountTunnels = /* @__PURE__ */ new Map();
79
- /** hostname (lowercased, no port) → mount name. Updated when tunnels connect. */
80
- hostToMount = /* @__PURE__ */ new Map();
81
- caddy = null;
82
- proxyServer = null;
83
- auth = null;
84
- /** Live child processes for managed mounts. Keyed by mount name. */
85
- managedProcs = /* @__PURE__ */ new Map();
86
- /** Single timer that scans managed mounts every 30s for idle eviction. */
87
- idleTimer = null;
88
- persistFile;
89
- log;
90
- hyphaServerUrl;
91
- constructor(svampHome, logger, hyphaServerUrl) {
92
- this.persistFile = path.join(svampHome, "serve-mounts.json");
93
- this.log = logger || ((msg) => console.log(`[SERVE] ${msg}`));
94
- const resolvedServerUrl = hyphaServerUrl || getHyphaServerUrl();
95
- if (!resolvedServerUrl) {
96
- throw new Error("ServeManager requires a Hypha server URL \u2014 set HYPHA_SERVER_URL.");
97
- }
98
- this.hyphaServerUrl = resolvedServerUrl;
99
- this.auth = new ServeAuth({ hyphaServerUrl: this.hyphaServerUrl });
100
- }
101
- // ── Public API ───────────────────────────────────────────────────────
102
- /**
103
- * Add a static mount (backward-compatible thin wrapper around applyMount).
104
- * Throws if a mount with the same name already exists, preserving the
105
- * pre-existing semantics that callers may depend on.
106
- */
107
- async addMount(name, directory, sessionId, access = "link", ownerEmail) {
108
- if (this.mounts.has(name)) {
109
- throw new Error(`Mount '${name}' already exists. Remove it first or choose a different name.`);
110
- }
111
- return this.applyMount({ name, directory, sessionId, access, ownerEmail });
112
- }
113
- /**
114
- * Apply a mount declaratively. Unifies static and managed-process mounts:
115
- * pass `directory` for static, `process` for managed (or both — `process`
116
- * takes precedence for routing). Replaces an existing mount with the same
117
- * name (idempotent), so repeated `svamp serve apply` is safe.
118
- *
119
- * Backward-compatible: existing `addMount(name, directory, ...)` callers
120
- * still work and produce the same result as `applyMount({name, directory, ...})`.
121
- */
122
- async applyMount(spec) {
123
- validateMountName(spec.name);
124
- if (!spec.directory && !spec.process) {
125
- throw new Error(`Mount '${spec.name}': must specify either directory (static) or process (managed)`);
126
- }
127
- if (spec.process) {
128
- if (!spec.process.command) {
129
- throw new Error(`Mount '${spec.name}': process.command is required`);
130
- }
131
- if (!spec.process.port || spec.process.port <= 0) {
132
- throw new Error(`Mount '${spec.name}': process.port must be a positive integer`);
133
- }
134
- }
135
- const resolvedDir = spec.directory ? path.resolve(spec.directory) : void 0;
136
- if (resolvedDir && !fs.existsSync(resolvedDir)) {
137
- throw new Error(`Path does not exist: ${resolvedDir}`);
138
- }
139
- if (this.mounts.has(spec.name)) {
140
- await this.removeMount(spec.name);
141
- }
142
- const access = spec.access ?? "link";
143
- if (access === "owner" && !spec.ownerEmail) {
144
- this.log(`\u26A0 Mount '${spec.name}': access='owner' but no owner email could be resolved \u2014 NO ONE will be able to sign in. Use an explicit email allowlist instead, e.g. access: ["you@example.com"].`);
145
- }
146
- const mount = {
147
- name: spec.name,
148
- directory: resolvedDir,
149
- process: spec.process,
150
- sessionId: spec.sessionId,
151
- ownerEmail: spec.ownerEmail,
152
- access,
153
- // Generate a capability token if access is 'link' and we don't
154
- // already have one from persisted state (token is stable across
155
- // restarts).
156
- linkToken: access === "link" ? spec.linkToken || generateLinkToken() : void 0,
157
- addedAt: Date.now()
158
- };
159
- this.mounts.set(spec.name, mount);
160
- await this.ensureRunning();
161
- if (resolvedDir && this.caddy?.isRunning) {
162
- await this.caddy.addMount(spec.name, resolvedDir);
163
- }
164
- await this.startMountTunnel(spec.name);
165
- if (spec.process && !spec.process.wakeOnRequest) {
166
- try {
167
- await this.ensureManagedRunning(spec.name);
168
- } catch (err) {
169
- this.log(`Mount '${spec.name}': initial process start failed: ${err.message}`);
170
- }
171
- }
172
- this.ensureIdleTimer();
173
- this.persist();
174
- const url = this.getMountUrl(spec.name);
175
- const what = spec.process ? `${spec.process.command}${spec.process.args?.length ? " " + spec.process.args.join(" ") : ""} (port ${spec.process.port})` : resolvedDir;
176
- this.log(`Mount applied: ${spec.name} \u2192 ${what} (${url ?? "tunnel pending"})`);
177
- return { url: url || "", mount };
178
- }
179
- /**
180
- * Remove a mount. If no mounts remain, stop Caddy + auth proxy.
181
- */
182
- async removeMount(name) {
183
- if (!this.mounts.has(name)) {
184
- throw new Error(`Mount '${name}' not found`);
185
- }
186
- this.mounts.delete(name);
187
- await this.stopManagedProcess(name).catch(() => {
188
- });
189
- const tunnel = this.mountTunnels.get(name);
190
- if (tunnel) {
191
- try {
192
- tunnel.destroy();
193
- } catch {
194
- }
195
- this.mountTunnels.delete(name);
196
- }
197
- for (const [host, mountName] of this.hostToMount.entries()) {
198
- if (mountName === name) this.hostToMount.delete(host);
199
- }
200
- if (this.caddy?.isRunning) {
201
- try {
202
- await this.caddy.removeMount(name);
203
- } catch (err) {
204
- this.log(`Warning: Caddy mount removal failed: ${err.message}`);
205
- }
206
- }
207
- this.persist();
208
- this.log(`Mount removed: ${name}`);
209
- if (this.mounts.size === 0) {
210
- await this.shutdown();
211
- }
212
- }
213
- /**
214
- * List mounts, optionally filtered by sessionId.
215
- */
216
- listMounts(sessionId) {
217
- const all = Array.from(this.mounts.values());
218
- if (sessionId) {
219
- return all.filter((m) => m.sessionId === sessionId);
220
- }
221
- return all;
222
- }
223
- /**
224
- * Get server info — each mount has its own URL (per-mount subdomain).
225
- */
226
- getInfo() {
227
- const running = this.caddy?.isRunning ?? false;
228
- const firstMount = this.mounts.values().next().value;
229
- const firstUrl = firstMount ? this.getMountUrl(firstMount.name) : null;
230
- return {
231
- url: firstUrl,
232
- port: running ? this.caddy?.port ?? this.port : 0,
233
- authProxyPort: running ? this.port : 0,
234
- running,
235
- mountCount: this.mounts.size,
236
- mounts: Array.from(this.mounts.values()).map((m) => ({
237
- ...m,
238
- url: this.getMountUrl(m.name) || void 0
239
- }))
240
- };
241
- }
242
- /**
243
- * Restore mounts from disk and restart if there are persisted mounts.
244
- * Called during daemon startup.
245
- */
246
- async restore() {
247
- try {
248
- if (!fs.existsSync(this.persistFile)) return;
249
- const raw = JSON.parse(fs.readFileSync(this.persistFile, "utf-8"));
250
- this.persistedPort = raw.port;
251
- this.persistedCaddyPort = raw.caddyPort;
252
- if (!raw.mounts || raw.mounts.length === 0) return;
253
- let restoredCount = 0;
254
- for (const m of raw.mounts) {
255
- if (m.directory && !fs.existsSync(m.directory)) {
256
- this.log(`Skipping mount '${m.name}': directory no longer exists (${m.directory})`);
257
- continue;
258
- }
259
- if (!m.directory && !m.process) {
260
- this.log(`Skipping mount '${m.name}': neither directory nor process configured`);
261
- continue;
262
- }
263
- this.mounts.set(m.name, m);
264
- restoredCount++;
265
- }
266
- if (restoredCount > 0) {
267
- this.log(`Restoring ${restoredCount} mount(s)...`);
268
- await this.ensureRunning();
269
- const mountList = Array.from(this.mounts.values());
270
- const mountConcurrency = Math.max(1, parseInt(process.env.SVAMP_RESTORE_CONCURRENCY || "8", 10) || 8);
271
- const restoreOneMount = async (m) => {
272
- try {
273
- await this.startMountTunnel(m.name);
274
- } catch (err) {
275
- this.log(`Failed to start tunnel for restored mount '${m.name}': ${err.message}`);
276
- }
277
- if (m.process && !m.process.wakeOnRequest) {
278
- try {
279
- await this.ensureManagedRunning(m.name);
280
- } catch (err) {
281
- this.log(`Restored managed process '${m.name}' failed to start: ${err.message}`);
282
- }
283
- }
284
- };
285
- let mountCursor = 0;
286
- const mountWorker = async () => {
287
- while (mountCursor < mountList.length) {
288
- await restoreOneMount(mountList[mountCursor++]);
289
- }
290
- };
291
- const mt0 = Date.now();
292
- await Promise.all(
293
- Array.from({ length: Math.min(mountConcurrency, mountList.length) }, () => mountWorker())
294
- );
295
- this.log(`Restored ${mountList.length} mount(s) in ${Date.now() - mt0}ms (concurrency ${mountConcurrency})`);
296
- this.ensureIdleTimer();
297
- this.persist();
298
- }
299
- } catch (err) {
300
- this.log(`Error restoring serve state: ${err.message}`);
301
- }
302
- }
303
- /**
304
- * Shut down auth proxy + Caddy + all per-mount frpc tunnels + all
305
- * managed processes.
306
- */
307
- async shutdown() {
308
- for (const name of Array.from(this.managedProcs.keys())) {
309
- await this.stopManagedProcess(name).catch(() => {
310
- });
311
- }
312
- if (this.idleTimer) {
313
- clearInterval(this.idleTimer);
314
- this.idleTimer = null;
315
- }
316
- for (const [name, tunnel] of this.mountTunnels.entries()) {
317
- try {
318
- tunnel.destroy();
319
- } catch {
320
- }
321
- this.log(`frpc tunnel for '${name}' stopped`);
322
- }
323
- this.mountTunnels.clear();
324
- this.hostToMount.clear();
325
- if (this.proxyServer) {
326
- await new Promise((resolve) => this.proxyServer.close(() => resolve()));
327
- this.proxyServer = null;
328
- }
329
- if (this.caddy) {
330
- await this.caddy.stop();
331
- this.caddy = null;
332
- this.log("Caddy stopped");
333
- }
334
- this.auth?.destroy();
335
- }
336
- // ── Managed-process lifecycle ─────────────────────────────────────────
337
- /**
338
- * Ensure the managed process for a mount is running and warm. If a warmup
339
- * is already in flight, awaits the same promise (no duplicate spawns).
340
- * Throws if warmup probe never returns 200/3xx within warmupTimeoutMs.
341
- */
342
- async ensureManagedRunning(name) {
343
- const mount = this.mounts.get(name);
344
- if (!mount?.process) return;
345
- const handle = this.managedProcs.get(name);
346
- if (handle && handle.warmupPromise) {
347
- return handle.warmupPromise;
348
- }
349
- if (handle && handle.child.exitCode === null && !handle.child.killed) {
350
- handle.lastRequestAt = Date.now();
351
- return;
352
- }
353
- const cfg = mount.process;
354
- const child = spawn(cfg.command, cfg.args ?? [], {
355
- cwd: cfg.workdir ?? process.cwd(),
356
- env: { ...process.env, ...cfg.env ?? {} },
357
- stdio: ["ignore", "pipe", "pipe"],
358
- detached: false
359
- });
360
- child.stdout?.on("data", (d) => {
361
- const line = d.toString().trimEnd();
362
- if (line) this.log(`[${name}] ${line}`);
363
- });
364
- child.stderr?.on("data", (d) => {
365
- const line = d.toString().trimEnd();
366
- if (line) this.log(`[${name}] ${line}`);
367
- });
368
- child.on("exit", (code, signal) => {
369
- this.log(`Managed process '${name}' exited (code=${code}, signal=${signal})`);
370
- const h = this.managedProcs.get(name);
371
- if (h && h.child === child) this.managedProcs.delete(name);
372
- });
373
- const warmupPath = cfg.warmupPath ?? "/";
374
- const warmupTimeoutMs = cfg.warmupTimeoutMs ?? 3e4;
375
- const warmupPromise = this.warmupProbe(`http://127.0.0.1:${cfg.port}${warmupPath}`, warmupTimeoutMs).then(() => {
376
- const h = this.managedProcs.get(name);
377
- if (h) {
378
- h.warmupPromise = null;
379
- h.lastRequestAt = Date.now();
380
- }
381
- this.log(`Managed process '${name}' ready on 127.0.0.1:${cfg.port}`);
382
- }).catch((err) => {
383
- this.log(`Managed process '${name}' warmup failed: ${err.message}`);
384
- try {
385
- child.kill("SIGTERM");
386
- } catch {
387
- }
388
- this.managedProcs.delete(name);
389
- throw err;
390
- });
391
- const newHandle = {
392
- child,
393
- pid: child.pid ?? 0,
394
- lastRequestAt: Date.now(),
395
- warmupPromise
396
- };
397
- this.managedProcs.set(name, newHandle);
398
- this.log(`Managed process '${name}' starting: ${cfg.command} ${(cfg.args ?? []).join(" ")} (port ${cfg.port})`);
399
- return warmupPromise;
400
- }
401
- /** Poll a URL until it returns <500 or the deadline passes. */
402
- async warmupProbe(url, timeoutMs) {
403
- const deadline = Date.now() + timeoutMs;
404
- let lastErr;
405
- while (Date.now() < deadline) {
406
- try {
407
- const ctrl = new AbortController();
408
- const t = setTimeout(() => ctrl.abort(), 2e3);
409
- let resp;
410
- try {
411
- resp = await fetch(url, {
412
- method: "GET",
413
- signal: ctrl.signal,
414
- redirect: "manual"
415
- });
416
- } finally {
417
- clearTimeout(t);
418
- }
419
- if (resp.status < 500) return;
420
- lastErr = new Error(`status ${resp.status}`);
421
- } catch (err) {
422
- lastErr = err;
423
- }
424
- await new Promise((r) => setTimeout(r, 500));
425
- }
426
- throw new Error(`warmup probe ${url} did not respond within ${timeoutMs}ms (${lastErr?.message || "no response"})`);
427
- }
428
- /** Stop a managed process (SIGTERM + SIGKILL after 5s). */
429
- async stopManagedProcess(name) {
430
- const h = this.managedProcs.get(name);
431
- if (!h) return;
432
- this.managedProcs.delete(name);
433
- try {
434
- h.child.kill("SIGTERM");
435
- } catch {
436
- }
437
- const child = h.child;
438
- await new Promise((resolve) => {
439
- const t = setTimeout(() => {
440
- try {
441
- child.kill("SIGKILL");
442
- } catch {
443
- }
444
- resolve();
445
- }, 5e3);
446
- child.once("exit", () => {
447
- clearTimeout(t);
448
- resolve();
449
- });
450
- });
451
- this.log(`Managed process '${name}' stopped`);
452
- }
453
- /** Idle eviction loop — stops processes that have been idle longer than configured. */
454
- ensureIdleTimer() {
455
- if (this.idleTimer) return;
456
- this.idleTimer = setInterval(() => {
457
- const now = Date.now();
458
- for (const [name, mount] of this.mounts) {
459
- const cfg = mount.process;
460
- if (!cfg || !cfg.idleTimeoutSec || cfg.idleTimeoutSec <= 0) continue;
461
- const h = this.managedProcs.get(name);
462
- if (!h || h.warmupPromise) continue;
463
- if (now - h.lastRequestAt >= cfg.idleTimeoutSec * 1e3) {
464
- this.log(`Idle eviction: stopping '${name}' (idle ${Math.round((now - h.lastRequestAt) / 1e3)}s \u2265 ${cfg.idleTimeoutSec}s)`);
465
- this.stopManagedProcess(name).catch(() => {
466
- });
467
- }
468
- }
469
- }, 3e4);
470
- }
471
- // ── Internal ─────────────────────────────────────────────────────────
472
- /**
473
- * Get the public URL for a mount (mount-specific subdomain).
474
- * For `access: 'link'` mounts, the subdomain itself carries the capability —
475
- * the tunnel registers a long random suffix (~128 bits of entropy), so the
476
- * URL is identical in shape to other tiers, just longer. Content serves
477
- * from the root, so HTML with absolute paths (`/style.css`, `/app.js`)
478
- * works unchanged.
479
- */
480
- getMountUrl(name) {
481
- const tunnel = this.mountTunnels.get(name);
482
- const tunnelUrl = tunnel?.getUrls().get(this.port);
483
- if (tunnelUrl) return `${tunnelUrl}/`;
484
- if (this.port) return `http://127.0.0.1:${this.port}/${name}/`;
485
- return null;
486
- }
487
- persist() {
488
- const state = {
489
- mounts: Array.from(this.mounts.values()),
490
- ...this.port > 0 ? { port: this.port } : {},
491
- ...this.caddyPort > 0 ? { caddyPort: this.caddyPort } : {}
492
- };
493
- try {
494
- fs.writeFileSync(this.persistFile, JSON.stringify(state, null, 2));
495
- } catch (err) {
496
- this.log(`Error persisting serve state: ${err.message}`);
497
- }
498
- }
499
- /** Start auth proxy + Caddy if not already running. Per-mount tunnels are started separately. */
500
- async ensureRunning() {
501
- if (this.caddy?.isRunning) return;
502
- this.caddyPort = await tryReservePort(this.persistedCaddyPort);
503
- this.port = await tryReservePort(this.persistedPort);
504
- const adminPort = await findFreePort();
505
- if (this.persistedPort && this.port !== this.persistedPort) {
506
- this.log(`\u26A0 Previous serve port ${this.persistedPort} unavailable \u2014 using ${this.port}. Downstream configs referencing the old port will need updating.`);
507
- }
508
- if (this.persistedCaddyPort && this.caddyPort !== this.persistedCaddyPort) {
509
- this.log(`\u26A0 Previous Caddy port ${this.persistedCaddyPort} unavailable \u2014 using ${this.caddyPort} (Caddy is internal; no downstream impact).`);
510
- }
511
- const { CaddyManager } = await import('./caddy-CuTbE3NY.mjs');
512
- this.caddy = new CaddyManager({
513
- listenPort: this.caddyPort,
514
- adminPort,
515
- configDir: path.join(path.dirname(this.persistFile), "caddy"),
516
- log: (msg) => this.log(`[Caddy] ${msg}`)
517
- });
518
- for (const mount of this.mounts.values()) {
519
- if (mount.directory) {
520
- await this.caddy.addMount(mount.name, mount.directory);
521
- }
522
- }
523
- await this.caddy.start();
524
- this.log(`Caddy file server started on 127.0.0.1:${this.caddyPort}`);
525
- await this.startAuthProxy();
526
- this.log(`Auth proxy started on 127.0.0.1:${this.port}`);
527
- }
528
- /** Start a lightweight Node.js HTTP proxy with auth + upload support. */
529
- startAuthProxy() {
530
- return new Promise((resolve, reject) => {
531
- const server = http.createServer(async (req, res) => {
532
- const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
533
- const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
534
- const hostMount = this.hostToMount.get(incomingHost);
535
- let mountName;
536
- let mountResolvedByHost = false;
537
- let basePath;
538
- if (hostMount && this.mounts.has(hostMount)) {
539
- mountName = hostMount;
540
- mountResolvedByHost = true;
541
- basePath = url.pathname;
542
- } else {
543
- mountName = url.pathname.split("/").filter(Boolean)[0];
544
- basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
545
- }
546
- const mount = mountName ? this.mounts.get(mountName) : void 0;
547
- if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
548
- res.writeHead(200, {
549
- "Content-Type": "application/json",
550
- "Cache-Control": "no-store"
551
- });
552
- res.end(JSON.stringify({
553
- ok: true,
554
- mount: mountName || null,
555
- ts: Date.now()
556
- }));
557
- return;
558
- }
559
- if (basePath === "/__login__" || url.pathname === "/__login__") {
560
- const returnUrl = url.searchParams.get("return") || "/";
561
- const isSameOriginPath = returnUrl.startsWith("/") && !returnUrl.startsWith("//") && !returnUrl.startsWith("/__login__");
562
- const safeReturn = isSameOriginPath ? returnUrl : "/";
563
- const html = this.auth ? this.auth.getLoginPageHtml(safeReturn) : "<h1>Auth not configured</h1>";
564
- res.writeHead(200, {
565
- "Content-Type": "text/html; charset=utf-8",
566
- "Cache-Control": "no-store"
567
- });
568
- res.end(html);
569
- return;
570
- }
571
- if (mount && mount.access !== "public" && mount.access !== "link") {
572
- const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
573
- if (mount.access === "owner" && !mount.ownerEmail) {
574
- this.log(`Auth DENY '${mountName}': owner-only mount but ownerEmail is empty (misconfigured \u2014 the daemon's token has no email claim). Set access to an explicit email allowlist. requester=${userEmail || "anonymous"}`);
575
- const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, misconfigured: true, returnUrl: req.url || "/" }) : "Access denied (misconfigured owner mount).";
576
- res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
577
- res.end(html);
578
- return;
579
- }
580
- const allowed = this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
581
- if (!allowed) {
582
- if (!userEmail) {
583
- this.log(`Auth: '${mountName}' requires sign-in (no valid token) \u2014 redirecting to /__login__`);
584
- const loginUrl = `/__login__?return=${encodeURIComponent(req.url || "/")}`;
585
- const headers = { Location: loginUrl };
586
- if (hasCookieToken(req)) {
587
- headers["Set-Cookie"] = "svamp_serve_token=; Path=/; Max-Age=0; SameSite=Lax";
588
- }
589
- res.writeHead(302, headers);
590
- res.end();
591
- return;
592
- }
593
- const need = mount.access === "owner" ? `owner (${mount.ownerEmail || "unset"})` : `one of [${mount.access.join(", ")}]`;
594
- this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
595
- const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
596
- res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
597
- res.end(html);
598
- return;
599
- }
600
- this.log(`Auth OK '${mountName}': ${userEmail}`);
601
- }
602
- if (req.method === "PUT" && mount && mount.directory) {
603
- const filePath = path.join(mount.directory, basePath);
604
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
605
- const ws = fs.createWriteStream(filePath);
606
- req.pipe(ws);
607
- ws.on("finish", () => {
608
- res.writeHead(201);
609
- res.end();
610
- });
611
- ws.on("error", (err) => {
612
- res.writeHead(500);
613
- res.end(err.message);
614
- });
615
- return;
616
- }
617
- if (req.method === "DELETE" && mount && mount.directory) {
618
- const filePath = path.join(mount.directory, basePath);
619
- try {
620
- fs.unlinkSync(filePath);
621
- res.writeHead(204);
622
- res.end();
623
- } catch (err) {
624
- res.writeHead(err.code === "ENOENT" ? 404 : 500);
625
- res.end(err.message);
626
- }
627
- return;
628
- }
629
- if (mount && mount.process) {
630
- const cfg = mount.process;
631
- if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
632
- try {
633
- await this.ensureManagedRunning(mount.name);
634
- } catch (err) {
635
- res.writeHead(503, { "Content-Type": "text/plain" });
636
- res.end(`Backend not ready: ${err?.message || err}`);
637
- return;
638
- }
639
- }
640
- const handle = this.managedProcs.get(mount.name);
641
- if (handle) handle.lastRequestAt = Date.now();
642
- const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
643
- const proxyReq2 = http.request({
644
- hostname: "127.0.0.1",
645
- port: cfg.port,
646
- path: targetPath,
647
- method: req.method,
648
- headers: req.headers
649
- }, (proxyRes) => {
650
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
651
- proxyRes.pipe(res);
652
- });
653
- proxyReq2.on("error", (err) => {
654
- if (!res.headersSent) {
655
- res.writeHead(502);
656
- res.end(`Backend error: ${err.message}`);
657
- }
658
- });
659
- req.pipe(proxyReq2);
660
- return;
661
- }
662
- let proxyPath = req.url || "/";
663
- if (mountResolvedByHost && mountName) {
664
- const search = url.search || "";
665
- proxyPath = `/${mountName}${basePath === "/" ? "/" : basePath}${search}`;
666
- }
667
- const proxyReq = http.request({
668
- hostname: "127.0.0.1",
669
- port: this.caddyPort,
670
- path: proxyPath,
671
- method: req.method,
672
- headers: req.headers
673
- }, (proxyRes) => {
674
- res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
675
- proxyRes.pipe(res);
676
- });
677
- proxyReq.on("error", (err) => {
678
- if (!res.headersSent) {
679
- res.writeHead(502);
680
- res.end("Proxy error");
681
- }
682
- });
683
- req.pipe(proxyReq);
684
- });
685
- this.proxyServer = server;
686
- server.listen(this.port, "127.0.0.1", () => resolve());
687
- server.on("error", reject);
688
- });
689
- }
690
- /** Get aggregate health: returns the status of the worst-failing per-mount tunnel. */
691
- getTunnelHealth() {
692
- if (this.mountTunnels.size === 0) return null;
693
- let worst = null;
694
- for (const t of this.mountTunnels.values()) {
695
- const s = t.status;
696
- if (worst === null) {
697
- worst = s;
698
- continue;
699
- }
700
- if (s.failingDurationMs > worst.failingDurationMs) worst = s;
701
- }
702
- return worst;
703
- }
704
- /** Destroy and recreate all per-mount tunnels with fresh configs. */
705
- async recreateTunnel() {
706
- this.log("Recreating all per-mount frpc tunnels (persistent failure detected)...");
707
- const names = Array.from(this.mountTunnels.keys());
708
- for (const name of names) {
709
- const t = this.mountTunnels.get(name);
710
- if (t) {
711
- try {
712
- t.destroy();
713
- } catch {
714
- }
715
- }
716
- this.mountTunnels.delete(name);
717
- }
718
- this.hostToMount.clear();
719
- for (const name of names) {
720
- try {
721
- await this.startMountTunnel(name);
722
- } catch (err) {
723
- this.log(`Failed to restart tunnel for '${name}': ${err.message}`);
724
- }
725
- }
726
- }
727
- /** Start a per-mount frpc tunnel pointing the auth-proxy port to a dedicated subdomain. */
728
- async startMountTunnel(mountName) {
729
- if (this.mountTunnels.has(mountName)) return;
730
- if (!this.port) throw new Error("Auth proxy not running \u2014 call ensureRunning() first");
731
- const subdomainSafe = mountName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
732
- const tunnelName = `static-${subdomainSafe}`;
733
- const mount = this.mounts.get(mountName);
734
- const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
735
- try {
736
- const { FrpcTunnel } = await import('./frpc-vpwKk0kV.mjs');
737
- let tunnel;
738
- tunnel = new FrpcTunnel({
739
- name: tunnelName,
740
- ports: [this.port],
741
- subdomains: subdomainOverride,
742
- // End-to-end probe: the daemon's health loop watches probe.ok
743
- // to detect ghosted tunnel registrations (frpc says "connected"
744
- // but no traffic actually flows). The sentinel route is served
745
- // by the auth proxy without auth.
746
- probePath: "/__svamp_health",
747
- probeIntervalMs: 3e4,
748
- onError: (err) => this.log(`frpc error [${mountName}]: ${err.message}`),
749
- onConnect: () => {
750
- const url2 = tunnel.getUrls().get(this.port);
751
- if (url2) {
752
- try {
753
- const host = new URL(url2).hostname.toLowerCase();
754
- this.hostToMount.set(host, mountName);
755
- } catch {
756
- }
757
- this.log(`frpc tunnel connected for '${mountName}'. URL: ${url2}/`);
758
- }
759
- },
760
- onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`),
761
- onProbeFail: (err) => this.log(`probe fail [${mountName}]: ${err.message}`)
762
- });
763
- await tunnel.connect();
764
- this.mountTunnels.set(mountName, tunnel);
765
- const url = tunnel.getUrls().get(this.port);
766
- if (url) {
767
- try {
768
- const host = new URL(url).hostname.toLowerCase();
769
- this.hostToMount.set(host, mountName);
770
- } catch {
771
- }
772
- this.log(`frpc tunnel started for '${mountName}'. URL: ${url}/`);
773
- }
774
- } catch (err) {
775
- this.log(`Warning: could not expose mount '${mountName}' externally: ${err.message}`);
776
- this.log(`Mount '${mountName}' available locally at http://127.0.0.1:${this.port}/${mountName}/`);
777
- }
778
- }
779
- }
780
-
781
- export { ServeManager, buildLinkSubdomain };