svamp-cli 0.2.268 → 0.2.269
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.
- package/dist/{agentCommands-Co_QEKON.mjs → agentCommands-Dzk4e5se.mjs} +7 -5
- package/dist/{auth-DMu2Vwk5.mjs → auth-Bsp6Mn7W.mjs} +3 -1
- package/dist/{cli-DRF6a6Xn.mjs → cli-COQg5XhV.mjs} +63 -63
- package/dist/cli.mjs +4 -2
- package/dist/{commands-Dlfz0uVQ.mjs → commands-BS1nRSti.mjs} +4 -2
- package/dist/{commands-Bw7MWVF5.mjs → commands-BbsqugoB.mjs} +4 -2
- package/dist/{commands-Bs4SXRAz.mjs → commands-C-UQ3PbA.mjs} +3 -1
- package/dist/{commands-Dl1jJEj_.mjs → commands-CJxlXlTy.mjs} +7 -7
- package/dist/{commands-CBJSJyBO.mjs → commands-DqyqbI0p.mjs} +3 -1
- package/dist/{commands-D8Emv7hS.mjs → commands-Y-Nna_A4.mjs} +4 -2
- package/dist/{commands-Dbib43Yt.mjs → commands-v-bJG0B0.mjs} +3 -1
- package/dist/{fleet-oqb0y16y.mjs → fleet-BNJf0oiF.mjs} +3 -1
- package/dist/{frpc-DR9BQXat.mjs → frpc-D0O_ho2a.mjs} +2 -1
- package/dist/{headlessCli-Dz68y7ce.mjs → headlessCli-CDajSqLP.mjs} +4 -2
- package/dist/index.mjs +3 -1
- package/dist/{package-VG-zoCbE.mjs → package-C4LMqtdL.mjs} +1 -1
- package/dist/{rpc-UN-3Vc6p.mjs → rpc-CiB0ZTCJ.mjs} +3 -1
- package/dist/{rpc-C_6FAG5Q.mjs → rpc-KQlBd_Kl.mjs} +3 -1
- package/dist/{run-B1EaF6gi.mjs → run-BwLjjDzE.mjs} +3 -1
- package/dist/{run-BtoxvDlh.mjs → run-ZZJd3r3N.mjs} +10681 -9429
- package/dist/{scheduler-D4OY3XDH.mjs → scheduler-dJbWmJa6.mjs} +3 -1
- package/dist/{serveCommands-CG9re27C.mjs → serveCommands-Wy_O_dIQ.mjs} +5 -5
- package/dist/{sideband-C7h5QrRN.mjs → sideband-DgvSqT9x.mjs} +3 -1
- package/package.json +1 -1
- package/dist/serveManager-CswGKYKJ.mjs +0 -902
- package/dist/staticFileServer-CbYnj2bH.mjs +0 -255
|
@@ -1,902 +0,0 @@
|
|
|
1
|
-
import { spawn, execSync } 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 { serveStaticMount } from './staticFileServer-CbYnj2bH.mjs';
|
|
8
|
-
import { j as getHyphaServerUrl, S as ServeAuth, k as hasCookieToken } from './run-BtoxvDlh.mjs';
|
|
9
|
-
import 'os';
|
|
10
|
-
import 'fs/promises';
|
|
11
|
-
import 'url';
|
|
12
|
-
import 'node:crypto';
|
|
13
|
-
import 'node:fs';
|
|
14
|
-
import 'node:child_process';
|
|
15
|
-
import 'util';
|
|
16
|
-
import 'node:os';
|
|
17
|
-
import 'node:path';
|
|
18
|
-
import 'node:events';
|
|
19
|
-
import '@agentclientprotocol/sdk';
|
|
20
|
-
import 'node:readline';
|
|
21
|
-
import 'node:fs/promises';
|
|
22
|
-
import 'node:util';
|
|
23
|
-
import 'yaml';
|
|
24
|
-
|
|
25
|
-
function generateLinkToken() {
|
|
26
|
-
return crypto.randomBytes(11).toString("hex");
|
|
27
|
-
}
|
|
28
|
-
const DNS_LABEL_MAX = 63;
|
|
29
|
-
function buildLinkSubdomain(subdomainSafe, linkToken) {
|
|
30
|
-
const prefix = "static-";
|
|
31
|
-
const room = Math.max(1, DNS_LABEL_MAX - prefix.length - 1 - linkToken.length);
|
|
32
|
-
const namePart = subdomainSafe.slice(0, room).replace(/-+$/, "") || "s";
|
|
33
|
-
return `${prefix}${namePart}-${linkToken}`;
|
|
34
|
-
}
|
|
35
|
-
function findFreePort() {
|
|
36
|
-
return new Promise((resolve, reject) => {
|
|
37
|
-
const srv = net.createServer();
|
|
38
|
-
srv.listen(0, "127.0.0.1", () => {
|
|
39
|
-
const addr = srv.address();
|
|
40
|
-
srv.close(() => resolve(addr.port));
|
|
41
|
-
});
|
|
42
|
-
srv.on("error", reject);
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
async function tryReservePort(preferred) {
|
|
46
|
-
if (!preferred || preferred <= 0) return findFreePort();
|
|
47
|
-
return new Promise((resolve) => {
|
|
48
|
-
const srv = net.createServer();
|
|
49
|
-
srv.once("error", () => {
|
|
50
|
-
resolve(findFreePort());
|
|
51
|
-
});
|
|
52
|
-
srv.listen(preferred, "127.0.0.1", () => {
|
|
53
|
-
srv.close(() => resolve(preferred));
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
function killOrphanedCaddy(log) {
|
|
58
|
-
if (process.platform === "win32") return;
|
|
59
|
-
try {
|
|
60
|
-
const out = execSync("ps -axo pid=,command=", { encoding: "utf-8" });
|
|
61
|
-
const pids = [];
|
|
62
|
-
for (const line of out.split("\n")) {
|
|
63
|
-
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
64
|
-
if (!m) continue;
|
|
65
|
-
const [, pid, cmd] = m;
|
|
66
|
-
if (cmd.includes(".svamp/bin/caddy") && pid !== String(process.pid)) pids.push(pid);
|
|
67
|
-
}
|
|
68
|
-
for (const pid of pids) {
|
|
69
|
-
try {
|
|
70
|
-
process.kill(Number(pid), "SIGKILL");
|
|
71
|
-
} catch {
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (pids.length) log(`Reaped ${pids.length} orphaned Caddy process(es) \u2014 retired in favor of the native file server (#0358).`);
|
|
75
|
-
} catch {
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const MOUNT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
79
|
-
function validateMountName(name) {
|
|
80
|
-
if (!name || name.length > 128) {
|
|
81
|
-
throw new Error("Mount name must be 1\u2013128 characters");
|
|
82
|
-
}
|
|
83
|
-
if (!MOUNT_NAME_RE.test(name)) {
|
|
84
|
-
throw new Error("Mount name must start with alphanumeric and contain only alphanumeric, hyphens, dots, or underscores");
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
class ServeManager {
|
|
88
|
-
mounts = /* @__PURE__ */ new Map();
|
|
89
|
-
port = 0;
|
|
90
|
-
// public HTTP server port (serves static files natively — #0358)
|
|
91
|
-
/** Last-used port loaded from disk — used as the preferred value on next start
|
|
92
|
-
* so external configs (tunnels, cron, bookmarks) survive daemon restarts. */
|
|
93
|
-
persistedPort;
|
|
94
|
-
/** Per-mount frpc tunnel — each mount gets its own subdomain `static-<name>-<hash>`. */
|
|
95
|
-
mountTunnels = /* @__PURE__ */ new Map();
|
|
96
|
-
/** hostname (lowercased, no port) → mount name. Updated when tunnels connect. */
|
|
97
|
-
hostToMount = /* @__PURE__ */ new Map();
|
|
98
|
-
proxyServer = null;
|
|
99
|
-
auth = null;
|
|
100
|
-
/** Live child processes for managed mounts. Keyed by mount name. */
|
|
101
|
-
managedProcs = /* @__PURE__ */ new Map();
|
|
102
|
-
/** Single timer that scans managed mounts every 30s for idle eviction. */
|
|
103
|
-
idleTimer = null;
|
|
104
|
-
persistFile;
|
|
105
|
-
log;
|
|
106
|
-
hyphaServerUrl;
|
|
107
|
-
constructor(svampHome, logger, hyphaServerUrl) {
|
|
108
|
-
this.persistFile = path.join(svampHome, "serve-mounts.json");
|
|
109
|
-
this.log = logger || ((msg) => console.log(`[SERVE] ${msg}`));
|
|
110
|
-
const resolvedServerUrl = hyphaServerUrl || getHyphaServerUrl();
|
|
111
|
-
if (!resolvedServerUrl) {
|
|
112
|
-
throw new Error("ServeManager requires a Hypha server URL \u2014 set HYPHA_SERVER_URL.");
|
|
113
|
-
}
|
|
114
|
-
this.hyphaServerUrl = resolvedServerUrl;
|
|
115
|
-
this.auth = new ServeAuth({ hyphaServerUrl: this.hyphaServerUrl });
|
|
116
|
-
}
|
|
117
|
-
// ── Public API ───────────────────────────────────────────────────────
|
|
118
|
-
/**
|
|
119
|
-
* Add a static mount (backward-compatible thin wrapper around applyMount).
|
|
120
|
-
* Throws if a mount with the same name already exists, preserving the
|
|
121
|
-
* pre-existing semantics that callers may depend on.
|
|
122
|
-
*/
|
|
123
|
-
async addMount(name, directory, sessionId, access = "link", ownerEmail) {
|
|
124
|
-
if (this.mounts.has(name)) {
|
|
125
|
-
throw new Error(`Mount '${name}' already exists. Remove it first or choose a different name.`);
|
|
126
|
-
}
|
|
127
|
-
return this.applyMount({ name, directory, sessionId, access, ownerEmail });
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Apply a mount declaratively. Unifies static and managed-process mounts:
|
|
131
|
-
* pass `directory` for static, `process` for managed (or both — `process`
|
|
132
|
-
* takes precedence for routing). Replaces an existing mount with the same
|
|
133
|
-
* name (idempotent), so repeated `svamp serve apply` is safe.
|
|
134
|
-
*
|
|
135
|
-
* Backward-compatible: existing `addMount(name, directory, ...)` callers
|
|
136
|
-
* still work and produce the same result as `applyMount({name, directory, ...})`.
|
|
137
|
-
*/
|
|
138
|
-
async applyMount(spec) {
|
|
139
|
-
validateMountName(spec.name);
|
|
140
|
-
if (!spec.directory && !spec.process) {
|
|
141
|
-
throw new Error(`Mount '${spec.name}': must specify either directory (static) or process (managed)`);
|
|
142
|
-
}
|
|
143
|
-
if (spec.process) {
|
|
144
|
-
if (!spec.process.command) {
|
|
145
|
-
throw new Error(`Mount '${spec.name}': process.command is required`);
|
|
146
|
-
}
|
|
147
|
-
if (!spec.process.port || spec.process.port <= 0) {
|
|
148
|
-
throw new Error(`Mount '${spec.name}': process.port must be a positive integer`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const resolvedDir = spec.directory ? path.resolve(spec.directory) : void 0;
|
|
152
|
-
if (resolvedDir && !fs.existsSync(resolvedDir)) {
|
|
153
|
-
throw new Error(`Path does not exist: ${resolvedDir}`);
|
|
154
|
-
}
|
|
155
|
-
if (this.mounts.has(spec.name)) {
|
|
156
|
-
await this.removeMount(spec.name);
|
|
157
|
-
}
|
|
158
|
-
const access = spec.access ?? "link";
|
|
159
|
-
if (access === "owner" && !spec.ownerEmail) {
|
|
160
|
-
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"].`);
|
|
161
|
-
}
|
|
162
|
-
const mount = {
|
|
163
|
-
name: spec.name,
|
|
164
|
-
directory: resolvedDir,
|
|
165
|
-
process: spec.process,
|
|
166
|
-
sessionId: spec.sessionId,
|
|
167
|
-
ownerEmail: spec.ownerEmail,
|
|
168
|
-
access,
|
|
169
|
-
// Generate a capability token if access is 'link' and we don't
|
|
170
|
-
// already have one from persisted state (token is stable across
|
|
171
|
-
// restarts).
|
|
172
|
-
linkToken: access === "link" ? spec.linkToken || generateLinkToken() : void 0,
|
|
173
|
-
addedAt: Date.now()
|
|
174
|
-
};
|
|
175
|
-
this.mounts.set(spec.name, mount);
|
|
176
|
-
await this.ensureRunning();
|
|
177
|
-
await this.startMountTunnel(spec.name);
|
|
178
|
-
if (spec.process && !spec.process.wakeOnRequest) {
|
|
179
|
-
try {
|
|
180
|
-
await this.ensureManagedRunning(spec.name);
|
|
181
|
-
} catch (err) {
|
|
182
|
-
this.log(`Mount '${spec.name}': initial process start failed: ${err.message}`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
this.ensureIdleTimer();
|
|
186
|
-
this.persist();
|
|
187
|
-
const url = this.getMountUrl(spec.name);
|
|
188
|
-
const what = spec.process ? `${spec.process.command}${spec.process.args?.length ? " " + spec.process.args.join(" ") : ""} (port ${spec.process.port})` : resolvedDir;
|
|
189
|
-
this.log(`Mount applied: ${spec.name} \u2192 ${what} (${url ?? "tunnel pending"})`);
|
|
190
|
-
return { url: url || "", mount };
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Remove a mount. If no mounts remain, stop Caddy + auth proxy.
|
|
194
|
-
*/
|
|
195
|
-
async removeMount(name) {
|
|
196
|
-
if (!this.mounts.has(name)) {
|
|
197
|
-
throw new Error(`Mount '${name}' not found`);
|
|
198
|
-
}
|
|
199
|
-
this.mounts.delete(name);
|
|
200
|
-
await this.stopManagedProcess(name).catch(() => {
|
|
201
|
-
});
|
|
202
|
-
const tunnel = this.mountTunnels.get(name);
|
|
203
|
-
if (tunnel) {
|
|
204
|
-
try {
|
|
205
|
-
tunnel.destroy();
|
|
206
|
-
} catch {
|
|
207
|
-
}
|
|
208
|
-
this.mountTunnels.delete(name);
|
|
209
|
-
}
|
|
210
|
-
for (const [host, mountName] of this.hostToMount.entries()) {
|
|
211
|
-
if (mountName === name) this.hostToMount.delete(host);
|
|
212
|
-
}
|
|
213
|
-
this.persist();
|
|
214
|
-
this.log(`Mount removed: ${name}`);
|
|
215
|
-
if (this.mounts.size === 0) {
|
|
216
|
-
await this.shutdown();
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* List mounts, optionally filtered by sessionId.
|
|
221
|
-
*/
|
|
222
|
-
listMounts(sessionId) {
|
|
223
|
-
const all = Array.from(this.mounts.values());
|
|
224
|
-
if (sessionId) {
|
|
225
|
-
return all.filter((m) => m.sessionId === sessionId);
|
|
226
|
-
}
|
|
227
|
-
return all;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Get server info — each mount has its own URL (per-mount subdomain).
|
|
231
|
-
*/
|
|
232
|
-
getInfo() {
|
|
233
|
-
const running = this.proxyServer != null;
|
|
234
|
-
const firstMount = this.mounts.values().next().value;
|
|
235
|
-
const firstUrl = firstMount ? this.getMountUrl(firstMount.name) : null;
|
|
236
|
-
return {
|
|
237
|
-
url: firstUrl,
|
|
238
|
-
port: running ? this.port : 0,
|
|
239
|
-
authProxyPort: running ? this.port : 0,
|
|
240
|
-
running,
|
|
241
|
-
mountCount: this.mounts.size,
|
|
242
|
-
mounts: Array.from(this.mounts.values()).map((m) => ({
|
|
243
|
-
...m,
|
|
244
|
-
url: this.getMountUrl(m.name) || void 0
|
|
245
|
-
}))
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Restore mounts from disk and restart if there are persisted mounts.
|
|
250
|
-
* Called during daemon startup.
|
|
251
|
-
*/
|
|
252
|
-
async restore() {
|
|
253
|
-
try {
|
|
254
|
-
if (!fs.existsSync(this.persistFile)) return;
|
|
255
|
-
const raw = JSON.parse(fs.readFileSync(this.persistFile, "utf-8"));
|
|
256
|
-
this.persistedPort = raw.port;
|
|
257
|
-
if (!raw.mounts || raw.mounts.length === 0) return;
|
|
258
|
-
let restoredCount = 0;
|
|
259
|
-
for (const m of raw.mounts) {
|
|
260
|
-
if (m.directory && !fs.existsSync(m.directory)) {
|
|
261
|
-
this.log(`Skipping mount '${m.name}': directory no longer exists (${m.directory})`);
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
if (!m.directory && !m.process) {
|
|
265
|
-
this.log(`Skipping mount '${m.name}': neither directory nor process configured`);
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
this.mounts.set(m.name, m);
|
|
269
|
-
restoredCount++;
|
|
270
|
-
}
|
|
271
|
-
if (restoredCount > 0) {
|
|
272
|
-
this.log(`Restoring ${restoredCount} mount(s)...`);
|
|
273
|
-
await this.ensureRunning();
|
|
274
|
-
const mountList = Array.from(this.mounts.values());
|
|
275
|
-
const mountConcurrency = Math.max(1, parseInt(process.env.SVAMP_RESTORE_CONCURRENCY || "8", 10) || 8);
|
|
276
|
-
const restoreOneMount = async (m) => {
|
|
277
|
-
try {
|
|
278
|
-
await this.startMountTunnel(m.name);
|
|
279
|
-
} catch (err) {
|
|
280
|
-
this.log(`Failed to start tunnel for restored mount '${m.name}': ${err.message}`);
|
|
281
|
-
}
|
|
282
|
-
if (m.process && !m.process.wakeOnRequest) {
|
|
283
|
-
try {
|
|
284
|
-
await this.ensureManagedRunning(m.name);
|
|
285
|
-
} catch (err) {
|
|
286
|
-
this.log(`Restored managed process '${m.name}' failed to start: ${err.message}`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
};
|
|
290
|
-
let mountCursor = 0;
|
|
291
|
-
const mountWorker = async () => {
|
|
292
|
-
while (mountCursor < mountList.length) {
|
|
293
|
-
await restoreOneMount(mountList[mountCursor++]);
|
|
294
|
-
}
|
|
295
|
-
};
|
|
296
|
-
const mt0 = Date.now();
|
|
297
|
-
await Promise.all(
|
|
298
|
-
Array.from({ length: Math.min(mountConcurrency, mountList.length) }, () => mountWorker())
|
|
299
|
-
);
|
|
300
|
-
this.log(`Restored ${mountList.length} mount(s) in ${Date.now() - mt0}ms (concurrency ${mountConcurrency})`);
|
|
301
|
-
this.ensureIdleTimer();
|
|
302
|
-
this.persist();
|
|
303
|
-
}
|
|
304
|
-
} catch (err) {
|
|
305
|
-
this.log(`Error restoring serve state: ${err.message}`);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Shut down auth proxy + Caddy + all per-mount frpc tunnels + all
|
|
310
|
-
* managed processes.
|
|
311
|
-
*/
|
|
312
|
-
async shutdown() {
|
|
313
|
-
for (const name of Array.from(this.managedProcs.keys())) {
|
|
314
|
-
await this.stopManagedProcess(name).catch(() => {
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
if (this.idleTimer) {
|
|
318
|
-
clearInterval(this.idleTimer);
|
|
319
|
-
this.idleTimer = null;
|
|
320
|
-
}
|
|
321
|
-
for (const [name, tunnel] of this.mountTunnels.entries()) {
|
|
322
|
-
try {
|
|
323
|
-
tunnel.destroy();
|
|
324
|
-
} catch {
|
|
325
|
-
}
|
|
326
|
-
this.log(`frpc tunnel for '${name}' stopped`);
|
|
327
|
-
}
|
|
328
|
-
this.mountTunnels.clear();
|
|
329
|
-
this.hostToMount.clear();
|
|
330
|
-
if (this.proxyServer) {
|
|
331
|
-
await new Promise((resolve) => this.proxyServer.close(() => resolve()));
|
|
332
|
-
this.proxyServer = null;
|
|
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
|
-
};
|
|
492
|
-
try {
|
|
493
|
-
fs.writeFileSync(this.persistFile, JSON.stringify(state, null, 2));
|
|
494
|
-
} catch (err) {
|
|
495
|
-
this.log(`Error persisting serve state: ${err.message}`);
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
/** Start auth proxy + Caddy if not already running. Per-mount tunnels are started separately. */
|
|
499
|
-
async ensureRunning() {
|
|
500
|
-
if (this.proxyServer) return;
|
|
501
|
-
killOrphanedCaddy((m) => this.log(m));
|
|
502
|
-
this.port = await tryReservePort(this.persistedPort);
|
|
503
|
-
if (this.persistedPort && this.port !== this.persistedPort) {
|
|
504
|
-
this.log(`\u26A0 Previous serve port ${this.persistedPort} unavailable \u2014 using ${this.port}. Downstream configs referencing the old port will need updating.`);
|
|
505
|
-
}
|
|
506
|
-
await this.startAuthProxy();
|
|
507
|
-
this.log(`Static file server started on 127.0.0.1:${this.port}`);
|
|
508
|
-
}
|
|
509
|
-
/** Minimal mount-listing page for the server root (replaces Caddy's root route). */
|
|
510
|
-
mountListHtml() {
|
|
511
|
-
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
512
|
-
const items = Array.from(this.mounts.values()).filter((m) => m.directory).map((m) => `<li><a href="/${esc(m.name)}/">${esc(m.name)}</a> \u2014 ${esc(m.directory || "")}</li>`).join("\n");
|
|
513
|
-
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Svamp File Server</title>
|
|
514
|
-
<style>body{font-family:system-ui,sans-serif;max-width:700px;margin:40px auto;padding:0 20px;color:#333}
|
|
515
|
-
h1{font-size:1.3rem}ul{list-style:none;padding:0}li{padding:8px 0;border-bottom:1px solid #eee}
|
|
516
|
-
a{color:#0969da;text-decoration:none;font-weight:500}a:hover{text-decoration:underline}</style></head>
|
|
517
|
-
<body><h1>Svamp File Server</h1><ul>${items || "<li>No mounts registered.</li>"}</ul></body></html>`;
|
|
518
|
-
}
|
|
519
|
-
/** Start a lightweight Node.js HTTP proxy with auth + upload support. */
|
|
520
|
-
startAuthProxy() {
|
|
521
|
-
return new Promise((resolve, reject) => {
|
|
522
|
-
const server = http.createServer(async (req, res) => {
|
|
523
|
-
const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
|
|
524
|
-
const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
525
|
-
const hostMount = this.hostToMount.get(incomingHost);
|
|
526
|
-
let mountName;
|
|
527
|
-
let mountResolvedByHost = false;
|
|
528
|
-
let basePath;
|
|
529
|
-
if (hostMount && this.mounts.has(hostMount)) {
|
|
530
|
-
mountName = hostMount;
|
|
531
|
-
mountResolvedByHost = true;
|
|
532
|
-
basePath = url.pathname;
|
|
533
|
-
} else {
|
|
534
|
-
mountName = url.pathname.split("/").filter(Boolean)[0];
|
|
535
|
-
basePath = mountName ? url.pathname.slice(`/${mountName}`.length) || "/" : url.pathname;
|
|
536
|
-
}
|
|
537
|
-
const mount = mountName ? this.mounts.get(mountName) : void 0;
|
|
538
|
-
if (basePath === "/__svamp_health" || url.pathname === "/__svamp_health") {
|
|
539
|
-
res.writeHead(200, {
|
|
540
|
-
"Content-Type": "application/json",
|
|
541
|
-
"Cache-Control": "no-store"
|
|
542
|
-
});
|
|
543
|
-
res.end(JSON.stringify({
|
|
544
|
-
ok: true,
|
|
545
|
-
mount: mountName || null,
|
|
546
|
-
ts: Date.now()
|
|
547
|
-
}));
|
|
548
|
-
return;
|
|
549
|
-
}
|
|
550
|
-
if (basePath === "/__login__" || url.pathname === "/__login__") {
|
|
551
|
-
const returnUrl = url.searchParams.get("return") || "/";
|
|
552
|
-
const isSameOriginPath = returnUrl.startsWith("/") && !returnUrl.startsWith("//") && !returnUrl.startsWith("/__login__");
|
|
553
|
-
const safeReturn = isSameOriginPath ? returnUrl : "/";
|
|
554
|
-
const html = this.auth ? this.auth.getLoginPageHtml(safeReturn) : "<h1>Auth not configured</h1>";
|
|
555
|
-
res.writeHead(200, {
|
|
556
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
557
|
-
"Cache-Control": "no-store"
|
|
558
|
-
});
|
|
559
|
-
res.end(html);
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
if (mount && mount.access !== "public" && mount.access !== "link") {
|
|
563
|
-
const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
|
|
564
|
-
if (mount.access === "owner" && !mount.ownerEmail) {
|
|
565
|
-
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"}`);
|
|
566
|
-
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).";
|
|
567
|
-
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
568
|
-
res.end(html);
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
const allowed = this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
|
|
572
|
-
if (!allowed) {
|
|
573
|
-
if (!userEmail) {
|
|
574
|
-
this.log(`Auth: '${mountName}' requires sign-in (no valid token) \u2014 redirecting to /__login__`);
|
|
575
|
-
const loginUrl = `/__login__?return=${encodeURIComponent(req.url || "/")}`;
|
|
576
|
-
const headers = { Location: loginUrl };
|
|
577
|
-
if (hasCookieToken(req)) {
|
|
578
|
-
headers["Set-Cookie"] = "svamp_serve_token=; Path=/; Max-Age=0; SameSite=Lax";
|
|
579
|
-
}
|
|
580
|
-
res.writeHead(302, headers);
|
|
581
|
-
res.end();
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
const need = mount.access === "owner" ? `owner (${mount.ownerEmail || "unset"})` : `one of [${mount.access.join(", ")}]`;
|
|
585
|
-
this.log(`Auth DENY '${mountName}': '${userEmail}' is not authorized (requires ${need})`);
|
|
586
|
-
const html = this.auth ? this.auth.getAccessDeniedHtml({ email: userEmail, access: mount.access, ownerEmail: mount.ownerEmail, returnUrl: req.url || "/" }) : `Access denied for ${userEmail}.`;
|
|
587
|
-
res.writeHead(403, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
588
|
-
res.end(html);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
this.log(`Auth OK '${mountName}': ${userEmail}`);
|
|
592
|
-
}
|
|
593
|
-
const resolveContained = () => {
|
|
594
|
-
const base = path.resolve(mount.directory);
|
|
595
|
-
const fp = path.resolve(path.join(base, basePath));
|
|
596
|
-
if (fp !== base && !fp.startsWith(base + path.sep)) return null;
|
|
597
|
-
try {
|
|
598
|
-
const realRoot = fs.realpathSync(base);
|
|
599
|
-
let ancestor = fp;
|
|
600
|
-
while (!fs.existsSync(ancestor) && path.dirname(ancestor) !== ancestor) {
|
|
601
|
-
ancestor = path.dirname(ancestor);
|
|
602
|
-
}
|
|
603
|
-
const realAncestor = fs.realpathSync(ancestor);
|
|
604
|
-
if (realAncestor !== realRoot && !realAncestor.startsWith(realRoot + path.sep)) return null;
|
|
605
|
-
if (fs.existsSync(fp) && fs.lstatSync(fp).isSymbolicLink()) {
|
|
606
|
-
const realTarget = fs.realpathSync(fp);
|
|
607
|
-
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) return null;
|
|
608
|
-
}
|
|
609
|
-
} catch {
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
return fp;
|
|
613
|
-
};
|
|
614
|
-
if ((req.method === "PUT" || req.method === "DELETE") && mount && mount.directory) {
|
|
615
|
-
if (mount.access === "public") {
|
|
616
|
-
this.log(`Write DENY '${mountName}': ${req.method} not allowed on a public (unauthenticated) mount`);
|
|
617
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
618
|
-
res.end("Write access denied: public mounts are read-only.");
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
const filePath = resolveContained();
|
|
622
|
-
if (!filePath) {
|
|
623
|
-
this.log(`Write DENY '${mountName}': path escapes mount root (${basePath})`);
|
|
624
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
625
|
-
res.end("Forbidden: path escapes mount root.");
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
if (req.method === "PUT") {
|
|
629
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
630
|
-
const ws = fs.createWriteStream(filePath);
|
|
631
|
-
req.pipe(ws);
|
|
632
|
-
ws.on("finish", () => {
|
|
633
|
-
res.writeHead(201);
|
|
634
|
-
res.end();
|
|
635
|
-
});
|
|
636
|
-
ws.on("error", (err) => {
|
|
637
|
-
res.writeHead(500);
|
|
638
|
-
res.end(err.message);
|
|
639
|
-
});
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
try {
|
|
643
|
-
fs.unlinkSync(filePath);
|
|
644
|
-
res.writeHead(204);
|
|
645
|
-
res.end();
|
|
646
|
-
} catch (err) {
|
|
647
|
-
res.writeHead(err.code === "ENOENT" ? 404 : 500);
|
|
648
|
-
res.end(err.message);
|
|
649
|
-
}
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
if (mount && mount.process) {
|
|
653
|
-
const cfg = mount.process;
|
|
654
|
-
if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
|
|
655
|
-
try {
|
|
656
|
-
await this.ensureManagedRunning(mount.name);
|
|
657
|
-
} catch (err) {
|
|
658
|
-
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
659
|
-
res.end(`Backend not ready: ${err?.message || err}`);
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
const handle = this.managedProcs.get(mount.name);
|
|
664
|
-
if (handle) handle.lastRequestAt = Date.now();
|
|
665
|
-
const targetPath = mountResolvedByHost ? req.url || "/" : (basePath || "/") + (url.search || "");
|
|
666
|
-
const proxyReq = http.request({
|
|
667
|
-
hostname: "127.0.0.1",
|
|
668
|
-
port: cfg.port,
|
|
669
|
-
path: targetPath,
|
|
670
|
-
method: req.method,
|
|
671
|
-
headers: req.headers
|
|
672
|
-
}, (proxyRes) => {
|
|
673
|
-
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
674
|
-
proxyRes.pipe(res);
|
|
675
|
-
});
|
|
676
|
-
proxyReq.on("error", (err) => {
|
|
677
|
-
if (!res.headersSent) {
|
|
678
|
-
res.writeHead(502);
|
|
679
|
-
res.end(`Backend error: ${err.message}`);
|
|
680
|
-
}
|
|
681
|
-
});
|
|
682
|
-
const abortUpstream = () => {
|
|
683
|
-
if (!proxyReq.destroyed) proxyReq.destroy();
|
|
684
|
-
};
|
|
685
|
-
req.on("aborted", abortUpstream);
|
|
686
|
-
res.on("close", () => {
|
|
687
|
-
if (!res.writableFinished) abortUpstream();
|
|
688
|
-
});
|
|
689
|
-
proxyReq.setTimeout(12e4, abortUpstream);
|
|
690
|
-
req.pipe(proxyReq);
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
if (mount && mount.directory) {
|
|
694
|
-
serveStaticMount(req, res, {
|
|
695
|
-
rootDir: mount.directory,
|
|
696
|
-
relPath: basePath || "/",
|
|
697
|
-
// Path-based access is prefixed with /<mountName>; host-based has no prefix.
|
|
698
|
-
mountUrlPrefix: mountResolvedByHost ? "" : `/${mountName}`,
|
|
699
|
-
browse: true});
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "*" };
|
|
703
|
-
if (url.pathname === "/" || basePath === "/") {
|
|
704
|
-
res.writeHead(200, { ...cors, "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
705
|
-
res.end(this.mountListHtml());
|
|
706
|
-
} else {
|
|
707
|
-
res.writeHead(404, { ...cors, "Content-Type": "text/plain" });
|
|
708
|
-
res.end("Not found");
|
|
709
|
-
}
|
|
710
|
-
});
|
|
711
|
-
server.on("upgrade", (req, clientSocket, head) => {
|
|
712
|
-
const deny = (code, reason) => {
|
|
713
|
-
try {
|
|
714
|
-
clientSocket.write(`HTTP/1.1 ${code} ${reason}\r
|
|
715
|
-
Connection: close\r
|
|
716
|
-
\r
|
|
717
|
-
`);
|
|
718
|
-
} catch {
|
|
719
|
-
}
|
|
720
|
-
try {
|
|
721
|
-
clientSocket.destroy();
|
|
722
|
-
} catch {
|
|
723
|
-
}
|
|
724
|
-
};
|
|
725
|
-
void (async () => {
|
|
726
|
-
try {
|
|
727
|
-
const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`);
|
|
728
|
-
const incomingHost = (req.headers.host || "").split(":")[0].toLowerCase();
|
|
729
|
-
const hostMount = this.hostToMount.get(incomingHost);
|
|
730
|
-
let mountName;
|
|
731
|
-
let mountResolvedByHost = false;
|
|
732
|
-
if (hostMount && this.mounts.has(hostMount)) {
|
|
733
|
-
mountName = hostMount;
|
|
734
|
-
mountResolvedByHost = true;
|
|
735
|
-
} else {
|
|
736
|
-
mountName = url.pathname.split("/").filter(Boolean)[0];
|
|
737
|
-
}
|
|
738
|
-
const mount = mountName ? this.mounts.get(mountName) : void 0;
|
|
739
|
-
if (!mount || !mount.process) {
|
|
740
|
-
deny(404, "Not Found");
|
|
741
|
-
return;
|
|
742
|
-
}
|
|
743
|
-
if (mount.access !== "public" && mount.access !== "link") {
|
|
744
|
-
if (mount.access === "owner" && !mount.ownerEmail) {
|
|
745
|
-
deny(403, "Forbidden");
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
const userEmail = this.auth ? await this.auth.authenticate(req).catch(() => null) : null;
|
|
749
|
-
const allowed = userEmail && this.auth ? this.auth.isAuthorized(userEmail, mount.access, mount.ownerEmail) : false;
|
|
750
|
-
if (!allowed) {
|
|
751
|
-
deny(userEmail ? 403 : 401, userEmail ? "Forbidden" : "Unauthorized");
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
const cfg = mount.process;
|
|
756
|
-
if (cfg.wakeOnRequest || !this.managedProcs.has(mount.name)) {
|
|
757
|
-
try {
|
|
758
|
-
await this.ensureManagedRunning(mount.name);
|
|
759
|
-
} catch {
|
|
760
|
-
deny(503, "Backend Not Ready");
|
|
761
|
-
return;
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
const handle = this.managedProcs.get(mount.name);
|
|
765
|
-
if (handle) handle.lastRequestAt = Date.now();
|
|
766
|
-
const targetPath = mountResolvedByHost ? req.url || "/" : (url.pathname.slice(`/${mountName}`.length) || "/") + (url.search || "");
|
|
767
|
-
const upstream = net.connect(cfg.port, "127.0.0.1", () => {
|
|
768
|
-
const lines = [`${req.method} ${targetPath} HTTP/1.1`];
|
|
769
|
-
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
770
|
-
lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
771
|
-
}
|
|
772
|
-
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
773
|
-
if (head && head.length) upstream.write(head);
|
|
774
|
-
upstream.pipe(clientSocket);
|
|
775
|
-
clientSocket.pipe(upstream);
|
|
776
|
-
});
|
|
777
|
-
const teardown = () => {
|
|
778
|
-
try {
|
|
779
|
-
upstream.destroy();
|
|
780
|
-
} catch {
|
|
781
|
-
}
|
|
782
|
-
try {
|
|
783
|
-
clientSocket.destroy();
|
|
784
|
-
} catch {
|
|
785
|
-
}
|
|
786
|
-
};
|
|
787
|
-
upstream.on("error", teardown);
|
|
788
|
-
clientSocket.on("error", teardown);
|
|
789
|
-
upstream.on("close", () => {
|
|
790
|
-
try {
|
|
791
|
-
clientSocket.destroy();
|
|
792
|
-
} catch {
|
|
793
|
-
}
|
|
794
|
-
});
|
|
795
|
-
clientSocket.on("close", () => {
|
|
796
|
-
try {
|
|
797
|
-
upstream.destroy();
|
|
798
|
-
} catch {
|
|
799
|
-
}
|
|
800
|
-
});
|
|
801
|
-
} catch {
|
|
802
|
-
deny(500, "Internal Server Error");
|
|
803
|
-
}
|
|
804
|
-
})();
|
|
805
|
-
});
|
|
806
|
-
this.proxyServer = server;
|
|
807
|
-
server.listen(this.port, "127.0.0.1", () => resolve());
|
|
808
|
-
server.on("error", reject);
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
/** Get aggregate health: returns the status of the worst-failing per-mount tunnel. */
|
|
812
|
-
getTunnelHealth() {
|
|
813
|
-
if (this.mountTunnels.size === 0) return null;
|
|
814
|
-
let worst = null;
|
|
815
|
-
for (const t of this.mountTunnels.values()) {
|
|
816
|
-
const s = t.status;
|
|
817
|
-
if (worst === null) {
|
|
818
|
-
worst = s;
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
if (s.failingDurationMs > worst.failingDurationMs) worst = s;
|
|
822
|
-
}
|
|
823
|
-
return worst;
|
|
824
|
-
}
|
|
825
|
-
/** Destroy and recreate all per-mount tunnels with fresh configs. */
|
|
826
|
-
async recreateTunnel() {
|
|
827
|
-
this.log("Recreating all per-mount frpc tunnels (persistent failure detected)...");
|
|
828
|
-
const names = Array.from(this.mountTunnels.keys());
|
|
829
|
-
for (const name of names) {
|
|
830
|
-
const t = this.mountTunnels.get(name);
|
|
831
|
-
if (t) {
|
|
832
|
-
try {
|
|
833
|
-
t.destroy();
|
|
834
|
-
} catch {
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
this.mountTunnels.delete(name);
|
|
838
|
-
}
|
|
839
|
-
this.hostToMount.clear();
|
|
840
|
-
for (const name of names) {
|
|
841
|
-
try {
|
|
842
|
-
await this.startMountTunnel(name);
|
|
843
|
-
} catch (err) {
|
|
844
|
-
this.log(`Failed to restart tunnel for '${name}': ${err.message}`);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
/** Start a per-mount frpc tunnel pointing the auth-proxy port to a dedicated subdomain. */
|
|
849
|
-
async startMountTunnel(mountName) {
|
|
850
|
-
if (this.mountTunnels.has(mountName)) return;
|
|
851
|
-
if (!this.port) throw new Error("Auth proxy not running \u2014 call ensureRunning() first");
|
|
852
|
-
const subdomainSafe = mountName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
853
|
-
const tunnelName = `static-${subdomainSafe}`;
|
|
854
|
-
const mount = this.mounts.get(mountName);
|
|
855
|
-
const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
|
|
856
|
-
try {
|
|
857
|
-
const { FrpcTunnel } = await import('./frpc-DR9BQXat.mjs');
|
|
858
|
-
let tunnel;
|
|
859
|
-
tunnel = new FrpcTunnel({
|
|
860
|
-
name: tunnelName,
|
|
861
|
-
ports: [this.port],
|
|
862
|
-
subdomains: subdomainOverride,
|
|
863
|
-
// End-to-end probe: the daemon's health loop watches probe.ok
|
|
864
|
-
// to detect ghosted tunnel registrations (frpc says "connected"
|
|
865
|
-
// but no traffic actually flows). The sentinel route is served
|
|
866
|
-
// by the auth proxy without auth.
|
|
867
|
-
probePath: "/__svamp_health",
|
|
868
|
-
probeIntervalMs: 3e4,
|
|
869
|
-
onError: (err) => this.log(`frpc error [${mountName}]: ${err.message}`),
|
|
870
|
-
onConnect: () => {
|
|
871
|
-
const url2 = tunnel.getUrls().get(this.port);
|
|
872
|
-
if (url2) {
|
|
873
|
-
try {
|
|
874
|
-
const host = new URL(url2).hostname.toLowerCase();
|
|
875
|
-
this.hostToMount.set(host, mountName);
|
|
876
|
-
} catch {
|
|
877
|
-
}
|
|
878
|
-
this.log(`frpc tunnel connected for '${mountName}'. URL: ${url2}/`);
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
onDisconnect: () => this.log(`frpc tunnel for '${mountName}' disconnected, will auto-reconnect...`),
|
|
882
|
-
onProbeFail: (err) => this.log(`probe fail [${mountName}]: ${err.message}`)
|
|
883
|
-
});
|
|
884
|
-
await tunnel.connect();
|
|
885
|
-
this.mountTunnels.set(mountName, tunnel);
|
|
886
|
-
const url = tunnel.getUrls().get(this.port);
|
|
887
|
-
if (url) {
|
|
888
|
-
try {
|
|
889
|
-
const host = new URL(url).hostname.toLowerCase();
|
|
890
|
-
this.hostToMount.set(host, mountName);
|
|
891
|
-
} catch {
|
|
892
|
-
}
|
|
893
|
-
this.log(`frpc tunnel started for '${mountName}'. URL: ${url}/`);
|
|
894
|
-
}
|
|
895
|
-
} catch (err) {
|
|
896
|
-
this.log(`Warning: could not expose mount '${mountName}' externally: ${err.message}`);
|
|
897
|
-
this.log(`Mount '${mountName}' available locally at http://127.0.0.1:${this.port}/${mountName}/`);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
export { ServeManager, buildLinkSubdomain };
|