dsh-remote-access-proxy 0.0.0-stage → 1.5.0

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.
@@ -0,0 +1,1224 @@
1
+ /**
2
+ * dsh-remote-access-proxy — embedded open RPC gateway for the DSH web GUI, as a
3
+ * host-plane plugin.
4
+ *
5
+ * - Starts/stops with DSH (plugin apply/dispose), no external tool needed.
6
+ * - Listens on every interface (easytier, ZeroTier, Tailscale, a plain LAN or
7
+ * a reverse-proxy front all work); `allowFrom` decides which client
8
+ * addresses may connect, dropping the rest at the TCP level.
9
+ * - Random-path gate, an optional password sign-in, and a signed 30-day
10
+ * remembered-device cookie in front of the DSH app and its /api.
11
+ * - Forwards to DSH with Host rewritten to loopback and Origin stripped, so
12
+ * the /api trust fence accepts any front — no per-host trustedHosts needed.
13
+ * - Forwards to the running DSH instance: `upstreamHost`/`upstreamPort` default
14
+ * to auto-detect from the live `webServer` service (the port the OS actually
15
+ * bound — the desktop launcher asks for `--port 0`, so only that service knows
16
+ * it), falling back to `webStartup` and then to 3080, so no configuration is
17
+ * needed; explicit values override.
18
+ * - Optional TLS (self-signed pfx): browsers require a secure context for
19
+ * crypto.randomUUID, so plain-HTTP remote access breaks the web client.
20
+ * - Configured from the Plugins page: the bundle's row page (sidebar → Plugins
21
+ * → dsh-remote-access-proxy → the `remote-access-proxy` row → configure).
22
+ * A volatile-only edit never remounts this plugin; it restarts the embedded
23
+ * server in place.
24
+ * - Diagnostics go to a size-bounded `access.log` beside this file: it rotates
25
+ * at `logMaxBytes` and keeps `logKeep` older files (`access.log.1`…), so a
26
+ * long-running deployment cannot grow an unbounded log.
27
+ * - Updates are managed by DSH through the npm registry.
28
+ *
29
+ * DSH browser-auth bridge (2026-09-04): DSH now authenticates every browser
30
+ * session with a per-process launch token (`http://<upstream>/?token=…`)
31
+ * plus an authority-bound signed cookie (`dsh-auth-*`); without it every /api
32
+ * request is 401. This proxy therefore performs the token exchange itself and
33
+ * forwards carrying the minted DSH cookie, so remote users keep the old gate
34
+ * UX (random path + HttpOnly cookie) and never need to know the launch token.
35
+ * The cookie is signed with a durable secret and lives 30 days, surviving DSH
36
+ * restarts; an upstream 401 drops it and triggers a fresh exchange.
37
+ *
38
+ * DSH 0.1.7 configuration model (2026-09-24): `settings.yaml` is gone. A plugin
39
+ * declares its configurable values in its own Cordis `Config`, marks the ones
40
+ * that may change without a remount `.volatile()`, and reads them through those
41
+ * references; Loader commits volatile-only changes in place and notifies this
42
+ * instance through `loader/volatile-update`. Edits persist into the active
43
+ * profile's own `cordis.patch.yml` through the configuration editor, so the
44
+ * profile entry id — not a separately registered namespace — IS the settings
45
+ * namespace. Every field below is volatile.
46
+ */
47
+
48
+ import http from "node:http"
49
+ import https from "node:https"
50
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"
51
+ import { BlockList, isIP } from "node:net"
52
+ import { appendFileSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
53
+ import { networkInterfaces } from "node:os"
54
+ import { fileURLToPath } from "node:url"
55
+ import z from "@deepseek-ai/schemastery"
56
+
57
+ export const name = "dsh-remote-access-proxy"
58
+ /** Package name the bundle row loads this plugin by (portable across installs). */
59
+ const PACKAGE = "dsh-remote-access-proxy"
60
+
61
+ /**
62
+ * Required services. `connection` carries this process's launch token. The
63
+ * settings service is deliberately NOT required: the plugin's configuration is
64
+ * its own Config, so the proxy still runs — and still serves remote clients —
65
+ * in a deployment that mounts no settings forms at all. `webStartup` (upstream
66
+ * auto-detect), `configEditor` (persisting generated secrets) and `hmr`
67
+ * (leaving the current hot-reload transaction before editing) are read
68
+ * optionally through `ctx.get`.
69
+ */
70
+ export const inject = ["connection"]
71
+
72
+ /** Profile entry id of the host row. On DSH 0.1.7+ this IS the settings namespace. */
73
+ const NS = "remote-access-proxy"
74
+ const HERE = fileURLToPath(new URL(".", import.meta.url))
75
+ /** Name of the HttpOnly cookie this proxy's gate mints. */
76
+ const GATE_COOKIE = "dsh_et_gate"
77
+ /** Authenticated status route the configuration page reads (below DSH's own `/api`). */
78
+ export const STATUS_ROUTE = "/api/remote-access-proxy/status"
79
+ /** What a gate path may contain: it is one URL segment. */
80
+ const SECRET_PATH_PATTERN = /^[A-Za-z0-9_-]{4,32}$/
81
+ /** What a gate cookie value may contain: a cookie-octet subset without separators. */
82
+ const COOKIE_VALUE_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
83
+ /** How long a remembered device stays signed in. */
84
+ const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
85
+ /** Internal routes below the gate path (the login form posts here). */
86
+ const LOGIN_ROUTE = "/__rap/login"
87
+ const LOGOUT_ROUTE = "/__rap/logout"
88
+ /** Failed password attempts allowed per client address in LOGIN_WINDOW_MS. */
89
+ const LOGIN_MAX_FAILURES = 5
90
+ const LOGIN_WINDOW_MS = 5 * 60 * 1000
91
+ /** A ready-made allow list: private LANs, CGNAT/Tailscale, IPv6 ULA and link-local. */
92
+ export const PRIVATE_NETWORKS = "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, fc00::/7, fe80::/10"
93
+ /** Rotation defaults, used when a settings object predates the log fields. */
94
+ const DEFAULT_LOG_MAX_BYTES = 1024 * 1024
95
+ const DEFAULT_LOG_KEEP = 3
96
+
97
+ /** Every configurable field, in card order. */
98
+ const FIELDS = [
99
+ "enabled",
100
+ "listenPort",
101
+ "allowFrom",
102
+ "secretPath",
103
+ "password",
104
+ "cookieValue",
105
+ "upstreamHost",
106
+ "upstreamPort",
107
+ "tlsEnabled",
108
+ "tlsPfxPath",
109
+ "tlsPassphrase",
110
+ "logMaxBytes",
111
+ "logKeep",
112
+
113
+ ]
114
+
115
+ /**
116
+ * The plugin's live configuration. Every field is `.volatile()`: a change is
117
+ * parsed and validated by Loader, committed into these stable references, and
118
+ * announced with `loader/volatile-update` — the running instance is retained,
119
+ * so the embedded server is restarted rather than re-imported. No ordinary
120
+ * field exists, so an edit never takes the remount lifecycle.
121
+ */
122
+ export const Config = z.object({
123
+ enabled: z.boolean().default(true).volatile(),
124
+ listenPort: z.number().default(13337).volatile(),
125
+ // Which client addresses may connect: IPs, CIDR blocks or a-b ranges,
126
+ // separated by commas, spaces or new lines. Empty = any address. The proxy
127
+ // always listens on every interface; loopback is always allowed.
128
+ allowFrom: z.string().default("").volatile(),
129
+ secretPath: z.string().default("").volatile(),
130
+ // Optional access password. Empty = the secret path alone opens the site.
131
+ password: z.string().default("").volatile(),
132
+ // Server-side secret that signs the remembered-device cookie. Generated, never
133
+ // shown on the page; regenerating it signs every device out.
134
+ cookieValue: z.string().default("").volatile(),
135
+ // Upstream DSH body. Empty host / port 0 mean "follow the running instance":
136
+ // the address is read at start time from the live `webServer` service (the
137
+ // bound port) and then `webStartup` (the requested one), so both `dsh web` and
138
+ // a desktop launcher started with `--port 0` work without configuration. An
139
+ // explicit value always wins.
140
+ upstreamHost: z.string().default("").volatile(),
141
+ upstreamPort: z.number().default(0).volatile(),
142
+ tlsEnabled: z.boolean().default(false).volatile(),
143
+ tlsPfxPath: z.string().default("").volatile(),
144
+ tlsPassphrase: z.string().default("").volatile(),
145
+ // access.log rotation: rotate before a write that would pass this size
146
+ // (0 = never rotate, the pre-rotation behaviour); how many rotated files to
147
+ // keep (0 = keep no history, just truncate).
148
+ logMaxBytes: z.number().default(DEFAULT_LOG_MAX_BYTES).volatile(),
149
+ logKeep: z.number().default(DEFAULT_LOG_KEEP).volatile(),
150
+ })
151
+
152
+ /** This package's own version, read fresh: after a hot swap it is the new one. */
153
+ function packageVersion() {
154
+ try {
155
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
156
+ return typeof pkg.version === "string" ? pkg.version : undefined
157
+ } catch {
158
+ return undefined
159
+ }
160
+ }
161
+
162
+ /**
163
+ * A random lowercase alphanumeric token from the OS CSPRNG. The gate path and
164
+ * cookie ARE the access control, so `Math.random` (predictable) is not enough.
165
+ * Rejection sampling keeps every character equally likely.
166
+ */
167
+ export function genToken(length) {
168
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
169
+ const limit = 256 - (256 % chars.length)
170
+ let out = ""
171
+ while (out.length < length) {
172
+ for (const byte of randomBytes(length * 2)) {
173
+ if (byte >= limit) continue
174
+ out += chars[byte % chars.length]
175
+ if (out.length === length) break
176
+ }
177
+ }
178
+ return out
179
+ }
180
+
181
+ /**
182
+ * Parse an allow list: IPs, CIDR blocks (`10.0.0.0/8`, `fd00::/8`) and ranges
183
+ * (`192.168.1.10-192.168.1.20`), separated by commas, semicolons or whitespace.
184
+ *
185
+ * @param text - the configured `allowFrom` value.
186
+ * @returns `{ entries, bad, list }`; `list` is undefined when every address may connect.
187
+ */
188
+ export function parseAllowList(text) {
189
+ const entries = String(text ?? "").split(/[\s,;]+/).filter(Boolean)
190
+ const bad = []
191
+ if (entries.length === 0) return { entries, bad, list: undefined }
192
+ const list = new BlockList()
193
+ const family = (ip) => (isIP(ip) === 6 ? "ipv6" : "ipv4")
194
+ for (const entry of entries) {
195
+ const slash = entry.indexOf("/")
196
+ const dash = entry.indexOf("-")
197
+ try {
198
+ if (slash > 0) {
199
+ const ip = entry.slice(0, slash)
200
+ const bits = Number(entry.slice(slash + 1))
201
+ const max = isIP(ip) === 6 ? 128 : 32
202
+ if (!isIP(ip) || !/^\d+$/.test(entry.slice(slash + 1)) || bits > max) throw new Error("bad cidr")
203
+ list.addSubnet(ip, bits, family(ip))
204
+ } else if (dash > 0) {
205
+ const from = entry.slice(0, dash)
206
+ const to = entry.slice(dash + 1)
207
+ if (!isIP(from) || isIP(from) !== isIP(to)) throw new Error("bad range")
208
+ list.addRange(from, to, family(from))
209
+ } else {
210
+ if (!isIP(entry)) throw new Error("bad address")
211
+ list.addAddress(entry, family(entry))
212
+ }
213
+ } catch {
214
+ bad.push(entry)
215
+ }
216
+ }
217
+ return { entries, bad, list }
218
+ }
219
+
220
+ /** A client socket address in plain form (`::ffff:1.2.3.4` becomes `1.2.3.4`). */
221
+ function plainAddress(address) {
222
+ const text = String(address ?? "")
223
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(text)
224
+ return mapped !== null ? mapped[1] : text
225
+ }
226
+
227
+ /**
228
+ * Whether a client address may connect. Loopback always may: it is this
229
+ * machine itself, and a local HTTPS reverse proxy connects from it.
230
+ */
231
+ export function addressAllowed(list, address) {
232
+ const ip = plainAddress(address)
233
+ if (ip === "::1" || ip.startsWith("127.")) return true
234
+ if (list === undefined) return true
235
+ const version = isIP(ip)
236
+ if (version === 0) return false
237
+ return list.check(ip, version === 6 ? "ipv6" : "ipv4")
238
+ }
239
+
240
+ /**
241
+ * The addresses a remote device can reach this listener on: every external
242
+ * IPv4 interface, those inside the allow list first (a VPN adapter in the
243
+ * allowed range is usually the one to use).
244
+ *
245
+ * @param list - the parsed allow list, or undefined for any address.
246
+ * @returns host literals, best first; never empty.
247
+ */
248
+ export function reachableHosts(list) {
249
+ const hosts = []
250
+ for (const list of Object.values(networkInterfaces())) {
251
+ for (const item of list ?? []) {
252
+ const v4 = item.family === "IPv4" || item.family === 4
253
+ if (v4 && !item.internal) hosts.push(item.address)
254
+ }
255
+ }
256
+ if (hosts.length === 0) return ["127.0.0.1"]
257
+ if (list === undefined) return hosts
258
+ const inside = hosts.filter((host) => list.check(host, "ipv4"))
259
+ return [...inside, ...hosts.filter((host) => !inside.includes(host))]
260
+ }
261
+
262
+ /** A remembered-device cookie value: `<expiry>.<signature>`. */
263
+ export function mintSession(settings, now = Date.now()) {
264
+ const expiry = Math.floor(now / 1000 + SESSION_TTL_SECONDS).toString(36)
265
+ return `${expiry}.${sessionSignature(settings, expiry)}`
266
+ }
267
+
268
+ /** Whether a cookie value is a live session for these gate settings. */
269
+ export function verifySession(settings, value, now = Date.now()) {
270
+ const dot = typeof value === "string" ? value.indexOf(".") : -1
271
+ if (dot <= 0) return false
272
+ const expiry = value.slice(0, dot)
273
+ if (!/^[0-9a-z]{1,12}$/.test(expiry) || parseInt(expiry, 36) * 1000 <= now) return false
274
+ const given = Buffer.from(value.slice(dot + 1))
275
+ const wanted = Buffer.from(sessionSignature(settings, expiry))
276
+ return given.length === wanted.length && timingSafeEqual(given, wanted)
277
+ }
278
+
279
+ /**
280
+ * The signing key covers the gate path and the password, so changing either —
281
+ * or regenerating the server secret — signs every remembered device out.
282
+ */
283
+ function sessionSignature(settings, expiry) {
284
+ const key = createHmac("sha256", settings.cookieValue).update(`rap-session\0${settings.secretPath}\0${settings.password ?? ""}`).digest()
285
+ return createHmac("sha256", key).update(expiry).digest("base64url")
286
+ }
287
+
288
+ /** Constant-time password comparison. */
289
+ function passwordMatches(given, wanted) {
290
+ const a = createHash("sha256").update(String(given)).digest()
291
+ const b = createHash("sha256").update(String(wanted)).digest()
292
+ return timingSafeEqual(a, b)
293
+ }
294
+
295
+ const LOGIN_COPY = {
296
+ zh: { title: "DSH 远程访问", prompt: "输入访问密码", button: "进入", wrong: "密码不对", locked: "尝试次数太多,请几分钟后再试" },
297
+ en: { title: "DSH remote access", prompt: "Enter the access password", button: "Continue", wrong: "Wrong password", locked: "Too many attempts. Try again in a few minutes." },
298
+ }
299
+
300
+ function escapeHtml(text) {
301
+ return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c])
302
+ }
303
+
304
+ /** The login page: one password field the browser can remember. */
305
+ export function loginPage({ action, next, error, lang }) {
306
+ const t = LOGIN_COPY[lang] ?? LOGIN_COPY.en
307
+ const message = error === "wrong" ? t.wrong : error === "locked" ? t.locked : ""
308
+ return `<!doctype html>
309
+ <html lang="${lang === "zh" ? "zh-CN" : "en"}"><head><meta charset="utf-8">
310
+ <meta name="viewport" content="width=device-width, initial-scale=1">
311
+ <meta name="robots" content="noindex">
312
+ <title>${escapeHtml(t.title)}</title>
313
+ <style>
314
+ :root{color-scheme:light dark;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}
315
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:Canvas;color:CanvasText}
316
+ form{width:min(320px,86vw);display:flex;flex-direction:column;gap:12px}
317
+ h1{font-size:20px;font-weight:600;margin:0 0 4px}
318
+ label{font-size:14px;opacity:.75}
319
+ input[type=password]{font-size:17px;padding:12px;border-radius:10px;border:1px solid #8886;background:transparent;color:inherit}
320
+ button{font-size:16px;padding:12px;border-radius:10px;border:0;background:#2563eb;color:#fff;font-weight:600}
321
+ p{margin:0;color:#dc2626;font-size:14px}
322
+ </style></head><body>
323
+ <form method="post" action="${escapeHtml(action)}">
324
+ <h1>${escapeHtml(t.title)}</h1>
325
+ <input type="text" name="username" value="dsh" autocomplete="username" hidden>
326
+ <label for="password">${escapeHtml(t.prompt)}</label>
327
+ <input id="password" type="password" name="password" autocomplete="current-password" autofocus required>
328
+ <input type="hidden" name="next" value="${escapeHtml(next)}">
329
+ ${message ? `<p role="alert">${escapeHtml(message)}</p>` : ""}
330
+ <button type="submit">${escapeHtml(t.button)}</button>
331
+ </form></body></html>
332
+ `
333
+ }
334
+
335
+ /**
336
+ * Why a resolved configuration cannot start. `code` lets the page say it in the
337
+ * user's language; `message` is the English line for the log.
338
+ *
339
+ * @param settings - resolved settings (secrets already generated).
340
+ * @returns `{ code, message }`, or undefined when the values are usable.
341
+ */
342
+ export function configProblem(settings) {
343
+ const port = settings.listenPort
344
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { code: "bad-port", message: `listenPort must be an integer from 1 to 65535 (got ${port})` }
345
+ if (!SECRET_PATH_PATTERN.test(settings.secretPath)) return { code: "bad-secret-path", message: "secretPath must be 4–32 letters, digits, '-' or '_'" }
346
+ if (!COOKIE_VALUE_PATTERN.test(settings.cookieValue)) return { code: "bad-cookie", message: "cookieValue must be 8–128 letters, digits, '-' or '_'" }
347
+ const allow = parseAllowList(settings.allowFrom)
348
+ if (allow.bad.length > 0) return { code: "bad-allow", detail: allow.bad.join(", "), message: `allowFrom entries are not an IP, CIDR block or range: ${allow.bad.join(", ")}` }
349
+ if (typeof settings.password === "string" && settings.password.length > 256) return { code: "bad-password", message: "password must be at most 256 characters" }
350
+ if (settings.tlsEnabled && !settings.tlsPfxPath) return { code: "tls-no-cert", message: "tlsEnabled needs tlsPfxPath (a .pfx certificate file)" }
351
+ return undefined
352
+ }
353
+
354
+ /** Byte size of a file, or 0 when it does not exist. */
355
+ function sizeOf(path) {
356
+ try {
357
+ return statSync(path).size
358
+ } catch {
359
+ return 0
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Shift the log ring one step: `<base>` → `<base>.1` → … → `<base>.<keep>`, the
365
+ * oldest file dropped. Destinations are always freed before a rename, because
366
+ * Windows `rename` refuses to overwrite an existing file.
367
+ *
368
+ * @param base - absolute path of the live log file.
369
+ * @param keep - rotated files to keep; `<= 0` truncates instead of archiving.
370
+ */
371
+ function rotateLog(base, keep) {
372
+ if (keep <= 0) {
373
+ try {
374
+ writeFileSync(base, "")
375
+ } catch {
376
+ /* best effort */
377
+ }
378
+ return
379
+ }
380
+ try {
381
+ rmSync(`${base}.${keep}`, { force: true })
382
+ } catch {
383
+ /* best effort */
384
+ }
385
+ for (let i = keep - 1; i >= 1; i -= 1) {
386
+ try {
387
+ renameSync(`${base}.${i}`, `${base}.${i + 1}`)
388
+ } catch {
389
+ /* that ring slot is empty */
390
+ }
391
+ }
392
+ try {
393
+ renameSync(base, `${base}.1`)
394
+ } catch {
395
+ /* nothing to rotate yet */
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Append one timestamped line to `access.log`, rotating by size first.
401
+ *
402
+ * @param dir - directory holding the log (the plugin's own directory).
403
+ * @param line - the message, without the timestamp or newline.
404
+ * @param maxBytes - rotate before a write that would pass this size; `0` disables.
405
+ * @param keep - rotated files to keep.
406
+ */
407
+ function writeLogLine(dir, line, maxBytes, keep) {
408
+ const base = dir + "access.log"
409
+ const entry = `[${new Date().toISOString()}] ${line}\n`
410
+ const cap = typeof maxBytes === "number" ? maxBytes : DEFAULT_LOG_MAX_BYTES
411
+ if (cap > 0 && sizeOf(base) + Buffer.byteLength(entry) > cap) {
412
+ rotateLog(base, typeof keep === "number" ? keep : DEFAULT_LOG_KEEP)
413
+ }
414
+ appendFileSync(base, entry)
415
+ }
416
+
417
+ /**
418
+ * Build the gate + forward server for one resolved settings value.
419
+ *
420
+ * @param settings - resolved proxy settings (gate + upstream).
421
+ * @param log - `(line) => void` diagnostic sink.
422
+ * @param getDshCookie - `() => Promise<string | undefined>`; the proxy-held DSH
423
+ * session cookie, exchanged from the launch token on first need.
424
+ * @param onUpstream401 - invoked when the upstream answers 401 to a gated
425
+ * request (stale DSH cookie); drop it and refresh in the background.
426
+ * @param getUpstream - `() => ({ host, port })`; read per request, so an
427
+ * auto-detected address can still change after the OS binds its port.
428
+ */
429
+ function buildServer(settings, log, getDshCookie, onUpstream401, onDenied, getUpstream) {
430
+ const { secretPath } = settings
431
+ const upstreamHost = () => getUpstream().host
432
+ const upstreamPort = () => getUpstream().port
433
+ /** Every live socket, both sides of a bridged websocket included, so a stop can end them all. */
434
+ const sockets = new Set()
435
+ const track = (socket) => {
436
+ sockets.add(socket)
437
+ socket.once("close", () => sockets.delete(socket))
438
+ }
439
+ const prefix = "/" + secretPath
440
+ const password = settings.password || ""
441
+ const allow = parseAllowList(settings.allowFrom).list
442
+ /** Failed logins per client address: timestamps inside LOGIN_WINDOW_MS. */
443
+ const failures = new Map()
444
+
445
+ /** Whether the request carries a live remembered-device cookie. */
446
+ function hasGate(headers) {
447
+ const raw = headers.cookie || ""
448
+ for (const part of raw.split(";")) {
449
+ const idx = part.indexOf("=")
450
+ if (idx < 0) continue
451
+ if (part.slice(0, idx).trim() === GATE_COOKIE && verifySession(settings, part.slice(idx + 1).trim())) return true
452
+ }
453
+ return false
454
+ }
455
+
456
+ function sessionCookie() {
457
+ return `${GATE_COOKIE}=${mintSession(settings)}; Path=/; Max-Age=${SESSION_TTL_SECONDS}; HttpOnly; SameSite=Strict${settings.tlsEnabled ? "; Secure" : ""}`
458
+ }
459
+
460
+ /** A page load (not a fetch or an asset): the place to show the login form. */
461
+ function isDocument(req) {
462
+ const dest = req.headers["sec-fetch-dest"]
463
+ if (typeof dest === "string") return dest === "document"
464
+ return String(req.headers.accept || "").includes("text/html")
465
+ }
466
+
467
+ function language(req) {
468
+ return /^\s*zh/i.test(String(req.headers["accept-language"] || "")) ? "zh" : "en"
469
+ }
470
+
471
+ /** Where to go after login: only somewhere under the gate path. */
472
+ function safeNext(value) {
473
+ return typeof value === "string" && value.startsWith(prefix + "/") && !value.startsWith(prefix + "/__rap/") ? value : prefix + "/"
474
+ }
475
+
476
+ function recentFailures(address) {
477
+ const now = Date.now()
478
+ const list = (failures.get(address) ?? []).filter((at) => now - at < LOGIN_WINDOW_MS)
479
+ if (list.length > 0) failures.set(address, list)
480
+ else failures.delete(address)
481
+ return list
482
+ }
483
+
484
+ function sendLogin(req, res, status, next, error) {
485
+ res.writeHead(status, {
486
+ "content-type": "text/html; charset=utf-8",
487
+ "cache-control": "no-store",
488
+ "x-frame-options": "DENY",
489
+ "referrer-policy": "no-referrer",
490
+ })
491
+ res.end(req.method === "HEAD" ? undefined : loginPage({ action: prefix + LOGIN_ROUTE, next, error, lang: language(req) }))
492
+ }
493
+
494
+ function handleLogin(req, res) {
495
+ const address = plainAddress(req.socket.remoteAddress)
496
+ let body = ""
497
+ let tooBig = false
498
+ req.setEncoding("utf8")
499
+ req.on("data", (chunk) => {
500
+ body += chunk
501
+ if (body.length > 8192) {
502
+ tooBig = true
503
+ req.destroy()
504
+ }
505
+ })
506
+ req.on("end", () => {
507
+ if (tooBig) return
508
+ const form = new URLSearchParams(body)
509
+ const next = safeNext(form.get("next"))
510
+ if (recentFailures(address).length >= LOGIN_MAX_FAILURES) {
511
+ log(`LOGIN-LOCKED ${address}`)
512
+ sendLogin(req, res, 429, next, "locked")
513
+ return
514
+ }
515
+ if (password !== "" && passwordMatches(form.get("password") ?? "", password)) {
516
+ failures.delete(address)
517
+ log(`LOGIN ${address}`)
518
+ res.writeHead(303, { location: next, "set-cookie": sessionCookie(), "cache-control": "no-store" })
519
+ res.end()
520
+ return
521
+ }
522
+ failures.set(address, [...recentFailures(address), Date.now()])
523
+ log(`LOGIN-FAIL ${address}`)
524
+ // A short pause makes guessing slow without bothering a person.
525
+ setTimeout(() => sendLogin(req, res, 401, next, "wrong"), 400)
526
+ })
527
+ }
528
+
529
+ function routePath(pathname) {
530
+ if (pathname === prefix) return "/"
531
+ if (pathname.startsWith(prefix + "/")) return pathname.slice(prefix.length)
532
+ return undefined
533
+ }
534
+
535
+ /**
536
+ * Present any request to DSH as loopback: the /api trust fence accepts it no
537
+ * matter which VPN/reverse-proxy front this proxy sits behind (stripping
538
+ * Origin satisfies the fence's same-origin check). The proxy-held DSH cookie
539
+ * satisfies the browser-auth gate, and replacing the client's own Cookie
540
+ * header keeps it from reaching DSH. Security is still our gate in front.
541
+ */
542
+ function upstreamHeaders(req, cookie) {
543
+ const headers = { ...req.headers }
544
+ headers.host = `${upstreamHost()}:${upstreamPort()}`
545
+ delete headers.origin
546
+ if (cookie === undefined) delete headers.cookie
547
+ else headers.cookie = cookie
548
+ return headers
549
+ }
550
+
551
+ /**
552
+ * Upstream path with the launch token stripped — the proxy owns the DSH cookie.
553
+ *
554
+ * Operates on the RAW query string: DSH's client-plugin bundler serves bundles
555
+ * at `/plugins/??<id>,<id>&rev=<hash>`, a scheme whose `?`, `,`, `@` and `/`
556
+ * must reach the upstream verbatim. A URL/searchParams round-trip percent-
557
+ * encodes them (`??id` -> `?%3Fid`), the bundle 404s, and every client plugin
558
+ * (incl. the `@deepseek-ai/dsh-client-modules` bootstrap) fails to preload.
559
+ * So drop only the `token=` segment, textually, and touch nothing else.
560
+ */
561
+ function upstreamTarget(path, search) {
562
+ if (!search || search.indexOf("token=") === -1) return path + search
563
+ const lead = /^\?+/.exec(search)?.[0] ?? "?"
564
+ const kept = search.slice(lead.length).split("&").filter((part) => !part.startsWith("token="))
565
+ return kept.length > 0 ? path + lead + kept.join("&") : path
566
+ }
567
+
568
+ async function forward(req, res, path, search, gated, setGate) {
569
+ const cookie = await getDshCookie()
570
+ const upstreamReq = http.request(
571
+ {
572
+ hostname: upstreamHost(),
573
+ port: upstreamPort(),
574
+ method: req.method,
575
+ path: upstreamTarget(path, search),
576
+ headers: upstreamHeaders(req, cookie),
577
+ },
578
+ (upRes) => {
579
+ if (upRes.statusCode === 401) onUpstream401?.()
580
+ const outHeaders = { ...upRes.headers }
581
+ if (gated) {
582
+ // Defensive: today the token is always stripped, so DSH never redirects
583
+ // on the gate path; should it ever answer a root-relative Location,
584
+ // re-enter through the gate instead of the ungated origin root.
585
+ if (typeof outHeaders.location === "string" && outHeaders.location.startsWith("/")) {
586
+ outHeaders.location = prefix + outHeaders.location
587
+ }
588
+ }
589
+ // Each page load renews the remembered device for another 30 days.
590
+ if (setGate) outHeaders["set-cookie"] = sessionCookie()
591
+ res.writeHead(upRes.statusCode || 502, outHeaders)
592
+ upRes.pipe(res)
593
+ },
594
+ )
595
+ upstreamReq.on("error", (err) => {
596
+ log(`upstream error ${err.code || err.message}`)
597
+ if (!res.headersSent) {
598
+ res.writeHead(502, { "content-type": "text/plain" })
599
+ res.end("bad gateway\n")
600
+ } else res.destroy()
601
+ })
602
+ req.pipe(upstreamReq)
603
+ }
604
+
605
+ const handler = (req, res) => {
606
+ const url = new URL(req.url, "http://" + (req.headers.host || "local"))
607
+ const path = routePath(url.pathname)
608
+ if (url.pathname === prefix && (req.method === "GET" || req.method === "HEAD")) {
609
+ // The client resolves its assets against `<base href="./">`: without the
610
+ // trailing slash they would land outside the gate path.
611
+ log(`ENTRY ${req.method} ${req.url} -> ${prefix}/`)
612
+ res.writeHead(308, { location: prefix + "/" + url.search })
613
+ res.end()
614
+ return
615
+ }
616
+ if (path === LOGIN_ROUTE && req.method === "POST") return handleLogin(req, res)
617
+ if (path === LOGOUT_ROUTE) {
618
+ log(`LOGOUT ${plainAddress(req.socket.remoteAddress)}`)
619
+ res.writeHead(303, { location: prefix + "/", "set-cookie": `${GATE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`, "cache-control": "no-store" })
620
+ res.end()
621
+ return
622
+ }
623
+ const authed = hasGate(req.headers)
624
+ if (path !== undefined) {
625
+ if (password === "" || authed) {
626
+ log(`ENTRY ${req.method} ${req.url}`)
627
+ return forward(req, res, path, url.search, true, isDocument(req) || !authed)
628
+ }
629
+ if ((req.method === "GET" || req.method === "HEAD") && isDocument(req)) {
630
+ log(`LOGIN-PAGE ${req.method} ${req.url}`)
631
+ return sendLogin(req, res, 200, safeNext(url.pathname + url.search))
632
+ }
633
+ log(`UNAUTH ${req.method} ${req.url}`)
634
+ res.writeHead(401, { "content-type": "text/plain" })
635
+ res.end("sign in first\n")
636
+ return
637
+ }
638
+ if (authed) {
639
+ log(`PASS ${req.method} ${req.url}`)
640
+ return forward(req, res, url.pathname, url.search, false, false)
641
+ }
642
+ log(`DENY ${req.method} ${req.url}`)
643
+ res.writeHead(403, { "content-type": "text/plain" })
644
+ res.end("forbidden\n")
645
+ }
646
+
647
+ const handleUpgrade = async (req, socket, head) => {
648
+ const url = new URL(req.url, "http://" + (req.headers.host || "local"))
649
+ const path = routePath(url.pathname)
650
+ const authed = hasGate(req.headers)
651
+ const allowed = authed || (path !== undefined && password === "")
652
+ if (!allowed) {
653
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
654
+ socket.destroy()
655
+ return
656
+ }
657
+ const target = path !== undefined ? path : url.pathname
658
+ const cookie = await getDshCookie()
659
+ const upstreamReq = http.request({
660
+ hostname: upstreamHost(),
661
+ port: upstreamPort(),
662
+ method: req.method,
663
+ path: upstreamTarget(target, url.search),
664
+ headers: upstreamHeaders(req, cookie),
665
+ })
666
+ upstreamReq.on("upgrade", (upRes, upSocket, upHead) => {
667
+ log(`WS-UPGRADE ${req.url}`)
668
+ socket.write("HTTP/1.1 101 Switching Protocols\r\n")
669
+ for (const [key, value] of Object.entries(upRes.headers)) {
670
+ if (Array.isArray(value)) value.forEach((item) => socket.write(`${key}: ${item}\r\n`))
671
+ else socket.write(`${key}: ${value}\r\n`)
672
+ }
673
+ socket.write("\r\n")
674
+ if (upHead && upHead.length) socket.write(upHead)
675
+ track(upSocket)
676
+ upSocket.pipe(socket)
677
+ socket.pipe(upSocket)
678
+ socket.on("error", () => upSocket.destroy())
679
+ upSocket.on("error", () => socket.destroy())
680
+ })
681
+ upstreamReq.on("error", (err) => {
682
+ log(`WS upstream error ${err.code || err.message}`)
683
+ socket.destroy()
684
+ })
685
+ if (head && head.length) upstreamReq.write(head)
686
+ upstreamReq.end()
687
+ }
688
+
689
+ // TLS (self-signed cert) when enabled: browsers require a secure context for
690
+ // crypto.randomUUID, so plain-HTTP remote access breaks the web client.
691
+ let server
692
+ if (settings.tlsEnabled) {
693
+ try {
694
+ server = https.createServer(
695
+ { pfx: readFileSync(settings.tlsPfxPath), passphrase: settings.tlsPassphrase || "" },
696
+ handler,
697
+ )
698
+ } catch (err) {
699
+ log(`TLS init failed (${err.message}); proxy NOT started — set tlsEnabled=false for plain HTTP, or fix tlsPfxPath/tlsPassphrase`)
700
+ return { code: "tls-load", error: `TLS certificate could not be loaded: ${err.message}` }
701
+ }
702
+ } else {
703
+ server = http.createServer(handler)
704
+ }
705
+ server.on("upgrade", handleUpgrade)
706
+ // The allow list acts like a firewall: a client outside it is dropped before
707
+ // any byte (or TLS handshake) is exchanged, so a scan learns nothing.
708
+ server.on("connection", (socket) => {
709
+ if (addressAllowed(allow, socket.remoteAddress)) {
710
+ track(socket)
711
+ return
712
+ }
713
+ const address = plainAddress(socket.remoteAddress)
714
+ log(`DENY-ADDRESS ${address}`)
715
+ onDenied?.(address)
716
+ socket.destroy()
717
+ })
718
+
719
+ /**
720
+ * Stop listening. `hard` also ends every open connection at once — keep-alive
721
+ * sockets, requests in flight and bridged websockets — so nothing of this
722
+ * instance outlives it (a hot swap disposes it; the browser reconnects to the
723
+ * new version). A configuration restart is soft: it frees the port and closes
724
+ * idle keep-alive sockets, and a websocket open now keeps running.
725
+ */
726
+ function stop(hard) {
727
+ try {
728
+ server.close()
729
+ } catch {
730
+ /* already closed */
731
+ }
732
+ if (hard) {
733
+ try {
734
+ server.closeAllConnections?.()
735
+ } catch {
736
+ /* best effort */
737
+ }
738
+ for (const socket of sockets) socket.destroy()
739
+ sockets.clear()
740
+ } else {
741
+ try {
742
+ server.closeIdleConnections?.()
743
+ } catch {
744
+ /* best effort */
745
+ }
746
+ }
747
+ }
748
+
749
+ return { server, stop }
750
+ }
751
+
752
+ /**
753
+ * Mount the proxy and keep it in step with the plugin's live configuration.
754
+ *
755
+ * @param ctx - the plugin context (`connection` is injected).
756
+ * @param config - the schema-parsed Config: one `Volatile` reference per field.
757
+ */
758
+ export function apply(ctx, config) {
759
+ let server = undefined
760
+ /** Stops the running server (soft or hard); see buildServer. */
761
+ let stopServer = undefined
762
+ let current = undefined
763
+ let dshCookie = undefined
764
+ let exchanging = undefined
765
+ /**
766
+ * What the configuration page shows: whether the listener is up and, if not,
767
+ * why. `state` is one of starting | listening | disabled | error.
768
+ */
769
+ let status = { state: "starting" }
770
+ /** The last client address the allow list turned away, for the page. */
771
+ let lastDenied = undefined
772
+ /**
773
+ * This package's installed version. Read on every use, not once: an update
774
+ * whose host file is unchanged (only the page or the metadata changed) is
775
+ * installed without remounting this instance, and the page must still see
776
+ * the new number.
777
+ */
778
+ const installedVersion = () => packageVersion()
779
+ const version = installedVersion()
780
+ // This plugin ships its own page for its row; tell the settings service not to
781
+ // offer a generated one. Optional: a deployment without settings still runs.
782
+ ctx.inject(["settings"], (child) => {
783
+ child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), "dsh-remote-access-proxy: page policy")
784
+ })
785
+
786
+ function log(line) {
787
+ try {
788
+ writeLogLine(HERE, line, current?.logMaxBytes, current?.logKeep)
789
+ } catch {
790
+ /* best effort */
791
+ }
792
+ }
793
+
794
+ /** Record a failure the page should show, and say it on the DSH console too. */
795
+ function fail(message, code = "error", detail) {
796
+ status = { state: "error", code, error: message, ...(detail !== undefined ? { detail } : {}) }
797
+ log(message)
798
+ ctx.logger?.warn?.("remote-access-proxy: %s", message)
799
+ }
800
+
801
+ /** The status document the configuration page renders. */
802
+ function statusDocument() {
803
+ const settings = current
804
+ const doc = { ...status, version: installedVersion() }
805
+ if (settings !== undefined) {
806
+ const scheme = settings.tlsEnabled ? "https" : "http"
807
+ doc.scheme = scheme
808
+ const allow = parseAllowList(settings.allowFrom)
809
+ doc.listenPort = settings.listenPort
810
+ // The page must show the address requests actually go to, not the one
811
+ // resolved at start time (an auto-detected port can still have been unbound).
812
+ const live = upstreamNow()
813
+ doc.upstream = `${live.host}:${live.port}`
814
+ doc.tls = settings.tlsEnabled === true
815
+ doc.password = typeof settings.password === "string" && settings.password !== ""
816
+ doc.allowFrom = allow.bad.length === 0 ? allow.entries : []
817
+ if (lastDenied !== undefined) doc.lastDenied = lastDenied
818
+ doc.urls = reachableHosts(allow.bad.length === 0 ? allow.list : undefined).map((host) => `${scheme}://${host.includes(":") ? `[${host}]` : host}:${settings.listenPort}/${settings.secretPath}/`)
819
+ }
820
+ return doc
821
+ }
822
+
823
+ // The page reads the live status through DSH's own authenticated /api channel,
824
+ // so it is exactly as private as the rest of the API.
825
+ if (typeof ctx.connection?.fetch?.register === "function") {
826
+ ctx.effect(() => {
827
+ const dispose = ctx.connection.fetch.register({
828
+ path: STATUS_ROUTE,
829
+ methods: ["GET"],
830
+ requestBody: "buffered",
831
+ fetch: async () => new Response(JSON.stringify(statusDocument()), {
832
+ status: 200,
833
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
834
+ }),
835
+ })
836
+ return () => {
837
+ void Promise.resolve(typeof dispose === "function" ? dispose() : undefined).catch(() => {})
838
+ }
839
+ }, "dsh-remote-access-proxy: status route")
840
+ }
841
+
842
+ /** Read one `.get()` per configured field; an absent field keeps its schema default. */
843
+ function readConfig() {
844
+ const parsed = config ?? Config({})
845
+ const values = {}
846
+ for (const field of FIELDS) {
847
+ const ref = parsed[field]
848
+ values[field] = ref !== undefined && typeof ref.get === "function" ? ref.get() : ref
849
+ }
850
+ return values
851
+ }
852
+
853
+ /**
854
+ * Write generated values back into the active profile's plugin configuration.
855
+ *
856
+ * The values live in the profile's own `cordis.patch.yml`, so the write goes
857
+ * through the configuration editor — once the Loader has settled *and* outside
858
+ * the hot-reload transaction that is mounting this plugin, because the editor
859
+ * refuses to run re-entrantly ("HMR transactions cannot be nested").
860
+ * Without an editor (a profile-less run) the generated values stay
861
+ * process-local and change on the next start.
862
+ */
863
+ function persistConfig(patch) {
864
+ const entry = ctx.fiber?.entry
865
+ const editor = ctx.get?.("configEditor")
866
+ if (entry === undefined || editor === undefined) {
867
+ log("no profile configuration editor; generated values are process-local")
868
+ return
869
+ }
870
+ // HMR's guard rejects re-entrant edits with this exact message; another
871
+ // transaction (ours, or a peer plugin's) may still be in flight.
872
+ const NESTED = /HMR transactions cannot be nested/i
873
+ const MAX_ATTEMPTS = 5
874
+ const write = (attempts = MAX_ATTEMPTS) => {
875
+ editor.edit(entry, (existing = {}) => ({ ...existing, ...patch })).catch((error) => {
876
+ const message = error?.message ?? String(error)
877
+ if (attempts > 0 && NESTED.test(message)) {
878
+ // Retry outside the current async store rather than dropping the value.
879
+ setTimeout(() => outside(() => write(attempts - 1)), 250)
880
+ return
881
+ }
882
+ log(`configuration write failed (${error?.message ?? error}); generated values are process-local`)
883
+ })
884
+ }
885
+ // `configEditor.edit` runs through `hmr.runExclusive`, and this plugin's
886
+ // apply()/commit runs *inside* HMR's transaction: the guard rejects with
887
+ // "HMR transactions cannot be nested". AsyncLocalStorage keeps the store on
888
+ // every continuation the transaction started — including one chained off
889
+ // `loader.await()` — so leaving the context explicitly is the only way out
890
+ // (the loader's own config watcher does the same). `runExclusive` then
891
+ // queues the edit behind the in-flight transaction.
892
+ const hmr = ctx.get?.("hmr")
893
+ const outside = hmr?.executing !== undefined && typeof hmr.executing.exit === "function"
894
+ ? (work) => hmr.executing.exit(work)
895
+ : (work) => work()
896
+ const loader = ctx.root?.loader
897
+ const settled = loader !== undefined && typeof loader.await === "function" ? loader.await() : Promise.resolve()
898
+ settled.then(() => outside(write), () => outside(write))
899
+ }
900
+
901
+ /** A port that can actually be connected to; `0` is the OS's "pick a free one". */
902
+ const usablePort = (value) => Number.isInteger(value) && value > 0 && value < 65536
903
+
904
+ /**
905
+ * The running web server, whose `port` is the port the OS really bound. The
906
+ * strict read is tried first; a non-strict one still answers while the
907
+ * `webserver` row has not reached ACTIVE, and its `port` stays `undefined`
908
+ * until `listen` binds — hence `usablePort` rather than a plain truthiness test.
909
+ */
910
+ function runningWebServer() {
911
+ return ctx.get?.("webServer") ?? ctx.get?.("webServer", false)
912
+ }
913
+
914
+ /**
915
+ * Resolve the upstream address this proxy fronts.
916
+ *
917
+ * Both fields may be left unset (empty host / port 0) to follow the running
918
+ * instance. The authoritative source is the `webServer` service, which
919
+ * publishes the port the OS actually bound; the desktop launcher passes
920
+ * `--port 0` ("let the OS pick one"), so its connectable port exists only
921
+ * there. `webStartup` carries the *requested* address instead — still 0 on the
922
+ * desktop, but the real port under `dsh web` — so it is the fallback. An
923
+ * explicit value always wins; the last resort is the historical loopback
924
+ * 127.0.0.1:3080.
925
+ *
926
+ * @returns `{ upstreamHost, upstreamPort, known }`, where `known` says whether
927
+ * a live address was found (rather than the historical fallback).
928
+ * @param quiet - skip the log line; used by the per-request re-resolution,
929
+ * which must not write to the log on every request.
930
+ */
931
+ function resolveUpstream(settings, quiet = false) {
932
+ const bound = runningWebServer()
933
+ const live = ctx.get?.("webStartup")
934
+ let host = settings.upstreamHost || bound?.host || live?.host || "127.0.0.1"
935
+ // A bind address is not a connect address: 0.0.0.0/:: mean "every host".
936
+ if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "*") host = "127.0.0.1"
937
+ const boundPort = usablePort(bound?.port) ? bound.port : undefined
938
+ const livePort = usablePort(live?.port) ? live.port : undefined
939
+ const known = Boolean(settings.upstreamPort) || boundPort !== undefined || livePort !== undefined
940
+ const port = settings.upstreamPort || boundPort || livePort || 3080
941
+ if (!quiet && (!settings.upstreamHost || !settings.upstreamPort)) {
942
+ log(`upstream ${host}:${port}${known ? " (auto)" : " (unavailable, using the 3080 fallback)"}`)
943
+ }
944
+ return { upstreamHost: host, upstreamPort: port, known }
945
+ }
946
+
947
+ /**
948
+ * The settings a resolution re-reads later, while the address is auto-detected.
949
+ * `undefined` once an explicit address is configured — the value is then fixed.
950
+ */
951
+ let upstreamAuto
952
+
953
+ /**
954
+ * The address to forward to right now.
955
+ *
956
+ * An auto-detected address is re-resolved on every request rather than fixed at
957
+ * start time: a `--port 0` host (the desktop launcher) publishes its bound port
958
+ * only after the OS has assigned one, and nothing orders the `webserver` row
959
+ * before this plugin's, so the value read at start time can still be the 3080
960
+ * fallback — which is what answered 502 bad gateway to every remote request.
961
+ * Re-resolving removes the start-order dependency entirely; an explicit
962
+ * address is never re-read.
963
+ */
964
+ function upstreamNow() {
965
+ if (upstreamAuto === undefined || current === undefined) {
966
+ return current === undefined
967
+ ? { host: "127.0.0.1", port: 3080 }
968
+ : { host: current.upstreamHost, port: current.upstreamPort }
969
+ }
970
+ const live = resolveUpstream(upstreamAuto, true)
971
+ if (live.known && (live.upstreamHost !== current.upstreamHost || live.upstreamPort !== current.upstreamPort)) {
972
+ log(`upstream ${live.upstreamHost}:${live.upstreamPort} (auto)`)
973
+ current = { ...current, upstreamHost: live.upstreamHost, upstreamPort: live.upstreamPort }
974
+ }
975
+ return { host: current.upstreamHost, port: current.upstreamPort }
976
+ }
977
+
978
+ function ensureSecrets(settings) {
979
+ const patch = {}
980
+ if (!settings.secretPath) patch.secretPath = genToken(12)
981
+ if (!settings.cookieValue) patch.cookieValue = genToken(24)
982
+ if (Object.keys(patch).length > 0) {
983
+ persistConfig(patch)
984
+ return { ...settings, ...patch }
985
+ }
986
+ return settings
987
+ }
988
+
989
+ /** The launch token of this DSH process, read from the connection service. */
990
+ function launchToken(host, port) {
991
+ try {
992
+ const base = `http://${host}:${port}`
993
+ return new URL(ctx.connection.authenticatedUrl(base)).searchParams.get("token") || undefined
994
+ } catch (err) {
995
+ log(`launch token unavailable (${err.message})`)
996
+ return undefined
997
+ }
998
+ }
999
+
1000
+ /** Exchange the launch token for DSH's authority-bound session cookie. */
1001
+ function exchangeDshCookie(settings) {
1002
+ // The cookie is fetched from the address requests actually go to: at start
1003
+ // time an auto-detected port may not be bound yet, and an exchange that ran
1004
+ // against the 3080 fallback would simply never produce a cookie.
1005
+ const { host, port } = upstreamNow()
1006
+ const token = launchToken(host, port)
1007
+ if (!token) return Promise.resolve(undefined)
1008
+ return new Promise((resolve) => {
1009
+ const req = http.request(
1010
+ { hostname: host, port, method: "GET", path: "/?token=" + encodeURIComponent(token), headers: { host: `${host}:${port}` } },
1011
+ (res) => {
1012
+ const setCookie = res.headers["set-cookie"]
1013
+ res.resume()
1014
+ if (res.statusCode === 303 && Array.isArray(setCookie)) {
1015
+ for (const line of setCookie) {
1016
+ const pair = line.split(";")[0]
1017
+ if (pair.startsWith("dsh-auth-")) {
1018
+ dshCookie = pair
1019
+ log("DSH session cookie acquired")
1020
+ return resolve(pair)
1021
+ }
1022
+ }
1023
+ }
1024
+ log(`DSH session exchange returned ${res.statusCode || "no status"}`)
1025
+ resolve(undefined)
1026
+ },
1027
+ )
1028
+ req.on("error", (err) => {
1029
+ log(`DSH session exchange error ${err.code || err.message}`)
1030
+ resolve(undefined)
1031
+ })
1032
+ req.end()
1033
+ })
1034
+ }
1035
+
1036
+ /** Return the held DSH cookie, exchanging on first need (concurrent-safe). */
1037
+ function getDshCookie() {
1038
+ if (dshCookie !== undefined) return Promise.resolve(dshCookie)
1039
+ if (exchanging !== undefined) return exchanging
1040
+ if (current === undefined) return Promise.resolve(undefined)
1041
+ exchanging = exchangeDshCookie(current).finally(() => {
1042
+ exchanging = undefined
1043
+ })
1044
+ return exchanging
1045
+ }
1046
+
1047
+ /** Upstream 401 means our DSH cookie is stale; drop and refresh it. */
1048
+ function onUpstream401() {
1049
+ if (dshCookie === undefined) return
1050
+ dshCookie = undefined
1051
+ log("DSH 401 observed; refreshing session cookie")
1052
+ void getDshCookie()
1053
+ }
1054
+
1055
+ /**
1056
+ * Releases before 1.2 named the row by a relative path (`./plugin/…mjs`). DSH
1057
+ * resolved it to an absolute file URL, and the configuration editor copied that
1058
+ * URL into the user patch together with the generated gate values. The bundle
1059
+ * now names the row by its package, and a user row naming a different module
1060
+ * no longer applies to it — the saved gate path and cookie would be silently
1061
+ * replaced by fresh ones, and the address every device bookmarked would die.
1062
+ *
1063
+ * So rewrite such a stale row's `name:` to the package, synchronously, before
1064
+ * the first start. The loader's config watcher then reconciles the row and the
1065
+ * saved values arrive as an ordinary volatile update.
1066
+ *
1067
+ * @returns true when the user patch was rewritten.
1068
+ */
1069
+ function healUserPatch() {
1070
+ const path = ctx.get?.("configEditor")?.documentPath
1071
+ if (typeof path !== "string") return false
1072
+ let text
1073
+ try {
1074
+ text = readFileSync(path, "utf8")
1075
+ } catch {
1076
+ return false
1077
+ }
1078
+ let inRow = false
1079
+ let changed = false
1080
+ const lines = text.split("\n").map((line) => {
1081
+ const id = /^\s*-\s+id:\s*(['"]?)([^'"\s]+)\1\s*$/.exec(line)
1082
+ if (id !== null) {
1083
+ inRow = id[2] === NS
1084
+ return line
1085
+ }
1086
+ if (/^\s*-\s/.test(line)) inRow = false
1087
+ if (!inRow) return line
1088
+ const match = /^(\s*)name:\s*(.*?)\s*$/.exec(line)
1089
+ if (match === null) return line
1090
+ const value = match[2].replace(/^(['"])(.*)\1$/, "$2").replace(/\\/g, "/")
1091
+ if (value === PACKAGE || !value.endsWith("plugin/dsh-remote-access-proxy.mjs")) return line
1092
+ changed = true
1093
+ return `${match[1]}name: ${PACKAGE}${line.endsWith("\r") ? "\r" : ""}`
1094
+ })
1095
+ if (!changed) return false
1096
+ try {
1097
+ writeFileSync(path, lines.join("\n"))
1098
+ log(`migrated the row's module name to "${PACKAGE}" in ${path}`)
1099
+ return true
1100
+ } catch (error) {
1101
+ log(`could not migrate the row's module name (${error.message})`)
1102
+ return false
1103
+ }
1104
+ }
1105
+
1106
+ function start(settings) {
1107
+ stop(false)
1108
+ if (awaitingMigration && (!settings.secretPath || !settings.cookieValue)) {
1109
+ status = { state: "starting" }
1110
+ log("waiting for the migrated configuration before starting")
1111
+ return
1112
+ }
1113
+ const upstream = resolveUpstream(settings)
1114
+ // An auto-detected address stays a live query (an explicit one is fixed), so
1115
+ // a resolution that raced the OS binding its port heals on the next request.
1116
+ upstreamAuto = !settings.upstreamHost || !settings.upstreamPort ? settings : undefined
1117
+ const resolved = ensureSecrets({
1118
+ ...settings,
1119
+ upstreamHost: upstream.upstreamHost,
1120
+ upstreamPort: upstream.upstreamPort,
1121
+ })
1122
+ current = resolved
1123
+ if (!resolved.enabled) {
1124
+ status = { state: "disabled" }
1125
+ log("disabled")
1126
+ return
1127
+ }
1128
+ const problem = configProblem(resolved)
1129
+ if (problem !== undefined) {
1130
+ fail(`not started: ${problem.message}`, problem.code, problem.detail)
1131
+ return
1132
+ }
1133
+ lastDenied = undefined
1134
+ const built = buildServer(resolved, log, getDshCookie, onUpstream401, (address) => {
1135
+ lastDenied = { address, at: new Date().toISOString() }
1136
+ }, upstreamNow)
1137
+ if (built.error !== undefined) {
1138
+ fail(built.error, built.code)
1139
+ return
1140
+ }
1141
+ const own = built.server
1142
+ server = own
1143
+ stopServer = built.stop
1144
+ status = { state: "starting" }
1145
+ own.on("error", (err) => {
1146
+ if (server !== own) return
1147
+ // A machine with IPv6 turned off cannot bind "::"; every-interface IPv4 then.
1148
+ if (bindHost === "::" && (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL")) {
1149
+ bindHost = "0.0.0.0"
1150
+ own.listen(resolved.listenPort, bindHost, onListening)
1151
+ return
1152
+ }
1153
+ // The instance this one replaces (a hot swap, a restart) may still be
1154
+ // releasing the port for a moment: try again briefly before giving up.
1155
+ if (err.code === "EADDRINUSE" && bindRetries > 0) {
1156
+ bindRetries -= 1
1157
+ setTimeout(() => {
1158
+ if (server === own) own.listen(resolved.listenPort, bindHost, onListening)
1159
+ }, 250)
1160
+ return
1161
+ }
1162
+ const code = { EADDRINUSE: "port-in-use", EACCES: "port-denied" }[err.code] ?? "listen-error"
1163
+ const reason = err.code === "EADDRINUSE"
1164
+ ? `port ${resolved.listenPort} is already in use (another program, or a second DSH app sharing this profile)`
1165
+ : err.code === "EACCES"
1166
+ ? `no permission to listen on port ${resolved.listenPort}`
1167
+ : `listener error ${err.code || err.message}`
1168
+ fail(reason, code)
1169
+ server = undefined
1170
+ })
1171
+ // Every interface, IPv6 and IPv4 alike; who may connect is the allow list's job.
1172
+ let bindHost = "::"
1173
+ let bindRetries = 8
1174
+ const onListening = () => {
1175
+ if (server !== own) return
1176
+ status = { state: "listening" }
1177
+ log(`listening on ${bindHost === "::" ? "[::]" : bindHost}:${resolved.listenPort}${version ? ` v${version}` : ""} secret=/${resolved.secretPath} password=${resolved.password ? "on" : "off"} allow=${resolved.allowFrom || "any"}`)
1178
+ }
1179
+ own.listen(resolved.listenPort, bindHost, onListening)
1180
+ // Warm the DSH session cookie so the first remote request is fast.
1181
+ void getDshCookie()
1182
+ }
1183
+
1184
+ /** Stop the listener; `hard` (dispose) also ends every open connection. */
1185
+ function stop(hard) {
1186
+ if (server !== undefined) {
1187
+ try {
1188
+ stopServer?.(hard)
1189
+ } catch {
1190
+ /* already closed */
1191
+ }
1192
+ server = undefined
1193
+ stopServer = undefined
1194
+ }
1195
+ }
1196
+
1197
+ // A migrated user patch brings the saved gate values back through the loader
1198
+ // watcher; until then, do not mint (and persist) replacements for them.
1199
+ let awaitingMigration = healUserPatch()
1200
+ let migrationTimer
1201
+ if (awaitingMigration) {
1202
+ migrationTimer = setTimeout(() => {
1203
+ if (!awaitingMigration) return
1204
+ awaitingMigration = false
1205
+ log("migrated values did not arrive; starting with the current configuration")
1206
+ start(readConfig())
1207
+ }, 10000)
1208
+ migrationTimer.unref?.()
1209
+ }
1210
+ start(readConfig())
1211
+
1212
+ // A volatile-only configuration edit keeps this instance and lands here.
1213
+ ctx.on("loader/volatile-update", () => {
1214
+ awaitingMigration = false
1215
+ clearTimeout(migrationTimer)
1216
+ start(readConfig())
1217
+ })
1218
+
1219
+ ctx.effect(() => () => {
1220
+ clearTimeout(migrationTimer)
1221
+ stop(true)
1222
+ log(`stopped${version ? ` (v${version})` : ""}`)
1223
+ }, "dsh-remote-access-proxy: proxy server")
1224
+ }