@riceawa/dsh-lan-gateway 0.5.4 → 0.6.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.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, scryptSync, timingSafeEqual } from "node:crypto";
1
+ import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import http from "node:http";
4
4
  import https from "node:https";
@@ -56,6 +56,13 @@ function normalizeAddress(raw) {
56
56
  }
57
57
  /**
58
58
  * Classify a source address string into one of the three trust tiers.
59
+ *
60
+ * The input is a *socket* address — `req.socket.remoteAddress`, unwrapped from
61
+ * its `::ffff:` mapping — which is a different domain from the URL hostname
62
+ * `isLoopbackHost` in `request-policy.ts` judges. The two agree on the common
63
+ * inputs but are not interchangeable: this one never sees `[::1]`, and that one
64
+ * never sees a mapped form. Both spans are documented where each lives.
65
+ *
59
66
  * @param remoteAddress - the raw value of `req.socket.remoteAddress`.
60
67
  * @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
61
68
  * @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
@@ -216,6 +223,85 @@ var RateLimiter = class RateLimiter {
216
223
  }
217
224
  };
218
225
  //#endregion
226
+ //#region src/config-fields.ts
227
+ /**
228
+ * The editable settings, in display order. Adding a config key means adding it
229
+ * here (the host whitelist and the card's controls both follow), to the
230
+ * `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
231
+ * behavior.
232
+ */
233
+ const FIELDS = [
234
+ {
235
+ field: "enabled",
236
+ kind: "boolean"
237
+ },
238
+ {
239
+ field: "gatewayPort",
240
+ kind: "number"
241
+ },
242
+ {
243
+ field: "dshTargetPort",
244
+ kind: "number",
245
+ optional: true
246
+ },
247
+ {
248
+ field: "lanCidrs",
249
+ kind: "cidrs"
250
+ },
251
+ {
252
+ field: "lanPasswordless",
253
+ kind: "boolean"
254
+ },
255
+ {
256
+ field: "cookieMaxAgeDays",
257
+ kind: "number"
258
+ },
259
+ {
260
+ field: "tlsEnabled",
261
+ kind: "boolean"
262
+ },
263
+ {
264
+ field: "tlsMode",
265
+ kind: "select",
266
+ options: ["self-signed", "custom"]
267
+ },
268
+ {
269
+ field: "tlsSelfSignedHosts",
270
+ kind: "text"
271
+ },
272
+ {
273
+ field: "tlsCertPath",
274
+ kind: "text",
275
+ optional: true
276
+ },
277
+ {
278
+ field: "tlsKeyPath",
279
+ kind: "text",
280
+ optional: true
281
+ },
282
+ {
283
+ field: "tlsCertMaxAgeDays",
284
+ kind: "number"
285
+ },
286
+ {
287
+ field: "allowInsecurePlaintext",
288
+ kind: "boolean"
289
+ },
290
+ {
291
+ field: "trustedTerminator",
292
+ kind: "text",
293
+ optional: true
294
+ },
295
+ {
296
+ field: "secureCookies",
297
+ kind: "tristate"
298
+ }
299
+ ];
300
+ /** Every settings key the config route accepts; anything else is ignored. */
301
+ const CONFIG_FIELD_KEYS = new Set(FIELDS.map((def) => def.field));
302
+ /** Keys an empty submitted value clears back to the composition layer. */
303
+ const OPTIONAL_CONFIG_KEYS = new Set(FIELDS.filter((def) => def.optional === true).map((def) => def.field));
304
+ //#endregion
219
305
  //#region src/login.ts
220
306
  /** Path the gateway owns and never forwards. */
221
307
  const LOGIN_PATH = "/__login";
@@ -306,246 +392,257 @@ function readBody(req, maxBytes, res) {
306
392
  });
307
393
  }
308
394
  //#endregion
309
- //#region src/state.ts
395
+ //#region src/upstream-session.ts
310
396
  /**
311
- * Persistent runtime state for the LAN gateway: the cookie-signing secret and
312
- * the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
313
- * NOT in the schemastery Config — secrets must never surface in
314
- * `--dump-config` output. Writes are atomic (temp file + rename).
397
+ * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
315
398
  *
316
- * @module @riceawa/dsh-lan-gateway/state
317
- */
318
- /** The state directory: `~/.dsh/lan-gateway`. */
319
- function stateDir(home = homedir()) {
320
- return join(home, ".dsh", "lan-gateway");
321
- }
322
- const STATE_FILENAME = "state.json";
323
- /** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
324
- function deriveKey(password, salt, keylen) {
325
- return new Promise((resolve, reject) => {
326
- scrypt(password, salt, keylen, (error, derived) => {
327
- if (error !== null) reject(error);
328
- else resolve(derived);
329
- });
330
- });
331
- }
332
- /**
333
- * Whether a password is present and passes scrypt verification. Asynchronous
334
- * on purpose: `scryptSync` occupies the event loop for tens of milliseconds
335
- * per attempt, and that loop is shared with the dsh process the gateway is
336
- * forwarding to.
337
- */
338
- async function verifyPassword(state, password) {
339
- if (state.password === void 0) return false;
340
- const { hash, salt } = state.password;
341
- try {
342
- const expected = Buffer.from(hash, "hex");
343
- const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
344
- return expected.length === actual.length && timingSafeEqual(expected, actual);
345
- } catch {
346
- return false;
347
- }
348
- }
349
- /**
350
- * Set (or clear) the password, re-salted on every write. Both operations bump
351
- * the session epoch so every cookie issued under the previous epoch dies — a
352
- * password change must invalidate sessions the old password authorized.
399
+ * When dsh added browser-session authentication it stopped trusting a loopback
400
+ * Host header alone: every `/api` request (and the remote WebSocket mux) must
401
+ * now present a signed cookie bound to the authority it names
402
+ * (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
403
+ * process launch token. A reverse proxy that rewrites Host to loopback — which
404
+ * is what this gateway does — therefore gets a 401 no matter how the Host is
405
+ * forged. The gateway cannot mint that cookie itself (the signing secret lives
406
+ * in dsh's credential provider), so it does exactly what a browser does: on the
407
+ * loopback transport it visits the launch-token URL, keeps the Set-Cookie it
408
+ * earns, and replays that one shared session on every request it forwards.
409
+ *
410
+ * Semantics match the pre-existing "single password = single operator" model:
411
+ * whoever passes the gateway's own login rides this one upstream session. It is
412
+ * not multi-user authorization, and upstream (which holds the secret) remains
413
+ * the actual authority over what the session may do.
414
+ *
415
+ * The relay is a no-op on a base without browser sessions: acquisition fails
416
+ * and `cookie()` returns undefined, so the gateway simply forwards without a
417
+ * session cookie exactly as it did against an older dsh.
418
+ *
419
+ * @module @riceawa/dsh-lan-gateway/upstream-session
353
420
  */
354
- function setPassword(state, password) {
355
- const base = {
356
- cookieSecret: state.cookieSecret,
357
- sessionEpoch: state.sessionEpoch + 1
358
- };
359
- if (password === void 0) return base;
360
- const salt = randomBytes(16);
361
- const hash = scryptSync(password, salt, 64);
362
- return {
363
- ...base,
364
- password: {
365
- hash: hash.toString("hex"),
366
- salt: salt.toString("hex")
367
- }
368
- };
421
+ /** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
422
+ const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
423
+ /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
424
+ function nameValueOnly(setCookie) {
425
+ const semi = setCookie.indexOf(";");
426
+ return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
369
427
  }
370
428
  /**
371
- * Record a session id as revoked.
372
- * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
373
- * rejected on its own account, so the entry is no longer needed; dropping
374
- * expired entries here is what keeps the list bounded.
429
+ * The pathname of a URL, for logging. Never the whole URL: the authenticated
430
+ * URL carries the launch token as a query parameter, and that token is a
431
+ * bearer credential for the upstream harness.
375
432
  */
376
- function revokeSession(state, sid, expiresMs) {
377
- const now = Date.now();
378
- const revoked = {};
379
- for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
380
- revoked[sid] = expiresMs;
381
- return {
382
- ...state,
383
- revokedSessions: revoked
384
- };
385
- }
386
- /** Whether `sid` names a session that has been signed out. */
387
- function isSessionRevoked(state, sid) {
388
- if (sid === void 0) return false;
389
- return Object.hasOwn(state.revokedSessions ?? {}, sid);
390
- }
391
- function defaultState() {
392
- return {
393
- cookieSecret: randomBytes(32).toString("base64"),
394
- sessionEpoch: 0
395
- };
396
- }
397
- /** Keep the still-live entries of a persisted revocation list, or undefined. */
398
- function parseRevokedSessions(raw) {
399
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
400
- const now = Date.now();
401
- const out = {};
402
- let anyLive = false;
403
- for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
404
- out[sid] = exp;
405
- anyLive = true;
406
- }
407
- return anyLive ? out : void 0;
408
- }
409
- /** Load state; on first run (or a corrupt file) generate a fresh secret. */
410
- function loadState(home = homedir()) {
411
- const dir = stateDir(home);
433
+ function pathOf$1(url) {
412
434
  try {
413
- const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
414
- const parsed = JSON.parse(raw);
415
- if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
416
- const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
417
- const base = {
418
- cookieSecret: parsed.cookieSecret,
419
- sessionEpoch
420
- };
421
- if (parsed.password !== void 0) base.password = parsed.password;
422
- const revoked = parseRevokedSessions(parsed.revokedSessions);
423
- if (revoked !== void 0) base.revokedSessions = revoked;
424
- return base;
425
- }
426
- return defaultState();
435
+ return new URL(url).pathname;
427
436
  } catch {
428
- return defaultState();
437
+ return "<unparseable>";
429
438
  }
430
439
  }
431
- /** Persist state atomically. */
432
- function saveState(state, home = homedir()) {
433
- const dir = stateDir(home);
434
- mkdirSync(dir, { recursive: true });
435
- const target = join(dir, STATE_FILENAME);
436
- const tmp = join(dir, `.state.${process.pid}.tmp`);
437
- writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
438
- renameSync(tmp, target);
439
- try {
440
- chmodSync(target, 384);
441
- } catch {}
440
+ /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
441
+ function cookieNameOf(setCookie) {
442
+ const eq = setCookie.indexOf("=");
443
+ return eq === -1 ? "" : setCookie.slice(0, eq).trim();
442
444
  }
443
- //#endregion
444
- //#region src/gateway.ts
445
- /**
446
- * The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
447
- * address (dual-stack, so IPv6 clients reach it too) that forwards every
448
- * request to the loopback dsh web server.
449
- *
450
- * Security model (post-QVD / session-base):
451
- * - Source is classified from `socket.remoteAddress` only (never
452
- * `X-Forwarded-For`). Classification alone grants nothing: by default every
453
- * source — loopback, LAN, internet — must present a valid gateway session.
454
- * `lanPasswordless` (an explicit opt-in, false by default) is the one way a
455
- * LAN/loopback source skips the gateway login, and it is only ever allowed
456
- * against a session-capable dsh base (enforced by the plugin, which owns the
457
- * fail-closed guard).
458
- * - The gateway never forwards its own management surface (`/lan-gateway/*`)
459
- * or its login/logout paths; those are handled locally or refused.
460
- * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
461
- * blinded — so the gateway runs its own origin check on every relayed
462
- * request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
463
- * `sec-fetch-site: cross-site`, reject any Origin that does not name the
464
- * gateway authority the browser actually used, and require an Origin on
465
- * state-changing methods and on every WebSocket upgrade.
466
- * - Against a session-capable dsh base the Host/Origin rewrite alone would
467
- * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
468
- * authority-bound session cookie). The gateway therefore relays one shared
469
- * upstream session acquired through the launch-token exchange and replays it
470
- * on every forwarded request. See `upstream-session.ts`.
471
- * - Sessions are revocable two ways. Each carries a random id, so signing out
472
- * retires exactly that session and the WebSockets it opened; and each
473
- * carries a revocation epoch, so a password change or secret rotation kills
474
- * every session at once — cookie, socket, and all.
475
- *
476
- * @module @riceawa/dsh-lan-gateway/gateway
477
- */
478
- const DEFAULT_BODY_LIMIT_BYTES = 65536;
479
- const LOGIN_ATTEMPTS_LIMIT = 5;
480
- const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
481
- /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
482
- const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
483
- "GET",
484
- "HEAD",
485
- "OPTIONS"
486
- ]);
487
- /** The upstream browser-session cookie name prefix; the relay owns this namespace. */
488
- const UPSTREAM_COOKIE_PREFIX$1 = "dsh-auth-";
489
- /**
490
- * Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
491
- * the non-standard proxy-connection.
492
- */
493
- const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
494
- "connection",
495
- "keep-alive",
496
- "proxy-authenticate",
497
- "proxy-authorization",
498
- "proxy-connection",
499
- "te",
500
- "trailer",
501
- "transfer-encoding",
502
- "upgrade"
503
- ]);
504
445
  /**
505
- * Headers by which a client asserts where a request came from. The gateway
506
- * classifies on `socket.remoteAddress` and never reads these, so relaying a
507
- * caller's own values only hands the next hop a forgeable claim.
446
+ * Whether one `name=value` fragment of a request `Cookie` header names the
447
+ * upstream session namespace, and so must be dropped before the relay's own
448
+ * copy is appended. This is the *filter* rule: it matches the whole reserved
449
+ * namespace, name only, whether or not the pair is a well-formed session.
508
450
  */
509
- const FORWARDING_HEADERS = [
510
- "forwarded",
511
- "x-forwarded-for",
512
- "x-forwarded-host",
513
- "x-forwarded-port",
514
- "x-forwarded-proto",
515
- "x-real-ip"
516
- ];
517
- /** Whether a Cookie fragment names the upstream session cookie. */
518
- function isUpstreamSessionPair(pair) {
519
- return pair.startsWith(UPSTREAM_COOKIE_PREFIX$1);
451
+ function isUpstreamCookiePair(pair) {
452
+ return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX);
520
453
  }
521
454
  /**
522
- * Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
523
- * (possibly '').
455
+ * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
456
+ * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
457
+ * is followed by the authority hash, never by `=` itself, so the test is a
458
+ * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
459
+ * and silently relays every request anonymously.
524
460
  *
525
- * `attachUpstreamSession` appends the relay's session to the client's own
526
- * cookie, and upstream reads the FIRST name match. A client that holds any
527
- * `dsh-auth-<hash>` — typically one minted before dsh's signing secret was
528
- * reset, so still present but no longer verifying — would therefore shadow the
529
- * relay's session on every request. That draws a 401, the gateway reads the
530
- * 401 as "upstream revoked our session" and discards it, the next request
531
- * re-acquires, and the client's stale cookie shadows that one too: a loop that
532
- * never converges. Stripping the namespace makes the relay's copy the only one.
461
+ * This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
462
+ * on purpose: filtering drops a whole namespace the gateway owns, whereas
463
+ * accepting a session has to recognize the one cookie upstream actually mints.
464
+ * Both live here because this module owns the protocol fact; a consumer that
465
+ * re-derives it is how the two rules drifted apart before.
533
466
  */
534
- function withoutUpstreamSessionPairs(cookie) {
535
- return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamSessionPair(pair)).join("; ");
467
+ function isUpstreamSessionCookie(setCookie) {
468
+ const name = cookieNameOf(setCookie);
469
+ return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
536
470
  }
537
- /** Prefixes the gateway owns and must never relay to dsh. */
538
- function isOwnedPath(pathname) {
539
- return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
471
+ /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
472
+ function maxAgeSeconds(setCookie) {
473
+ const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
474
+ return match === null ? void 0 : Number(match[1]);
540
475
  }
541
476
  /**
542
- * The pathname a request is routed by: the one dsh's router resolves it to
543
- * (WHATWG URL parsing, which strips the query and collapses dot segments),
544
- * with trailing slashes then removed for the gateway's own surface tests.
545
- *
546
- * The decision paths below (owned prefix, login, logout) must use this rather
547
- * than the raw request target. dsh normalizes before matching, so a raw-string
548
- * test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
477
+ * Perform the token exchange over loopback: GET the launch-token URL with the
478
+ * upstream authority as Host, read the Set-Cookie the index route mints, and
479
+ * return its `name=value` plus expiry (or undefined when the exchange failed
480
+ * or no session cookie came back — e.g. an older base without browser
481
+ * sessions).
482
+ */
483
+ function exchange(url, authority, port, log) {
484
+ return new Promise((resolve) => {
485
+ let target;
486
+ try {
487
+ target = new URL(url);
488
+ } catch {
489
+ log("exchange: authenticatedUrl is not parseable");
490
+ resolve(void 0);
491
+ return;
492
+ }
493
+ const request = http.request({
494
+ host: "127.0.0.1",
495
+ port,
496
+ method: "GET",
497
+ path: `${target.pathname}${target.search}`,
498
+ headers: {
499
+ host: authority,
500
+ accept: "text/html"
501
+ }
502
+ }, (response) => {
503
+ const setCookies = response.headers["set-cookie"];
504
+ response.resume();
505
+ if (setCookies === void 0) {
506
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
507
+ resolve(void 0);
508
+ return;
509
+ }
510
+ const all = Array.isArray(setCookies) ? setCookies : [setCookies];
511
+ const raw = all.find(isUpstreamSessionCookie);
512
+ if (raw === void 0) {
513
+ const names = all.map(cookieNameOf).filter((name) => name !== "");
514
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
515
+ resolve(void 0);
516
+ return;
517
+ }
518
+ const header = nameValueOnly(raw);
519
+ const maxAge = maxAgeSeconds(raw);
520
+ log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
521
+ resolve({
522
+ header,
523
+ expiresAt: Date.now() + (maxAge ?? 0) * 1e3
524
+ });
525
+ });
526
+ request.on("error", (error) => {
527
+ log(`exchange error: ${error.message}`);
528
+ resolve(void 0);
529
+ });
530
+ request.setTimeout(5e3, () => {
531
+ log("exchange timeout (5s)");
532
+ request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
533
+ });
534
+ request.end();
535
+ });
536
+ }
537
+ /**
538
+ * A cached {@link UpstreamSession} acquired through the launch-token exchange.
539
+ * Acquisition runs at most once concurrently and the result is cached until it
540
+ * nears expiry or {@link invalidate} is called.
541
+ */
542
+ var UpstreamSessionRelay = class {
543
+ port;
544
+ authority;
545
+ authenticatedUrl;
546
+ log;
547
+ held;
548
+ inflight;
549
+ constructor(options) {
550
+ this.port = options.port;
551
+ this.authority = options.authority ?? `127.0.0.1:${options.port}`;
552
+ this.authenticatedUrl = options.authenticatedUrl;
553
+ this.log = options.log ?? (() => {});
554
+ }
555
+ /** Whether the held session is still comfortably inside its lifetime. */
556
+ fresh() {
557
+ const held = this.held;
558
+ if (held === void 0) return false;
559
+ return Date.now() < held.expiresAt - 6e4;
560
+ }
561
+ invalidate() {
562
+ if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
563
+ this.held = void 0;
564
+ }
565
+ async cookie() {
566
+ if (this.fresh()) return this.held?.header;
567
+ return this.acquire();
568
+ }
569
+ acquire() {
570
+ if (this.inflight !== void 0) return this.inflight;
571
+ const pending = this.doExchange().finally(() => {
572
+ this.inflight = void 0;
573
+ });
574
+ this.inflight = pending;
575
+ return pending;
576
+ }
577
+ async doExchange() {
578
+ const url = this.authenticatedUrl();
579
+ if (url === void 0) {
580
+ this.log("authenticatedUrl() returned undefined; keeping current session");
581
+ return this.held?.header;
582
+ }
583
+ this.log(`acquiring session from ${pathOf$1(url)}`);
584
+ const result = await exchange(url, this.authority, this.port, this.log);
585
+ if (result !== void 0) {
586
+ this.held = result;
587
+ this.log("session acquired and cached");
588
+ } else this.log("exchange failed; keeping current session");
589
+ return this.held?.header;
590
+ }
591
+ };
592
+ //#endregion
593
+ //#region src/request-policy.ts
594
+ /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
595
+ const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
596
+ "GET",
597
+ "HEAD",
598
+ "OPTIONS"
599
+ ]);
600
+ /**
601
+ * Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
602
+ * the non-standard proxy-connection.
603
+ */
604
+ const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
605
+ "connection",
606
+ "keep-alive",
607
+ "proxy-authenticate",
608
+ "proxy-authorization",
609
+ "proxy-connection",
610
+ "te",
611
+ "trailer",
612
+ "transfer-encoding",
613
+ "upgrade"
614
+ ]);
615
+ /**
616
+ * Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
617
+ * exchange that negotiates Connection/Upgrade, so they survive there and
618
+ * nowhere else.
619
+ */
620
+ const UPGRADE_HANDSHAKE_HEADERS = /* @__PURE__ */ new Set(["connection", "upgrade"]);
621
+ /**
622
+ * Headers by which a client asserts where a request came from. The gateway
623
+ * classifies on `socket.remoteAddress` and never reads these, so relaying a
624
+ * caller's own values only hands the next hop a forgeable claim.
625
+ */
626
+ const FORWARDING_HEADERS = [
627
+ "forwarded",
628
+ "x-forwarded-for",
629
+ "x-forwarded-host",
630
+ "x-forwarded-port",
631
+ "x-forwarded-proto",
632
+ "x-real-ip"
633
+ ];
634
+ /** Prefixes the gateway owns and must never relay to dsh. */
635
+ function isOwnedPath(pathname) {
636
+ return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
637
+ }
638
+ /**
639
+ * The pathname a request is routed by: the one dsh's router resolves it to
640
+ * (WHATWG URL parsing, which strips the query and collapses dot segments),
641
+ * with trailing slashes then removed for the gateway's own surface tests.
642
+ *
643
+ * The decision paths below (owned prefix, login, logout) must use this rather
644
+ * than the raw request target. dsh normalizes before matching, so a raw-string
645
+ * test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
549
646
  * path by string prefix, stays in the relay, and lands on the plugin's own
550
647
  * config route once Host has been rewritten to loopback. Forwarding still
551
648
  * relays the raw target: dsh applies the same normalization itself.
@@ -557,131 +654,507 @@ function isOwnedPath(pathname) {
557
654
  * single-page fallback. Blocking a trailing-slash spelling of an owned prefix
558
655
  * errs toward refusing, which costs nothing — no upstream route lives under it.
559
656
  */
560
- function pathOf$1(url) {
657
+ function pathOf(url) {
561
658
  try {
562
659
  return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
563
660
  } catch {
564
661
  return url;
565
662
  }
566
663
  }
567
- /** A fresh per-session id: 128 random bits, URL-safe. */
568
- function newSessionId() {
569
- return randomBytes(16).toString("base64url");
570
- }
571
664
  /**
572
- * The running gateway: owns the HTTP server and the auth state needed per
573
- * request. Created by the plugin on enable; torn down by the plugin on
574
- * disable or tree disposal.
665
+ * Whether `hostname` is loopback (127/8, localhost, ::1).
666
+ *
667
+ * This validates a URL *hostname* — the loopback fence on the gateway's own
668
+ * config route, where the input is the browser's Host header — so it accepts
669
+ * the spellings a URL parser produces, `[::1]` included. `classifySource` in
670
+ * `auth.ts` answers a different question about a different input (a socket
671
+ * address, unwrapped from its `::ffff:` mapping, and including LAN space); the
672
+ * two are related but not interchangeable, and neither should be rewritten in
673
+ * terms of the other without moving its input domain too.
575
674
  */
576
- var LanGateway = class {
577
- config;
578
- server;
579
- loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
580
- state;
581
- disposed = false;
582
- /**
583
- * Established WebSockets (upgraded client sockets), each keyed by the session
584
- * that opened it. A socket outlives the request that authenticated it, so it
585
- * has to be closable by session: on an epoch bump every socket dies, and on
586
- * sign-out only that session's. The value is undefined for a cookie minted
587
- * before per-session ids existed, which only a wholesale revocation reaches.
588
- */
589
- activeDuplexes = /* @__PURE__ */ new Map();
590
- constructor(config, state) {
591
- this.config = config;
592
- this.state = state;
593
- const handle = (req, res) => {
594
- this.handleHttp(req, res);
595
- };
596
- this.server = this.config.tls !== void 0 ? https.createServer({
597
- cert: this.config.tls.cert,
598
- key: this.config.tls.key
599
- }, handle) : http.createServer(handle);
600
- this.server.on("upgrade", (req, socket, head) => {
601
- this.handleUpgrade(req, socket, head);
602
- });
675
+ function isLoopbackHost(hostname) {
676
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
677
+ const parts = hostname.split(".");
678
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
679
+ }
680
+ /** Whether this source must present a gateway session (default: everyone). */
681
+ function requiresLogin(source, lanPasswordless) {
682
+ return !(lanPasswordless && source !== "internet");
683
+ }
684
+ /** Parse the session cookie out of a Cookie header. */
685
+ function sessionCookie(headers, cookieName) {
686
+ const header = headers.cookie;
687
+ if (typeof header !== "string") return void 0;
688
+ for (const part of header.split(";")) {
689
+ const trimmed = part.trim();
690
+ if (trimmed.startsWith(`${cookieName}=`)) return trimmed.slice(cookieName.length + 1);
603
691
  }
604
- /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
605
- setState(state) {
606
- if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
607
- this.state = state;
692
+ }
693
+ /**
694
+ * The cross-site test shared by every gateway-owned entry point, applied before
695
+ * any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
696
+ * does not name the authority the browser actually used.
697
+ *
698
+ * Only claims a cross-site page cannot suppress are read, which is what makes
699
+ * this usable on the login POST too (see {@link loginOriginAllowed}).
700
+ */
701
+ function isCrossSiteRequest(headers) {
702
+ if (headers["sec-fetch-site"] === "cross-site") return true;
703
+ const origin = headers.origin;
704
+ if (origin !== void 0 && !originMatchesHost(origin, headers.host)) return true;
705
+ return false;
706
+ }
707
+ /**
708
+ * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
709
+ * applied before any Host/Origin rewriting. Browsers attach Origin to
710
+ * state-changing requests and to every WebSocket handshake; reads without an
711
+ * Origin (navigations, non-browser clients holding a session) stay allowed.
712
+ */
713
+ function sameSiteAllowed(req, upgrade) {
714
+ if (isCrossSiteRequest(req.headers)) return false;
715
+ const origin = req.headers.origin;
716
+ if (upgrade) return origin !== void 0;
717
+ if (!READ_ONLY_METHODS.has(req.method ?? "GET")) return origin !== void 0;
718
+ return true;
719
+ }
720
+ /**
721
+ * The fence on the login POST. Issuing a session is as much a state change as
722
+ * retiring one — and a cross-site form post burns the victim's source address
723
+ * through the login rate limiter — so the login route runs the same cross-site
724
+ * test as everything else.
725
+ *
726
+ * It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
727
+ * request must carry an Origin" rule: a browser always sends an Origin on a
728
+ * form POST, but curl, the dsh CLI and other non-browser clients legitimately
729
+ * do not, and requiring one would lock them out of signing in. What remains is
730
+ * what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
731
+ * that disagrees with the Host the request names.
732
+ */
733
+ function loginOriginAllowed(headers) {
734
+ return !isCrossSiteRequest(headers);
735
+ }
736
+ /**
737
+ * Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
738
+ * (possibly '').
739
+ *
740
+ * The relay's session is appended to the client's own cookie, and upstream
741
+ * reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
742
+ * typically one minted before dsh's signing secret was reset, so still present
743
+ * but no longer verifying — would therefore shadow the relay's session on every
744
+ * request. That draws a 401, the gateway reads the 401 as "upstream revoked our
745
+ * session" and discards it, the next request re-acquires, and the client's
746
+ * stale cookie shadows that one too: a loop that never converges. Stripping the
747
+ * namespace makes the relay's copy the only one.
748
+ *
749
+ * This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
750
+ * be *accepted* from upstream. The two are deliberately different rules.
751
+ */
752
+ function withoutUpstreamSessionPairs(cookie) {
753
+ return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamCookiePair(pair)).join("; ");
754
+ }
755
+ /**
756
+ * Build the outbound headers for one relayed request: rewrite Host/Origin to
757
+ * the loopback upstream, drop hop-by-hop and caller-supplied forwarding
758
+ * headers, clear the upstream cookie namespace the relay owns, and attach the
759
+ * relayed session.
760
+ */
761
+ function upstreamRequestHeaders(headers, options) {
762
+ const out = { ...headers };
763
+ out.host = `127.0.0.1:${options.dshPort}`;
764
+ if (typeof out.origin === "string") out.origin = `http://127.0.0.1:${options.dshPort}`;
765
+ delete out["proxy-connection"];
766
+ if (!options.keepUpgrade) {
767
+ delete out.connection;
768
+ delete out.upgrade;
608
769
  }
609
- /**
610
- * Start listening on the configured port. The listener is dual-stack: with
611
- * no host given, node binds the unspecified IPv6 address `::` — which also
612
- * accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
613
- * unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
614
- * not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
615
- * unable to reach a gateway that classifies them.
616
- */
617
- async listen() {
618
- return new Promise((resolve, reject) => {
619
- const onError = (err) => {
620
- this.server.off("listening", onListening);
621
- reject(err);
622
- };
623
- const onListening = () => {
624
- this.server.off("error", onError);
625
- resolve();
626
- };
627
- this.server.once("error", onError);
628
- this.server.once("listening", onListening);
629
- this.server.listen(this.config.gatewayPort);
630
- });
770
+ for (const name of FORWARDING_HEADERS) delete out[name];
771
+ if (typeof out.cookie === "string") {
772
+ const kept = withoutUpstreamSessionPairs(out.cookie);
773
+ if (kept === "") delete out.cookie;
774
+ else out.cookie = kept;
631
775
  }
632
- /** The address actually bound, for logs and status (never a claim about it). */
633
- boundAddress() {
634
- const address = this.server.address();
635
- if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
636
- return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
776
+ const relayed = options.upstreamCookie;
777
+ if (relayed !== void 0 && relayed !== "") {
778
+ const existing = out.cookie;
779
+ out.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${relayed}` : relayed;
637
780
  }
638
- /** Close the server, drop upgraded sockets, and stop accepting connections. */
639
- async close() {
640
- if (this.disposed) return;
641
- this.disposed = true;
642
- this.destroyActiveDuplexes();
643
- return new Promise((resolve) => {
781
+ return out;
782
+ }
783
+ /**
784
+ * Filter one direction's worth of headers through the same cookie rule, so the
785
+ * HTTP and WebSocket branches cannot drift apart on it.
786
+ */
787
+ function stripUpstreamCookies(entry) {
788
+ return !isUpstreamSessionCookie(entry.trim());
789
+ }
790
+ /**
791
+ * The headers to send back to the client: hop-by-hop headers dropped, and the
792
+ * upstream session cookie withheld. Upstream's one cookie-minting route is the
793
+ * launch-token exchange at `/`, so a client that already holds a gateway session
794
+ * could otherwise post the token through the gateway and walk away with a
795
+ * durable upstream credential the relay exists to keep on this side. Cookies
796
+ * from other routes (plugins) still pass through.
797
+ */
798
+ function downstreamResponseHeaders(upstream) {
799
+ const headers = {};
800
+ for (const [key, value] of Object.entries(upstream)) {
801
+ if (value === void 0) continue;
802
+ const lower = key.toLowerCase();
803
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue;
804
+ if (lower === "set-cookie") {
805
+ const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
806
+ if (list.length > 0) headers[key] = list;
807
+ continue;
808
+ }
809
+ headers[key] = value;
810
+ }
811
+ return headers;
812
+ }
813
+ /**
814
+ * The headers of a 101 Switching Protocols response, replayed to the client on
815
+ * the socket the gateway just spliced.
816
+ *
817
+ * {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
818
+ * upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
819
+ * response. The cookie rule is not relaxed with them — the relay's session is
820
+ * withheld on this path too, so upstream cannot hand a client a durable
821
+ * credential by attaching it to the handshake.
822
+ */
823
+ function upgradeResponseHeaders(upstream) {
824
+ const headers = {};
825
+ for (const [key, value] of Object.entries(upstream)) {
826
+ if (value === void 0) continue;
827
+ const lower = key.toLowerCase();
828
+ if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue;
829
+ if (lower === "set-cookie") {
830
+ const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
831
+ if (list.length > 0) headers[key] = list;
832
+ continue;
833
+ }
834
+ headers[key] = value;
835
+ }
836
+ return headers;
837
+ }
838
+ //#endregion
839
+ //#region src/state.ts
840
+ /**
841
+ * Persistent runtime state for the LAN gateway: the cookie-signing secret and
842
+ * the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
843
+ * NOT in the schemastery Config — secrets must never surface in
844
+ * `--dump-config` output. Writes are atomic (temp file + rename).
845
+ *
846
+ * @module @riceawa/dsh-lan-gateway/state
847
+ */
848
+ /** The state directory: `~/.dsh/lan-gateway`. */
849
+ function stateDir(home = homedir()) {
850
+ return join(home, ".dsh", "lan-gateway");
851
+ }
852
+ const STATE_FILENAME = "state.json";
853
+ /** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
854
+ function deriveKey(password, salt, keylen) {
855
+ return new Promise((resolve, reject) => {
856
+ scrypt(password, salt, keylen, (error, derived) => {
857
+ if (error !== null) reject(error);
858
+ else resolve(derived);
859
+ });
860
+ });
861
+ }
862
+ /**
863
+ * Whether a password is present and passes scrypt verification. Asynchronous
864
+ * on purpose: `scryptSync` occupies the event loop for tens of milliseconds
865
+ * per attempt, and that loop is shared with the dsh process the gateway is
866
+ * forwarding to.
867
+ */
868
+ async function verifyPassword(state, password) {
869
+ if (state.password === void 0) return false;
870
+ const { hash, salt } = state.password;
871
+ try {
872
+ const expected = Buffer.from(hash, "hex");
873
+ const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
874
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
875
+ } catch {
876
+ return false;
877
+ }
878
+ }
879
+ /**
880
+ * Set (or clear) the password, re-salted on every write. Both operations bump
881
+ * the session epoch so every cookie issued under the previous epoch dies — a
882
+ * password change must invalidate sessions the old password authorized.
883
+ *
884
+ * Deriving the key is asynchronous for the same reason
885
+ * {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
886
+ * milliseconds, and that loop is shared with the dsh process the gateway
887
+ * forwards to. Every caller is already async.
888
+ */
889
+ async function setPassword(state, password) {
890
+ const base = {
891
+ cookieSecret: state.cookieSecret,
892
+ sessionEpoch: state.sessionEpoch + 1
893
+ };
894
+ if (password === void 0) return base;
895
+ const salt = randomBytes(16);
896
+ const hash = await deriveKey(password, salt, 64);
897
+ return {
898
+ ...base,
899
+ password: {
900
+ hash: hash.toString("hex"),
901
+ salt: salt.toString("hex")
902
+ }
903
+ };
904
+ }
905
+ /**
906
+ * Record a session id as revoked.
907
+ * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
908
+ * rejected on its own account, so the entry is no longer needed; dropping
909
+ * expired entries here is what keeps the list bounded.
910
+ * @param now - epoch millis to judge the existing entries against, injected so
911
+ * a test can age the list without fake timers.
912
+ */
913
+ function revokeSession(state, sid, expiresMs, now = Date.now()) {
914
+ const revoked = {};
915
+ for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
916
+ revoked[sid] = expiresMs;
917
+ return {
918
+ ...state,
919
+ revokedSessions: revoked
920
+ };
921
+ }
922
+ /** Whether `sid` names a session that has been signed out. */
923
+ function isSessionRevoked(state, sid) {
924
+ if (sid === void 0) return false;
925
+ return Object.hasOwn(state.revokedSessions ?? {}, sid);
926
+ }
927
+ function defaultState() {
928
+ return {
929
+ cookieSecret: randomBytes(32).toString("base64"),
930
+ sessionEpoch: 0
931
+ };
932
+ }
933
+ /** Keep the still-live entries of a persisted revocation list, or undefined. */
934
+ function parseRevokedSessions(raw) {
935
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
936
+ const now = Date.now();
937
+ const out = {};
938
+ let anyLive = false;
939
+ for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
940
+ out[sid] = exp;
941
+ anyLive = true;
942
+ }
943
+ return anyLive ? out : void 0;
944
+ }
945
+ /** Load state; on first run (or a corrupt file) generate a fresh secret. */
946
+ function loadState(home = homedir()) {
947
+ const dir = stateDir(home);
948
+ try {
949
+ const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
950
+ const parsed = JSON.parse(raw);
951
+ if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
952
+ const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
953
+ const base = {
954
+ cookieSecret: parsed.cookieSecret,
955
+ sessionEpoch
956
+ };
957
+ if (parsed.password !== void 0) base.password = parsed.password;
958
+ const revoked = parseRevokedSessions(parsed.revokedSessions);
959
+ if (revoked !== void 0) base.revokedSessions = revoked;
960
+ return base;
961
+ }
962
+ return defaultState();
963
+ } catch {
964
+ return defaultState();
965
+ }
966
+ }
967
+ /** Persist state atomically. */
968
+ function saveState(state, home = homedir()) {
969
+ const dir = stateDir(home);
970
+ mkdirSync(dir, { recursive: true });
971
+ const target = join(dir, STATE_FILENAME);
972
+ const tmp = join(dir, `.state.${process.pid}.tmp`);
973
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
974
+ renameSync(tmp, target);
975
+ try {
976
+ chmodSync(target, 384);
977
+ } catch {}
978
+ }
979
+ //#endregion
980
+ //#region src/gateway.ts
981
+ /**
982
+ * The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
983
+ * address (dual-stack, so IPv6 clients reach it too) that forwards every
984
+ * request to the loopback dsh web server.
985
+ *
986
+ * Security model (post-QVD / session-base):
987
+ * - Source is classified from `socket.remoteAddress` only (never
988
+ * `X-Forwarded-For`). Classification alone grants nothing: by default every
989
+ * source — loopback, LAN, internet — must present a valid gateway session.
990
+ * `lanPasswordless` (an explicit opt-in, false by default) is the one way a
991
+ * LAN/loopback source skips the gateway login, and it is only ever allowed
992
+ * against a session-capable dsh base (enforced by the plugin, which owns the
993
+ * fail-closed guard).
994
+ * - The gateway never forwards its own management surface (`/lan-gateway/*`)
995
+ * or its login/logout paths; those are handled locally or refused.
996
+ * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
997
+ * blinded — so the gateway runs its own origin check on every relayed
998
+ * request (HTTP and WebSocket upgrade) BEFORE rewriting. See
999
+ * `request-policy.ts`, which owns that decision along with every other
1000
+ * header/path/server decision; this module owns the transport.
1001
+ * - Against a session-capable dsh base the Host/Origin rewrite alone would
1002
+ * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
1003
+ * authority-bound session cookie). The gateway therefore relays one shared
1004
+ * upstream session acquired through the launch-token exchange and replays it
1005
+ * on every forwarded request. See `upstream-session.ts`.
1006
+ * - Sessions are revocable two ways. Each carries a random id, so signing out
1007
+ * retires exactly that session and the WebSockets it opened; and each
1008
+ * carries a revocation epoch, so a password change or secret rotation kills
1009
+ * every session at once — cookie, socket, and all.
1010
+ *
1011
+ * @module @riceawa/dsh-lan-gateway/gateway
1012
+ */
1013
+ const DEFAULT_BODY_LIMIT_BYTES = 65536;
1014
+ const LOGIN_ATTEMPTS_LIMIT = 5;
1015
+ const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
1016
+ /**
1017
+ * How long a half-open upstream WebSocket handshake may hang before the
1018
+ * gateway gives up on it. Without a deadline the client socket sits in the
1019
+ * pending table forever and never learns the upgrade failed — node's http
1020
+ * client would wait out its own socket timeout, which is measured in minutes.
1021
+ */
1022
+ const UPGRADE_HANDSHAKE_TIMEOUT_MS = 15e3;
1023
+ /** A fresh per-session id: 128 random bits, URL-safe. */
1024
+ function newSessionId() {
1025
+ return randomBytes(16).toString("base64url");
1026
+ }
1027
+ /**
1028
+ * The running gateway: owns the HTTP server and the auth state needed per
1029
+ * request. Created by the plugin on enable; torn down by the plugin on
1030
+ * disable or tree disposal.
1031
+ */
1032
+ var LanGateway = class {
1033
+ config;
1034
+ server;
1035
+ loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
1036
+ state;
1037
+ disposed = false;
1038
+ /**
1039
+ * Every WebSocket this gateway is responsible for, keyed by the client
1040
+ * socket: pending handshakes as well as established ones.
1041
+ *
1042
+ * A socket outlives the request that authenticated it, so it has to be
1043
+ * closable by session: on an epoch bump every socket dies, and on sign-out
1044
+ * only that session's. A handshake that is still waiting on the relay or on
1045
+ * upstream's 101 is tracked from the moment it passes the gates, not from the
1046
+ * moment it is spliced — otherwise a revocation that lands mid-handshake
1047
+ * closes the map's contents and then watches the abandoned handshake finish
1048
+ * and register itself as live.
1049
+ */
1050
+ sockets = /* @__PURE__ */ new Map();
1051
+ /**
1052
+ * Bumped by every revocation (epoch change, per-session sign-out) and by
1053
+ * disposal. A socket is retired when the generation moves past the one it was
1054
+ * admitted under, which is what lets a pending handshake be judged by the
1055
+ * rules in force when it *completes* rather than when it started.
1056
+ */
1057
+ gate = 0;
1058
+ constructor(config, state) {
1059
+ this.config = config;
1060
+ this.state = state;
1061
+ const handle = (req, res) => {
1062
+ this.handleHttp(req, res);
1063
+ };
1064
+ this.server = this.config.tls !== void 0 ? https.createServer({
1065
+ cert: this.config.tls.cert,
1066
+ key: this.config.tls.key
1067
+ }, handle) : http.createServer(handle);
1068
+ this.server.on("upgrade", (req, socket, head) => {
1069
+ this.handleUpgrade(req, socket, head);
1070
+ });
1071
+ }
1072
+ /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
1073
+ setState(state) {
1074
+ if (state.sessionEpoch !== this.state.sessionEpoch) {
1075
+ this.gate += 1;
1076
+ this.destroyAllSockets();
1077
+ }
1078
+ this.state = state;
1079
+ }
1080
+ /**
1081
+ * Start listening on the configured port. The listener is dual-stack: with
1082
+ * no host given, node binds the unspecified IPv6 address `::` — which also
1083
+ * accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
1084
+ * unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
1085
+ * not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
1086
+ * unable to reach a gateway that classifies them.
1087
+ */
1088
+ async listen() {
1089
+ return new Promise((resolve, reject) => {
1090
+ const onError = (err) => {
1091
+ this.server.off("listening", onListening);
1092
+ reject(err);
1093
+ };
1094
+ const onListening = () => {
1095
+ this.server.off("error", onError);
1096
+ resolve();
1097
+ };
1098
+ this.server.once("error", onError);
1099
+ this.server.once("listening", onListening);
1100
+ this.server.listen(this.config.gatewayPort);
1101
+ });
1102
+ }
1103
+ /** The address actually bound, for logs and status (never a claim about it). */
1104
+ boundAddress() {
1105
+ const address = this.server.address();
1106
+ if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
1107
+ return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
1108
+ }
1109
+ /** Close the server, drop every socket, and stop accepting connections. */
1110
+ async close() {
1111
+ if (this.disposed) return;
1112
+ this.disposed = true;
1113
+ this.gate += 1;
1114
+ this.destroyAllSockets();
1115
+ return new Promise((resolve) => {
644
1116
  this.server.close(() => resolve());
645
1117
  this.server.closeAllConnections();
646
1118
  });
647
1119
  }
648
- destroyActiveDuplexes() {
649
- for (const socket of this.activeDuplexes.keys()) socket.destroy();
650
- this.activeDuplexes.clear();
1120
+ destroyAllSockets() {
1121
+ for (const socket of this.sockets.keys()) socket.destroy();
1122
+ this.sockets.clear();
651
1123
  }
652
1124
  /** Close the sockets one session opened, so signing out ends its live streams too. */
653
- destroyDuplexesFor(sid) {
654
- for (const [socket, owner] of this.activeDuplexes) {
655
- if (owner !== sid) continue;
656
- this.activeDuplexes.delete(socket);
1125
+ destroySocketsFor(sid) {
1126
+ for (const [socket, tracked] of this.sockets) {
1127
+ if (tracked.sid !== sid) continue;
1128
+ this.sockets.delete(socket);
657
1129
  socket.destroy();
658
1130
  }
659
1131
  }
660
- trackDuplex(socket, sid) {
661
- this.activeDuplexes.set(socket, sid);
1132
+ /** Track a socket from admission to close. */
1133
+ trackSocket(socket, sid) {
1134
+ this.sockets.set(socket, {
1135
+ sid,
1136
+ gate: this.gate
1137
+ });
662
1138
  socket.on("close", () => {
663
- this.activeDuplexes.delete(socket);
1139
+ this.sockets.delete(socket);
664
1140
  });
665
1141
  }
1142
+ /** Whether a socket is still tracked, undisposed, and admitted under the current gate. */
1143
+ stillAdmitted(socket) {
1144
+ if (this.disposed) return false;
1145
+ const tracked = this.sockets.get(socket);
1146
+ return tracked !== void 0 && tracked.gate === this.gate;
1147
+ }
666
1148
  sourceOf(req) {
667
1149
  return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
668
1150
  }
669
- /** Parse the session cookie out of a Cookie header. */
670
- sessionCookie(req) {
671
- const header = req.headers.cookie;
672
- if (typeof header !== "string") return void 0;
673
- for (const part of header.split(";")) {
674
- const trimmed = part.trim();
675
- if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
676
- }
677
- }
678
1151
  /**
679
1152
  * The session a request carries, or undefined when it presents none, presents
680
1153
  * one that no longer verifies under the current epoch, or presents one whose
681
1154
  * id has been signed out.
682
1155
  */
683
1156
  session(req) {
684
- const cookie = this.sessionCookie(req);
1157
+ const cookie = sessionCookie(req.headers, this.config.cookieName);
685
1158
  if (cookie === void 0) return void 0;
686
1159
  const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
687
1160
  if (claims === void 0) return void 0;
@@ -691,19 +1164,20 @@ var LanGateway = class {
691
1164
  authorized(req) {
692
1165
  return this.session(req) !== void 0;
693
1166
  }
694
- /** Whether this source must present a gateway session (default: everyone). */
695
- requiresLogin(source) {
696
- return !(this.config.lanPasswordless && source !== "internet");
697
- }
698
- serveUnauthorized(res, limited) {
1167
+ /**
1168
+ * Send an unauthorized caller to the login form. The rate limiter's refusal
1169
+ * does not come through here: it is answered on the POST itself, where the
1170
+ * banner can be rendered without a round trip.
1171
+ */
1172
+ serveUnauthorized(res) {
699
1173
  res.writeHead(302, {
700
- location: `${LOGIN_PATH}${limited ? "?limited=1" : ""}`,
1174
+ location: LOGIN_PATH,
701
1175
  ...this.securityHeaders()
702
1176
  });
703
1177
  res.end();
704
1178
  }
705
- serveLoginError(res, message) {
706
- const opts = { error: message };
1179
+ serveLoginError(res, message, limited = false) {
1180
+ const opts = limited ? { limited: true } : { error: message };
707
1181
  res.writeHead(401, {
708
1182
  "content-type": "text/html; charset=utf-8",
709
1183
  "cache-control": "no-store",
@@ -715,30 +1189,14 @@ var LanGateway = class {
715
1189
  securityHeaders() {
716
1190
  return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
717
1191
  }
718
- /**
719
- * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
720
- * and applied before any Host/Origin rewriting. Browsers attach Origin to
721
- * state-changing requests and to every WebSocket handshake; reads without an
722
- * Origin (navigations, non-browser clients holding a session) stay allowed.
723
- */
724
- sameSiteAllowed(req, upgrade) {
725
- const headers = req.headers;
726
- if (headers["sec-fetch-site"] === "cross-site") return false;
727
- const origin = headers.origin;
728
- const host = headers.host;
729
- if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
730
- if (upgrade) return origin !== void 0;
731
- if (!READ_ONLY_METHODS$1.has(req.method ?? "GET")) return origin !== void 0;
732
- return true;
733
- }
734
1192
  sessionSetCookie(value, maxAgeSeconds) {
735
1193
  const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
736
1194
  return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
737
1195
  }
738
- /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
1196
+ /** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
739
1197
  async handleHttp(req, res) {
740
1198
  const url = req.url ?? "/";
741
- const pathname = pathOf$1(url);
1199
+ const pathname = pathOf(url);
742
1200
  const source = this.sourceOf(req);
743
1201
  if (pathname === "/__login") {
744
1202
  this.handleLogin(req, res);
@@ -753,11 +1211,11 @@ var LanGateway = class {
753
1211
  res.end("forbidden");
754
1212
  return;
755
1213
  }
756
- if (this.requiresLogin(source) && !this.authorized(req)) {
757
- this.serveUnauthorized(res, false);
1214
+ if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
1215
+ this.serveUnauthorized(res);
758
1216
  return;
759
1217
  }
760
- if (!this.sameSiteAllowed(req, false)) {
1218
+ if (!sameSiteAllowed(req, false)) {
761
1219
  res.writeHead(403, this.securityHeaders());
762
1220
  res.end("forbidden");
763
1221
  return;
@@ -766,7 +1224,6 @@ var LanGateway = class {
766
1224
  }
767
1225
  /** Handle the login GET form / POST submission. */
768
1226
  handleLogin(req, res) {
769
- req.url?.includes("limited=1");
770
1227
  if (req.method === "GET" || req.method === "HEAD") {
771
1228
  serveLoginGet(res, this.securityHeaders());
772
1229
  return;
@@ -776,9 +1233,14 @@ var LanGateway = class {
776
1233
  res.end();
777
1234
  return;
778
1235
  }
1236
+ if (!loginOriginAllowed(req.headers)) {
1237
+ res.writeHead(403, this.securityHeaders());
1238
+ res.end("forbidden");
1239
+ return;
1240
+ }
779
1241
  const key = req.socket.remoteAddress ?? "unknown";
780
1242
  if (!this.loginLimiter.allow(key)) {
781
- this.serveLoginError(res, "Too many attempts — please wait a minute.");
1243
+ this.serveLoginError(res, "Too many attempts — please wait a minute.", true);
782
1244
  return;
783
1245
  }
784
1246
  readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
@@ -789,7 +1251,13 @@ var LanGateway = class {
789
1251
  } catch {
790
1252
  password = void 0;
791
1253
  }
792
- if (!(password !== void 0 && await verifyPassword(this.state, password))) {
1254
+ const checked = this.state;
1255
+ const accepted = password !== void 0 && await verifyPassword(checked, password);
1256
+ if (this.state !== checked || this.disposed) {
1257
+ this.serveLoginError(res, "Sign-in was interrupted — please try again.");
1258
+ return;
1259
+ }
1260
+ if (!accepted) {
793
1261
  this.serveLoginError(res, "Incorrect password.");
794
1262
  return;
795
1263
  }
@@ -825,7 +1293,7 @@ var LanGateway = class {
825
1293
  res.end();
826
1294
  return;
827
1295
  }
828
- if (!this.sameSiteAllowed(req, false)) {
1296
+ if (!sameSiteAllowed(req, false)) {
829
1297
  res.writeHead(403, this.securityHeaders());
830
1298
  res.end("forbidden");
831
1299
  return;
@@ -834,7 +1302,7 @@ var LanGateway = class {
834
1302
  if (claims?.sid !== void 0) {
835
1303
  this.state = revokeSession(this.state, claims.sid, claims.exp);
836
1304
  this.config.onStateChange?.(this.state);
837
- this.destroyDuplexesFor(claims.sid);
1305
+ this.destroySocketsFor(claims.sid);
838
1306
  }
839
1307
  res.writeHead(302, {
840
1308
  location: "/",
@@ -843,63 +1311,20 @@ var LanGateway = class {
843
1311
  });
844
1312
  res.end();
845
1313
  }
846
- /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
847
- upstreamHeaders(req, keepUpgrade) {
848
- const headers = { ...req.headers };
849
- headers.host = `127.0.0.1:${this.config.dshPort}`;
850
- if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
851
- delete headers["proxy-connection"];
852
- if (!keepUpgrade) {
853
- delete headers.connection;
854
- delete headers.upgrade;
855
- }
856
- for (const name of FORWARDING_HEADERS) delete headers[name];
857
- if (typeof headers.cookie === "string") {
858
- const kept = withoutUpstreamSessionPairs(headers.cookie);
859
- if (kept === "") delete headers.cookie;
860
- else headers.cookie = kept;
861
- }
862
- return headers;
863
- }
864
- /** Attach the shared upstream session cookie to the outbound headers, if any. */
865
- attachUpstreamSession(headers) {
866
- const session = this.config.upstreamSession;
867
- if (session === void 0) return false;
868
- const cookie = session.peek();
869
- if (cookie === void 0) return false;
870
- const existing = headers.cookie;
871
- headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
872
- return true;
873
- }
874
- /**
875
- * The headers to send back to the client: hop-by-hop headers dropped, and
876
- * the upstream session cookie withheld. Upstream's one cookie-minting route
877
- * is the launch-token exchange at `/`, so a client that already holds a
878
- * gateway session could otherwise post the token through the gateway and
879
- * walk away with a durable upstream credential the relay exists to keep on
880
- * this side. Cookies from other routes (plugins) still pass through.
881
- */
882
- downstreamHeaders(upstream) {
883
- const headers = {};
884
- for (const [key, value] of Object.entries(upstream)) {
885
- if (value === void 0) continue;
886
- const lower = key.toLowerCase();
887
- if (HOP_BY_HOP_HEADERS.has(lower)) continue;
888
- if (lower === "set-cookie") {
889
- const list = (Array.isArray(value) ? value : [value]).filter((entry) => !isUpstreamSessionPair(entry.trim()));
890
- if (list.length > 0) headers[key] = list;
891
- continue;
892
- }
893
- headers[key] = value;
894
- }
895
- return headers;
1314
+ /** The shared upstream session's cookie value, if the relay holds one. */
1315
+ async upstreamCookie() {
1316
+ return this.config.upstreamSession === void 0 ? void 0 : this.config.upstreamSession.cookie();
896
1317
  }
897
1318
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
898
1319
  async relayHttp(req, res, url) {
899
1320
  const session = this.config.upstreamSession;
900
- if (session !== void 0) await session.cookie();
901
- const headers = this.upstreamHeaders(req, false);
902
- const attached = this.attachUpstreamSession(headers);
1321
+ const relayed = await this.upstreamCookie();
1322
+ const headers = upstreamRequestHeaders(req.headers, {
1323
+ dshPort: this.config.dshPort,
1324
+ keepUpgrade: false,
1325
+ upstreamCookie: relayed
1326
+ });
1327
+ const attached = relayed !== void 0;
903
1328
  const proxyReq = http.request({
904
1329
  host: "127.0.0.1",
905
1330
  port: this.config.dshPort,
@@ -908,7 +1333,7 @@ var LanGateway = class {
908
1333
  headers
909
1334
  }, (proxyRes) => {
910
1335
  if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
911
- res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers));
1336
+ res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers));
912
1337
  proxyRes.pipe(res);
913
1338
  });
914
1339
  proxyReq.on("error", () => {
@@ -920,7 +1345,7 @@ var LanGateway = class {
920
1345
  /** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
921
1346
  async handleUpgrade(req, socket, head) {
922
1347
  const url = req.url ?? "/";
923
- const pathname = pathOf$1(url);
1348
+ const pathname = pathOf(url);
924
1349
  const source = this.sourceOf(req);
925
1350
  const refuse = (status) => {
926
1351
  socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
@@ -931,18 +1356,29 @@ var LanGateway = class {
931
1356
  return;
932
1357
  }
933
1358
  const claims = this.session(req);
934
- if (this.requiresLogin(source) && claims === void 0) {
1359
+ if (requiresLogin(source, this.config.lanPasswordless) && claims === void 0) {
935
1360
  refuse(401);
936
1361
  return;
937
1362
  }
938
- if (!this.sameSiteAllowed(req, true)) {
1363
+ if (!sameSiteAllowed(req, true)) {
939
1364
  refuse(403);
940
1365
  return;
941
1366
  }
942
- const session = this.config.upstreamSession;
943
- if (session !== void 0) await session.cookie();
944
- const headers = this.upstreamHeaders(req, true);
945
- this.attachUpstreamSession(headers);
1367
+ this.trackSocket(socket, claims?.sid);
1368
+ const retire = () => {
1369
+ if (!this.sockets.delete(socket)) return;
1370
+ socket.destroy();
1371
+ };
1372
+ const relayed = await this.upstreamCookie();
1373
+ if (!this.stillAdmitted(socket)) {
1374
+ retire();
1375
+ return;
1376
+ }
1377
+ const headers = upstreamRequestHeaders(req.headers, {
1378
+ dshPort: this.config.dshPort,
1379
+ keepUpgrade: true,
1380
+ upstreamCookie: relayed
1381
+ });
946
1382
  const proxyReq = http.request({
947
1383
  host: "127.0.0.1",
948
1384
  port: this.config.dshPort,
@@ -950,10 +1386,22 @@ var LanGateway = class {
950
1386
  path: url,
951
1387
  headers
952
1388
  });
1389
+ const timer = setTimeout(() => {
1390
+ proxyReq.destroy();
1391
+ retire();
1392
+ }, UPGRADE_HANDSHAKE_TIMEOUT_MS);
1393
+ const settle = () => {
1394
+ clearTimeout(timer);
1395
+ };
953
1396
  proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
954
- this.trackDuplex(socket, claims?.sid);
1397
+ settle();
1398
+ if (!this.stillAdmitted(socket)) {
1399
+ proxySocket.destroy();
1400
+ retire();
1401
+ return;
1402
+ }
955
1403
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
956
- const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
1404
+ const headerLines = Object.entries(upgradeResponseHeaders(proxyRes.headers)).flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((entry) => `${key}: ${entry}\r\n`)).join("");
957
1405
  socket.write(`${statusLine}${headerLines}\r\n`);
958
1406
  if (head !== void 0 && head.length > 0) proxySocket.write(head);
959
1407
  proxySocket.pipe(socket).pipe(proxySocket);
@@ -961,7 +1409,22 @@ var LanGateway = class {
961
1409
  socket.on("error", () => proxySocket.destroy());
962
1410
  proxySocket.on("error", () => socket.destroy());
963
1411
  });
964
- proxyReq.on("error", () => socket.destroy());
1412
+ proxyReq.on("response", (proxyRes) => {
1413
+ settle();
1414
+ proxyRes.resume();
1415
+ if (!this.stillAdmitted(socket)) {
1416
+ retire();
1417
+ return;
1418
+ }
1419
+ if (proxyRes.statusCode === 401 && relayed !== void 0) this.config.upstreamSession?.invalidate();
1420
+ const body = `upstream refused the WebSocket upgrade (HTTP ${proxyRes.statusCode ?? 502})`;
1421
+ socket.write(`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? "Upstream Refused"}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
1422
+ retire();
1423
+ });
1424
+ proxyReq.on("error", () => {
1425
+ settle();
1426
+ retire();
1427
+ });
965
1428
  proxyReq.end();
966
1429
  }
967
1430
  };
@@ -1150,444 +1613,283 @@ function generateSelfSignedCert(options) {
1150
1613
  modulusLength: 2048,
1151
1614
  publicExponent: 65537
1152
1615
  });
1153
- const commonName = options.commonName?.trim() || hosts[0];
1154
- const serial = randomBytes(16);
1155
- serial[0] &= 127;
1156
- const issuer = derSeq(derSet(derSeq(derOid("2.5.4.3"), derUtf8String(commonName))));
1157
- const subject = issuer;
1158
- const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
1159
- const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
1160
- const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter));
1161
- const spki = publicKey.export({
1162
- type: "spki",
1163
- format: "der"
1164
- });
1165
- const extensionsWrapper = derTag(163, derSeq(derSeq(derOid("2.5.29.19"), derBoolean(true), derOctetString(derSeq())), derSeq(derOid("2.5.29.15"), derBoolean(true), derOctetString(derBitString(Buffer.from([160])))), derSeq(derOid("2.5.29.37"), derOctetString(derSeq(derOid("1.3.6.1.5.5.7.3.1")))), derSeq(derOid("2.5.29.17"), derOctetString(derSeq(...hosts.map(sanGeneralName))))));
1166
- const tbs = derSeq(derTag(160, derInt(Buffer.from([2]))), derInt(serial), sha256WithRsa(), issuer, validity, subject, spki, extensionsWrapper);
1167
- const signature = createSign("sha256").update(tbs).end().sign(privateKey);
1168
- const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature));
1169
- return {
1170
- certDer,
1171
- certPem: pemEncode("CERTIFICATE", certDer),
1172
- keyPem: privateKey.export({
1173
- type: "pkcs8",
1174
- format: "pem"
1175
- }).toString(),
1176
- publicKey,
1177
- privateKey
1178
- };
1179
- }
1180
- //#endregion
1181
- //#region src/tls.ts
1182
- /**
1183
- * TLS material management for the gateway: self-signed certificates are
1184
- * generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
1185
- * restarts reuse the same certificate instead of minting a new one every
1186
- * boot; custom certificates are read straight from user-supplied PEM paths.
1187
- *
1188
- * @module @riceawa/dsh-lan-gateway/tls
1189
- */
1190
- /** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
1191
- function tlsDir(home = homedir()) {
1192
- return join(home, ".dsh", "lan-gateway", "tls");
1193
- }
1194
- const SELF_SIGNED_CERT_FILE = "selfsigned.crt";
1195
- const SELF_SIGNED_KEY_FILE = "selfsigned.key";
1196
- function privateWrite(path, content) {
1197
- writeFileSync(path, content, { mode: 384 });
1198
- try {
1199
- chmodSync(path, 384);
1200
- } catch {}
1201
- }
1202
- /**
1203
- * Load the persisted self-signed certificate, generating it on first use.
1204
- * @param opts - hosts / validity for a fresh certificate.
1205
- * @param home - dsh home override (tests).
1206
- * @returns the material and whether it was just created.
1207
- */
1208
- function loadOrCreateSelfSigned(opts, home = homedir()) {
1209
- const dir = tlsDir(home);
1210
- const certPath = join(dir, SELF_SIGNED_CERT_FILE);
1211
- const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
1212
- if (existsSync(certPath) && existsSync(keyPath)) try {
1213
- const cert = readFileSync(certPath, "utf8");
1214
- const key = readFileSync(keyPath, "utf8");
1215
- new X509Certificate(cert);
1216
- return {
1217
- material: {
1218
- cert,
1219
- key
1220
- },
1221
- created: false
1222
- };
1223
- } catch {}
1224
- const material = generateSelfSignedMaterial(opts);
1225
- mkdirSync(dir, { recursive: true });
1226
- privateWrite(keyPath, material.key);
1227
- privateWrite(certPath, material.cert);
1228
- return {
1229
- material,
1230
- created: true
1231
- };
1232
- }
1233
- /**
1234
- * Load the self-signed material a listener should serve: generate it on first
1235
- * use, reuse the persisted pair otherwise, and replace a persisted certificate
1236
- * whose validity has already lapsed.
1237
- *
1238
- * Nothing renews a self-signed certificate in place, and a browser refuses a
1239
- * lapsed one outright, so without this a certificate that ran out would keep
1240
- * being served until an operator happened to read the expiry date out of
1241
- * `status` and act on it. Renewing mints a fresh key, so a client that had
1242
- * trusted the old certificate has to trust the new one — but that is the case
1243
- * either way, the old one having lapsed.
1244
- */
1245
- function loadOrRenewSelfSigned(opts, home = homedir()) {
1246
- const { material, created } = loadOrCreateSelfSigned(opts, home);
1247
- if (created || !isCertExpired(material.cert)) return {
1248
- material,
1249
- renewed: false
1250
- };
1251
- return {
1252
- material: regenerateSelfSigned(opts, home),
1253
- renewed: true
1254
- };
1255
- }
1256
- /**
1257
- * Force-regenerate the self-signed certificate (new key + cert), replacing
1258
- * the persisted files. Used by `lan_gateway tls-regenerate`.
1259
- */
1260
- function regenerateSelfSigned(opts, home = homedir()) {
1261
- const dir = tlsDir(home);
1262
- mkdirSync(dir, { recursive: true });
1263
- const material = generateSelfSignedMaterial(opts);
1264
- privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key);
1265
- privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
1266
- return material;
1267
- }
1268
- function generateSelfSignedMaterial(opts) {
1269
- const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
1270
- if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
1271
- const { certPem, keyPem } = generateSelfSignedCert({
1272
- hosts,
1273
- days: opts.days,
1274
- ...opts.commonName !== void 0 ? { commonName: opts.commonName } : {}
1275
- });
1276
- return {
1277
- cert: certPem,
1278
- key: keyPem
1279
- };
1280
- }
1281
- /**
1282
- * Load a user-supplied certificate + key pair from PEM files.
1283
- * @param certPath - path to the PEM certificate (or chain).
1284
- * @param keyPath - path to the PEM private key.
1285
- * @returns the material.
1286
- */
1287
- function loadCustomCert(certPath, keyPath) {
1288
- if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
1289
- if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
1290
- let cert;
1291
- try {
1292
- cert = readFileSync(certPath, "utf8");
1293
- } catch (error) {
1294
- throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
1295
- }
1296
- let key;
1297
- try {
1298
- key = readFileSync(keyPath, "utf8");
1299
- } catch (error) {
1300
- throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
1301
- }
1302
- try {
1303
- new X509Certificate(cert);
1304
- } catch {
1305
- throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
1306
- }
1307
- return {
1308
- cert,
1309
- key
1310
- };
1311
- }
1312
- /** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
1313
- function parseSelfSignedHosts(text) {
1314
- return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
1315
- }
1316
- /**
1317
- * Whether a PEM certificate's validity window has already closed. A lapsed
1318
- * certificate is a hard failure browsers will not let the user proceed past,
1319
- * so the listener replaces one rather than keep serving it.
1320
- */
1321
- function isCertExpired(certPem, now = Date.now()) {
1322
- const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
1323
- return Number.isFinite(expiresAt) && expiresAt <= now;
1324
- }
1325
- /** Describe a PEM certificate (throws on malformed input). */
1326
- function describeCert(certPem) {
1327
- const cert = new X509Certificate(certPem);
1328
- return {
1329
- subject: cert.subject,
1330
- issuer: cert.issuer,
1331
- validFrom: cert.validFrom,
1332
- validTo: cert.validTo,
1333
- fingerprint256: cert.fingerprint256,
1334
- ...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
1335
- };
1336
- }
1337
- function errorMessage(error) {
1338
- return error instanceof Error ? error.message : String(error);
1339
- }
1340
- //#endregion
1341
- //#region src/tool.ts
1342
- const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
1343
- /**
1344
- * Build the `lan_gateway` tool over a controller interface implemented by the
1345
- * plugin entry. Split so the tool stays testable and the plugin decides how
1346
- * the controller mutates state.
1347
- */
1348
- function lanGatewayTool(control) {
1349
- return defineTool({
1350
- name: LAN_GATEWAY_TOOL_NAME,
1351
- description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the ingress/TLS state, and the upstream-session-relay state. `enable` starts listening on 0.0.0.0 — a password is required, and by default every source (loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password; changing it revokes every existing session, and clearing it stops the listener. `rotate-secret` invalidates every issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
1352
- parameters: {
1353
- command: {
1354
- type: "string",
1355
- enum: [
1356
- "status",
1357
- "enable",
1358
- "disable",
1359
- "set-password",
1360
- "rotate-secret",
1361
- "tls-regenerate"
1362
- ],
1363
- description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password (setting revokes all sessions; clearing stops the listener). `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
1364
- },
1365
- password: {
1366
- type: "string",
1367
- description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
1368
- }
1369
- },
1370
- output: {
1371
- schema: {
1372
- type: "object",
1373
- additionalProperties: false,
1374
- properties: {
1375
- ok: {
1376
- type: "boolean",
1377
- required: true
1378
- },
1379
- message: {
1380
- type: "string",
1381
- required: true
1382
- }
1383
- }
1384
- },
1385
- render: (_args, value) => [{
1386
- type: "text",
1387
- text: value.message
1388
- }]
1389
- },
1390
- async execute(args, _exec) {
1391
- switch (args.command ?? "status") {
1392
- case "status": return control.status();
1393
- case "enable": return control.enable();
1394
- case "disable": return control.disable();
1395
- case "set-password": {
1396
- const password = args.password;
1397
- return control.setPassword(typeof password === "string" ? password : void 0);
1398
- }
1399
- case "rotate-secret": return control.rotateSecret();
1400
- case "tls-regenerate": return control.regenerateTls();
1401
- }
1402
- }
1616
+ const commonName = options.commonName?.trim() || hosts[0];
1617
+ const serial = randomBytes(16);
1618
+ serial[0] &= 127;
1619
+ const issuer = derSeq(derSet(derSeq(derOid("2.5.4.3"), derUtf8String(commonName))));
1620
+ const subject = issuer;
1621
+ const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
1622
+ const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
1623
+ const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter));
1624
+ const spki = publicKey.export({
1625
+ type: "spki",
1626
+ format: "der"
1403
1627
  });
1628
+ const extensionsWrapper = derTag(163, derSeq(derSeq(derOid("2.5.29.19"), derBoolean(true), derOctetString(derSeq())), derSeq(derOid("2.5.29.15"), derBoolean(true), derOctetString(derBitString(Buffer.from([160])))), derSeq(derOid("2.5.29.37"), derOctetString(derSeq(derOid("1.3.6.1.5.5.7.3.1")))), derSeq(derOid("2.5.29.17"), derOctetString(derSeq(...hosts.map(sanGeneralName))))));
1629
+ const tbs = derSeq(derTag(160, derInt(Buffer.from([2]))), derInt(serial), sha256WithRsa(), issuer, validity, subject, spki, extensionsWrapper);
1630
+ const signature = createSign("sha256").update(tbs).end().sign(privateKey);
1631
+ const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature));
1632
+ return {
1633
+ certDer,
1634
+ certPem: pemEncode("CERTIFICATE", certDer),
1635
+ keyPem: privateKey.export({
1636
+ type: "pkcs8",
1637
+ format: "pem"
1638
+ }).toString(),
1639
+ publicKey,
1640
+ privateKey
1641
+ };
1404
1642
  }
1405
1643
  //#endregion
1406
- //#region src/upstream-session.ts
1644
+ //#region src/tls.ts
1407
1645
  /**
1408
- * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
1409
- *
1410
- * When dsh added browser-session authentication it stopped trusting a loopback
1411
- * Host header alone: every `/api` request (and the remote WebSocket mux) must
1412
- * now present a signed cookie bound to the authority it names
1413
- * (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
1414
- * process launch token. A reverse proxy that rewrites Host to loopback — which
1415
- * is what this gateway does — therefore gets a 401 no matter how the Host is
1416
- * forged. The gateway cannot mint that cookie itself (the signing secret lives
1417
- * in dsh's credential provider), so it does exactly what a browser does: on the
1418
- * loopback transport it visits the launch-token URL, keeps the Set-Cookie it
1419
- * earns, and replays that one shared session on every request it forwards.
1420
- *
1421
- * Semantics match the pre-existing "single password = single operator" model:
1422
- * whoever passes the gateway's own login rides this one upstream session. It is
1423
- * not multi-user authorization, and upstream (which holds the secret) remains
1424
- * the actual authority over what the session may do.
1425
- *
1426
- * The relay is a no-op on a base without browser sessions: acquisition fails
1427
- * and `cookie()` returns undefined, so the gateway simply forwards without a
1428
- * session cookie exactly as it did against an older dsh.
1646
+ * TLS material management for the gateway: self-signed certificates are
1647
+ * generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
1648
+ * restarts reuse the same certificate instead of minting a new one every
1649
+ * boot; custom certificates are read straight from user-supplied PEM paths.
1429
1650
  *
1430
- * @module @riceawa/dsh-lan-gateway/upstream-session
1651
+ * @module @riceawa/dsh-lan-gateway/tls
1431
1652
  */
1432
- /** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
1433
- const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
1434
- /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
1435
- function nameValueOnly(setCookie) {
1436
- const semi = setCookie.indexOf(";");
1437
- return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
1653
+ /** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
1654
+ function tlsDir(home = homedir()) {
1655
+ return join(home, ".dsh", "lan-gateway", "tls");
1438
1656
  }
1439
- /**
1440
- * The pathname of a URL, for logging. Never the whole URL: the authenticated
1441
- * URL carries the launch token as a query parameter, and that token is a
1442
- * bearer credential for the upstream harness.
1443
- */
1444
- function pathOf(url) {
1657
+ const SELF_SIGNED_CERT_FILE = "selfsigned.crt";
1658
+ const SELF_SIGNED_KEY_FILE = "selfsigned.key";
1659
+ function privateWrite(path, content) {
1660
+ writeFileSync(path, content, { mode: 384 });
1445
1661
  try {
1446
- return new URL(url).pathname;
1447
- } catch {
1448
- return "<unparseable>";
1449
- }
1450
- }
1451
- /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
1452
- function cookieNameOf(setCookie) {
1453
- const eq = setCookie.indexOf("=");
1454
- return eq === -1 ? "" : setCookie.slice(0, eq).trim();
1662
+ chmodSync(path, 384);
1663
+ } catch {}
1455
1664
  }
1456
1665
  /**
1457
- * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
1458
- * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
1459
- * is followed by the authority hash, never by `=` itself, so the test is a
1460
- * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
1461
- * and silently relays every request anonymously.
1666
+ * Load the persisted self-signed certificate, generating it on first use.
1667
+ * @param opts - hosts / validity for a fresh certificate.
1668
+ * @param home - dsh home override (tests).
1669
+ * @returns the material and whether it was just created.
1462
1670
  */
1463
- function isUpstreamSessionCookie(setCookie) {
1464
- const name = cookieNameOf(setCookie);
1465
- return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
1671
+ function loadOrCreateSelfSigned(opts, home = homedir()) {
1672
+ const dir = tlsDir(home);
1673
+ const certPath = join(dir, SELF_SIGNED_CERT_FILE);
1674
+ const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
1675
+ if (existsSync(certPath) && existsSync(keyPath)) try {
1676
+ const cert = readFileSync(certPath, "utf8");
1677
+ const key = readFileSync(keyPath, "utf8");
1678
+ new X509Certificate(cert);
1679
+ return {
1680
+ material: {
1681
+ cert,
1682
+ key
1683
+ },
1684
+ created: false
1685
+ };
1686
+ } catch {}
1687
+ const material = generateSelfSignedMaterial(opts);
1688
+ mkdirSync(dir, { recursive: true });
1689
+ privateWrite(keyPath, material.key);
1690
+ privateWrite(certPath, material.cert);
1691
+ return {
1692
+ material,
1693
+ created: true
1694
+ };
1466
1695
  }
1467
- /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
1468
- function maxAgeSeconds(setCookie) {
1469
- const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
1470
- return match === null ? void 0 : Number(match[1]);
1696
+ /**
1697
+ * Load the self-signed material a listener should serve: generate it on first
1698
+ * use, reuse the persisted pair otherwise, and replace a persisted certificate
1699
+ * whose validity has already lapsed.
1700
+ *
1701
+ * Nothing renews a self-signed certificate in place, and a browser refuses a
1702
+ * lapsed one outright, so without this a certificate that ran out would keep
1703
+ * being served until an operator happened to read the expiry date out of
1704
+ * `status` and act on it. Renewing mints a fresh key, so a client that had
1705
+ * trusted the old certificate has to trust the new one — but that is the case
1706
+ * either way, the old one having lapsed.
1707
+ */
1708
+ function loadOrRenewSelfSigned(opts, home = homedir()) {
1709
+ const { material, created } = loadOrCreateSelfSigned(opts, home);
1710
+ if (created || !isCertExpired(material.cert)) return {
1711
+ material,
1712
+ renewed: false
1713
+ };
1714
+ return {
1715
+ material: regenerateSelfSigned(opts, home),
1716
+ renewed: true
1717
+ };
1471
1718
  }
1472
1719
  /**
1473
- * Perform the token exchange over loopback: GET the launch-token URL with the
1474
- * upstream authority as Host, read the Set-Cookie the index route mints, and
1475
- * return its `name=value` plus expiry (or undefined when the exchange failed
1476
- * or no session cookie came back — e.g. an older base without browser
1477
- * sessions).
1720
+ * Force-regenerate the self-signed certificate (new key + cert), replacing
1721
+ * the persisted files. Used by `lan_gateway tls-regenerate`.
1478
1722
  */
1479
- function exchange(url, authority, port, log) {
1480
- return new Promise((resolve) => {
1481
- let target;
1482
- try {
1483
- target = new URL(url);
1484
- } catch {
1485
- log("exchange: authenticatedUrl is not parseable");
1486
- resolve(void 0);
1487
- return;
1488
- }
1489
- const request = http.request({
1490
- host: "127.0.0.1",
1491
- port,
1492
- method: "GET",
1493
- path: `${target.pathname}${target.search}`,
1494
- headers: {
1495
- host: authority,
1496
- accept: "text/html"
1497
- }
1498
- }, (response) => {
1499
- const setCookies = response.headers["set-cookie"];
1500
- response.resume();
1501
- if (setCookies === void 0) {
1502
- log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
1503
- resolve(void 0);
1504
- return;
1505
- }
1506
- const all = Array.isArray(setCookies) ? setCookies : [setCookies];
1507
- const raw = all.find(isUpstreamSessionCookie);
1508
- if (raw === void 0) {
1509
- const names = all.map(cookieNameOf).filter((name) => name !== "");
1510
- log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
1511
- resolve(void 0);
1512
- return;
1513
- }
1514
- const header = nameValueOnly(raw);
1515
- const maxAge = maxAgeSeconds(raw);
1516
- log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
1517
- resolve({
1518
- header,
1519
- expiresAt: Date.now() + (maxAge ?? 0) * 1e3
1520
- });
1521
- });
1522
- request.on("error", (error) => {
1523
- log(`exchange error: ${error.message}`);
1524
- resolve(void 0);
1525
- });
1526
- request.setTimeout(5e3, () => {
1527
- log("exchange timeout (5s)");
1528
- request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
1529
- });
1530
- request.end();
1531
- });
1723
+ function regenerateSelfSigned(opts, home = homedir()) {
1724
+ const dir = tlsDir(home);
1725
+ mkdirSync(dir, { recursive: true });
1726
+ const material = generateSelfSignedMaterial(opts);
1727
+ privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key);
1728
+ privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
1729
+ return material;
1532
1730
  }
1533
1731
  /**
1534
- * A cached {@link UpstreamSession} acquired through the launch-token exchange.
1535
- * Acquisition runs at most once concurrently and the result is cached until it
1536
- * nears expiry or {@link invalidate} is called.
1732
+ * Read the persisted self-signed certificate for a status report, or undefined
1733
+ * when none has been generated yet.
1734
+ *
1735
+ * Nothing is created or written here. The status path answers a question about
1736
+ * a listener that is already running (or was), and minting a key pair — an RSA
1737
+ * generation plus two file writes — to answer a read would both be slow and
1738
+ * leave material on disk for a gateway that never started. Generation belongs
1739
+ * to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
1740
+ * @param home - dsh home override (tests).
1741
+ * @returns the certificate material as persisted, or undefined.
1537
1742
  */
1538
- var UpstreamSessionRelay = class {
1539
- port;
1540
- authority;
1541
- authenticatedUrl;
1542
- log;
1543
- held;
1544
- inflight;
1545
- constructor(options) {
1546
- this.port = options.port;
1547
- this.authority = options.authority ?? `127.0.0.1:${options.port}`;
1548
- this.authenticatedUrl = options.authenticatedUrl;
1549
- this.log = options.log ?? (() => {});
1550
- }
1551
- /** Whether the held session is still comfortably inside its lifetime. */
1552
- fresh() {
1553
- const held = this.held;
1554
- if (held === void 0) return false;
1555
- return Date.now() < held.expiresAt - 6e4;
1556
- }
1557
- peek() {
1558
- return this.held?.header;
1559
- }
1560
- invalidate() {
1561
- if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
1562
- this.held = void 0;
1743
+ function readSelfSignedStatus(home = homedir()) {
1744
+ const dir = tlsDir(home);
1745
+ const certPath = join(dir, SELF_SIGNED_CERT_FILE);
1746
+ const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
1747
+ if (!existsSync(certPath) || !existsSync(keyPath)) return void 0;
1748
+ const cert = readFileSync(certPath, "utf8");
1749
+ const key = readFileSync(keyPath, "utf8");
1750
+ new X509Certificate(cert);
1751
+ return {
1752
+ cert,
1753
+ key
1754
+ };
1755
+ }
1756
+ function generateSelfSignedMaterial(opts) {
1757
+ const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
1758
+ if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
1759
+ const { certPem, keyPem } = generateSelfSignedCert({
1760
+ hosts,
1761
+ days: opts.days,
1762
+ ...opts.commonName !== void 0 ? { commonName: opts.commonName } : {}
1763
+ });
1764
+ return {
1765
+ cert: certPem,
1766
+ key: keyPem
1767
+ };
1768
+ }
1769
+ /**
1770
+ * Load a user-supplied certificate + key pair from PEM files.
1771
+ * @param certPath - path to the PEM certificate (or chain).
1772
+ * @param keyPath - path to the PEM private key.
1773
+ * @returns the material.
1774
+ */
1775
+ function loadCustomCert(certPath, keyPath) {
1776
+ if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
1777
+ if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
1778
+ let cert;
1779
+ try {
1780
+ cert = readFileSync(certPath, "utf8");
1781
+ } catch (error) {
1782
+ throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
1563
1783
  }
1564
- async cookie() {
1565
- if (this.fresh()) return this.held?.header;
1566
- return this.acquire();
1784
+ let key;
1785
+ try {
1786
+ key = readFileSync(keyPath, "utf8");
1787
+ } catch (error) {
1788
+ throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
1567
1789
  }
1568
- acquire() {
1569
- if (this.inflight !== void 0) return this.inflight;
1570
- const pending = this.doExchange().finally(() => {
1571
- this.inflight = void 0;
1572
- });
1573
- this.inflight = pending;
1574
- return pending;
1790
+ try {
1791
+ new X509Certificate(cert);
1792
+ } catch {
1793
+ throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
1575
1794
  }
1576
- async doExchange() {
1577
- const url = this.authenticatedUrl();
1578
- if (url === void 0) {
1579
- this.log("authenticatedUrl() returned undefined; keeping current session");
1580
- return this.held?.header;
1795
+ return {
1796
+ cert,
1797
+ key
1798
+ };
1799
+ }
1800
+ /** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
1801
+ function parseSelfSignedHosts(text) {
1802
+ return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
1803
+ }
1804
+ /**
1805
+ * Whether a PEM certificate's validity window has already closed. A lapsed
1806
+ * certificate is a hard failure browsers will not let the user proceed past,
1807
+ * so the listener replaces one rather than keep serving it.
1808
+ */
1809
+ function isCertExpired(certPem, now = Date.now()) {
1810
+ const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
1811
+ return Number.isFinite(expiresAt) && expiresAt <= now;
1812
+ }
1813
+ /** Describe a PEM certificate (throws on malformed input). */
1814
+ function describeCert(certPem) {
1815
+ const cert = new X509Certificate(certPem);
1816
+ return {
1817
+ subject: cert.subject,
1818
+ issuer: cert.issuer,
1819
+ validFrom: cert.validFrom,
1820
+ validTo: cert.validTo,
1821
+ fingerprint256: cert.fingerprint256,
1822
+ ...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
1823
+ };
1824
+ }
1825
+ function errorMessage(error) {
1826
+ return error instanceof Error ? error.message : String(error);
1827
+ }
1828
+ //#endregion
1829
+ //#region src/tool.ts
1830
+ const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
1831
+ /**
1832
+ * Build the `lan_gateway` tool over a controller interface implemented by the
1833
+ * plugin entry. Split so the tool stays testable and the plugin decides how
1834
+ * the controller mutates state.
1835
+ */
1836
+ function lanGatewayTool(control) {
1837
+ return defineTool({
1838
+ name: LAN_GATEWAY_TOOL_NAME,
1839
+ description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the ingress/TLS state, and the upstream-session-relay state. `enable` starts listening on 0.0.0.0 — a password is required, and by default every source (loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password; changing it revokes every existing session, and clearing it stops the listener. `rotate-secret` invalidates every issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
1840
+ parameters: {
1841
+ command: {
1842
+ type: "string",
1843
+ enum: [
1844
+ "status",
1845
+ "enable",
1846
+ "disable",
1847
+ "set-password",
1848
+ "rotate-secret",
1849
+ "tls-regenerate"
1850
+ ],
1851
+ description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password (setting revokes all sessions; clearing stops the listener). `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
1852
+ },
1853
+ password: {
1854
+ type: "string",
1855
+ description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
1856
+ }
1857
+ },
1858
+ output: {
1859
+ schema: {
1860
+ type: "object",
1861
+ additionalProperties: false,
1862
+ properties: {
1863
+ ok: {
1864
+ type: "boolean",
1865
+ required: true
1866
+ },
1867
+ message: {
1868
+ type: "string",
1869
+ required: true
1870
+ }
1871
+ }
1872
+ },
1873
+ render: (_args, value) => [{
1874
+ type: "text",
1875
+ text: value.message
1876
+ }]
1877
+ },
1878
+ async execute(args, _exec) {
1879
+ switch (args.command ?? "status") {
1880
+ case "status": return control.status();
1881
+ case "enable": return control.enable();
1882
+ case "disable": return control.disable();
1883
+ case "set-password": {
1884
+ const password = args.password;
1885
+ return control.setPassword(typeof password === "string" ? password : void 0);
1886
+ }
1887
+ case "rotate-secret": return control.rotateSecret();
1888
+ case "tls-regenerate": return control.regenerateTls();
1889
+ }
1581
1890
  }
1582
- this.log(`acquiring session from ${pathOf(url)}`);
1583
- const result = await exchange(url, this.authority, this.port, this.log);
1584
- if (result !== void 0) {
1585
- this.held = result;
1586
- this.log("session acquired and cached");
1587
- } else this.log("exchange failed; keeping current session");
1588
- return this.held?.header;
1589
- }
1590
- };
1891
+ });
1892
+ }
1591
1893
  //#endregion
1592
1894
  //#region src/index.ts
1593
1895
  /** Stable Cordis plugin name. */
@@ -1595,40 +1897,91 @@ const name = "dsh-lan-gateway";
1595
1897
  /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
1596
1898
  const inject = ["webServer", "tools"];
1597
1899
  /**
1598
- * The `lan-gateway` user-settings namespace, mirroring the composition schema.
1599
- * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
1600
- * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
1601
- * shape works against both that release line and the older branded one.
1900
+ * Schemastery configuration validated by the Loader.
1901
+ *
1902
+ * Every field is `.volatile()`, which is what lets the Settings service write
1903
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
1904
+ * other path (`not volatile`). The mark also changes the runtime shape — a
1905
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
1906
+ * value the rest of this file expects — so read it through `readConfig`.
1602
1907
  */
1603
- const NS = "lan-gateway";
1604
- /** Optional config keys: an empty submitted value clears them back to the composition layer. */
1605
- const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
1606
- "dshTargetPort",
1607
- "tlsCertPath",
1608
- "tlsKeyPath",
1609
- "trustedTerminator"
1610
- ]);
1611
- /** Schemastery configuration validated by the Loader. */
1612
1908
  const Config = z.object({
1613
- enabled: z.boolean().default(false),
1614
- gatewayPort: z.natural().min(1).max(65535).default(3081),
1615
- dshTargetPort: z.natural().min(1).max(65535),
1616
- lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
1617
- lanPasswordless: z.boolean().default(false),
1618
- authRequired: z.boolean().default(true),
1619
- cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
1620
- cookieName: z.string().default("dsh_gw_auth"),
1621
- tlsEnabled: z.boolean().default(false),
1622
- tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
1623
- tlsCertPath: z.string(),
1624
- tlsKeyPath: z.string(),
1625
- tlsSelfSignedHosts: z.string().default("localhost"),
1626
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1627
- allowInsecurePlaintext: z.boolean().default(false),
1628
- trustedTerminator: z.string(),
1629
- secureCookies: z.boolean()
1909
+ enabled: z.boolean().default(false).volatile(),
1910
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
1911
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
1912
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
1913
+ lanPasswordless: z.boolean().default(false).volatile(),
1914
+ authRequired: z.boolean().default(true).volatile(),
1915
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
1916
+ cookieName: z.string().default("dsh_gw_auth").volatile(),
1917
+ tlsEnabled: z.boolean().default(false).volatile(),
1918
+ tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed").volatile(),
1919
+ tlsCertPath: z.string().volatile(),
1920
+ tlsKeyPath: z.string().volatile(),
1921
+ tlsSelfSignedHosts: z.string().default("localhost").volatile(),
1922
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
1923
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
1924
+ trustedTerminator: z.string().volatile(),
1925
+ secureCookies: z.boolean().volatile()
1630
1926
  });
1631
1927
  /**
1928
+ * Unwrap the config references into the plain values every other function in
1929
+ * this file reads. Called on each access rather than once, because a settings
1930
+ * write updates the references in place.
1931
+ * @param refs - the config object handed to `apply`.
1932
+ * @returns one detached plain snapshot.
1933
+ */
1934
+ function readConfig(refs) {
1935
+ const dshTargetPort = refs.dshTargetPort?.get();
1936
+ const authRequired = refs.authRequired?.get();
1937
+ const tlsCertPath = refs.tlsCertPath?.get();
1938
+ const tlsKeyPath = refs.tlsKeyPath?.get();
1939
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get();
1940
+ const trustedTerminator = refs.trustedTerminator?.get();
1941
+ const secureCookies = refs.secureCookies?.get();
1942
+ return {
1943
+ enabled: refs.enabled.get(),
1944
+ gatewayPort: refs.gatewayPort.get(),
1945
+ lanCidrs: [...refs.lanCidrs.get()],
1946
+ lanPasswordless: refs.lanPasswordless.get(),
1947
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
1948
+ cookieName: refs.cookieName.get(),
1949
+ tlsEnabled: refs.tlsEnabled.get(),
1950
+ tlsMode: refs.tlsMode.get(),
1951
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
1952
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
1953
+ ...dshTargetPort !== void 0 ? { dshTargetPort } : {},
1954
+ ...authRequired !== void 0 ? { authRequired } : {},
1955
+ ...tlsCertPath !== void 0 ? { tlsCertPath } : {},
1956
+ ...tlsKeyPath !== void 0 ? { tlsKeyPath } : {},
1957
+ ...tlsSelfSignedHosts !== void 0 ? { tlsSelfSignedHosts } : {},
1958
+ ...trustedTerminator !== void 0 ? { trustedTerminator } : {},
1959
+ ...secureCookies !== void 0 ? { secureCookies } : {}
1960
+ };
1961
+ }
1962
+ /**
1963
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
1964
+ * builds it. Exported for tests that drive `apply` directly.
1965
+ * @param raw - a config object; missing fields take their schema defaults.
1966
+ * @returns one reference per volatile field.
1967
+ */
1968
+ function configRefs(raw) {
1969
+ return Config(raw);
1970
+ }
1971
+ /**
1972
+ * Validate a raw config object the way the Loader does, and unwrap it.
1973
+ *
1974
+ * `Config` marks every field volatile, so a validation hands the values back as
1975
+ * references (typed deeply-readonly by schemastery); this returns the plain
1976
+ * shape the rest of the file reads. Used to judge a config the Settings card is
1977
+ * about to save, before it is persisted.
1978
+ * @param raw - a config object; missing fields take their schema defaults.
1979
+ * @returns the validated plain config.
1980
+ */
1981
+ function validateConfig(raw) {
1982
+ return readConfig(configRefs(raw));
1983
+ }
1984
+ /**
1632
1985
  * The fail-closed problems that prevent a config from enabling the listener.
1633
1986
  * Returns every problem (not just the first) so the operator sees the full
1634
1987
  * migration at once. Exported for tests.
@@ -1697,32 +2050,26 @@ function listenerKey(cfg, relayAvailable) {
1697
2050
  relayAvailable
1698
2051
  ]);
1699
2052
  }
1700
- /** One-line TLS description for status output. */
2053
+ /**
2054
+ * One-line TLS description for status output.
2055
+ *
2056
+ * Never generates: this is the read path behind `GET /lan-gateway/config` and
2057
+ * `lan_gateway status`, and a status query that mints an RSA key and writes a
2058
+ * certificate to disk is not a read. The material is created when the listener
2059
+ * starts, or by `lan_gateway tls-regenerate`.
2060
+ */
1701
2061
  function tlsStatusLine(cfg) {
1702
2062
  if (!cfg.tlsEnabled) return "off";
1703
2063
  if (cfg.tlsMode === "custom") return `custom (${cfg.tlsCertPath ?? "?"}, ${cfg.tlsKeyPath ?? "?"})`;
1704
2064
  try {
1705
- const { material } = loadOrCreateSelfSigned({
1706
- hosts: parseSelfSignedHosts(cfg.tlsSelfSignedHosts),
1707
- days: cfg.tlsCertMaxAgeDays
1708
- });
1709
- const info = describeCert(material.cert);
2065
+ const status = readSelfSignedStatus();
2066
+ if (status === void 0) return "self-signed (not generated yet — created when the listener starts)";
2067
+ const info = describeCert(status.cert);
1710
2068
  return `self-signed [${info.subject}] exp ${info.validTo}`;
1711
2069
  } catch (error) {
1712
2070
  return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`;
1713
2071
  }
1714
2072
  }
1715
- /** Whether `hostname` is loopback (127/8, localhost, ::1). */
1716
- function isLoopbackHost(hostname) {
1717
- if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
1718
- const parts = hostname.split(".");
1719
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1720
- }
1721
- const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1722
- "GET",
1723
- "HEAD",
1724
- "OPTIONS"
1725
- ]);
1726
2073
  /**
1727
2074
  * Same-origin loopback fence for the native `/lan-gateway/config` route. The
1728
2075
  * gateway refuses to relay this prefix, so the only way in is the native
@@ -1748,21 +2095,101 @@ function isTrustedConfigRequest(req) {
1748
2095
  if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
1749
2096
  return true;
1750
2097
  }
2098
+ /**
2099
+ * Turn a submitted config patch into the next user section: only keys the card
2100
+ * can edit, only real values, and `null` (or an emptied optional) removes the
2101
+ * key rather than storing it.
2102
+ *
2103
+ * The patch is built from the *submitted* object, never from a schema call's
2104
+ * output. Schemastery fills defaults into whatever it validates and passes
2105
+ * unknown keys through, so deriving the section from `Config(submitted)` wrote
2106
+ * `authRequired: true` (a capability that exists only to be refused) and any
2107
+ * stray key into the user's settings on every save — and, because it also
2108
+ * materialized `cookieName`, reset an operator's custom cookie name to the
2109
+ * schema default.
2110
+ *
2111
+ * A `null` value is the card's clear: the key is dropped from the patch, which
2112
+ * leaves it absent from the section, so it re-inherits the composition layer.
2113
+ */
2114
+ function buildConfigPatch(submitted) {
2115
+ const patch = {};
2116
+ const clear = [];
2117
+ const unknown = [];
2118
+ for (const [key, value] of Object.entries(submitted)) {
2119
+ if (!CONFIG_FIELD_KEYS.has(key)) {
2120
+ unknown.push(key);
2121
+ continue;
2122
+ }
2123
+ if (value === null || value === void 0) {
2124
+ clear.push(key);
2125
+ continue;
2126
+ }
2127
+ if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) {
2128
+ clear.push(key);
2129
+ continue;
2130
+ }
2131
+ patch[key] = value;
2132
+ }
2133
+ return {
2134
+ patch,
2135
+ clear,
2136
+ unknown
2137
+ };
2138
+ }
1751
2139
  function apply(ctx, config) {
1752
2140
  let state = loadState();
1753
2141
  let gateway;
1754
2142
  let startedWith;
1755
2143
  let lastError;
2144
+ /**
2145
+ * The operator's run intent, used only while no settings service is attached.
2146
+ * With settings present, `enabled` in the settings section *is* the intent —
2147
+ * the card and the tool write the same field, so there is one truth rather
2148
+ * than two that disagree.
2149
+ */
1756
2150
  let manualOverride;
1757
2151
  /** Whether the base enforces browser-session auth; set once `connection` is seen. */
1758
2152
  let upstreamSessionAvailable = false;
2153
+ /**
2154
+ * Identifies the current `connection` handler. A provider that detaches and a
2155
+ * new one that attaches run their disposers in an order the plugin does not
2156
+ * control, and a stale disposer clearing `makeRelay` would strand the live
2157
+ * provider — so a disposer only acts if it is still the latest generation.
2158
+ */
2159
+ let connectionGeneration = 0;
1759
2160
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
1760
2161
  let makeRelay;
1761
- /** The authoritative config: settings section when attached, else composition. */
1762
- let configSource = () => config;
1763
- /** Serializes listener start/stop/restart so settings changes cannot race. */
1764
- let syncing = Promise.resolve();
1765
- const effective = () => configSource();
2162
+ /** Whether the settings service is attached, so writes reach the profile entry. */
2163
+ let settingsAttached = false;
2164
+ /**
2165
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
2166
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
2167
+ * with is gone along with `settingsScope`.
2168
+ */
2169
+ let settingsEntryId;
2170
+ /**
2171
+ * The settings service, for the one write a merge patch cannot express: a key
2172
+ * must be *removed* to re-inherit the composition layer, and only its
2173
+ * path-addressed `mutate` can unset one.
2174
+ */
2175
+ let settingsProvider;
2176
+ /**
2177
+ * One queue for every lifecycle side effect. Settings changes, tool commands,
2178
+ * credential changes, TLS regeneration and plugin disposal all land here, so
2179
+ * two of them can never interleave a stop with a start.
2180
+ */
2181
+ let lifecycle = Promise.resolve();
2182
+ /** Set by the dispose hook; a start that completes after it must undo itself. */
2183
+ let disposed = false;
2184
+ const effective = () => readConfig(config);
2185
+ /** Queue one lifecycle action behind every action already running. */
2186
+ const enqueue = (reason, action) => {
2187
+ lifecycle = lifecycle.then(action).catch((error) => {
2188
+ lastError = error instanceof Error ? error.message : String(error);
2189
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
2190
+ });
2191
+ return lifecycle;
2192
+ };
1766
2193
  const startGateway = async (cfg) => {
1767
2194
  if (gateway !== void 0) return;
1768
2195
  const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
@@ -1789,6 +2216,10 @@ function apply(ctx, config) {
1789
2216
  }
1790
2217
  }, state);
1791
2218
  await next.listen();
2219
+ if (disposed) {
2220
+ await next.close();
2221
+ return;
2222
+ }
1792
2223
  gateway = next;
1793
2224
  startedWith = listenerKey(cfg, makeRelay !== void 0);
1794
2225
  ctx.logger.info(`dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}${encryptedIngress ? "" : " (plaintext, explicit allowInsecurePlaintext)"}${makeRelay !== void 0 ? " [shared upstream session relay]" : " [no upstream session relay: base has no browser-session auth]"}`);
@@ -1803,42 +2234,58 @@ function apply(ctx, config) {
1803
2234
  ctx.logger.info("dsh-lan-gateway: stopped");
1804
2235
  }
1805
2236
  };
2237
+ /** The config the listener should be running under, intent included. */
2238
+ const desiredConfig = () => {
2239
+ const cfg = effective();
2240
+ if (settingsAttached) return cfg;
2241
+ return manualOverride === void 0 ? cfg : {
2242
+ ...cfg,
2243
+ enabled: manualOverride
2244
+ };
2245
+ };
1806
2246
  /** Reconcile the listener with the effective config (start/stop/restart). */
1807
2247
  const syncGateway = (reason) => {
1808
- syncing = syncing.then(async () => {
2248
+ return enqueue(reason, async () => {
1809
2249
  lastError = void 0;
1810
- const cfg = effective();
1811
- const shouldRun = manualOverride ?? cfg.enabled;
1812
- try {
1813
- if (gateway === void 0) {
1814
- if (shouldRun) await startGateway(cfg);
1815
- } else if (!shouldRun) await stopGateway();
1816
- else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
1817
- await stopGateway();
1818
- await startGateway(cfg);
1819
- }
1820
- } catch (error) {
1821
- lastError = error instanceof Error ? error.message : String(error);
1822
- ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
2250
+ if (disposed) return;
2251
+ const cfg = desiredConfig();
2252
+ if (gateway === void 0) {
2253
+ if (cfg.enabled) await startGateway(cfg);
2254
+ } else if (!cfg.enabled) await stopGateway();
2255
+ else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
2256
+ await stopGateway();
2257
+ await startGateway(cfg);
1823
2258
  }
1824
2259
  });
1825
- return syncing;
1826
2260
  };
1827
- let settingsScope;
2261
+ /** Record the run intent where it will survive: the profile entry, or memory. */
2262
+ const setRunIntent = async (enabled) => {
2263
+ if (settingsAttached && settingsProvider !== void 0 && settingsEntryId !== void 0) {
2264
+ await settingsProvider.update(settingsEntryId, { enabled });
2265
+ return;
2266
+ }
2267
+ manualOverride = enabled;
2268
+ };
1828
2269
  ctx.inject(["settings"], (sctx) => {
1829
- const scope = sctx.settings.register(NS, Config, { base: config });
1830
- settingsScope = scope;
1831
- configSource = () => scope.get();
1832
- sctx.effect(() => scope.watch(() => {
2270
+ const entryId = ctx.fiber.entry?.options.id;
2271
+ if (entryId === void 0) return;
2272
+ settingsEntryId = entryId;
2273
+ settingsProvider = sctx.settings;
2274
+ settingsAttached = true;
2275
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
2276
+ sctx.effect(() => ctx.on("loader/volatile-update", () => {
1833
2277
  syncGateway("settings change");
1834
2278
  }));
1835
2279
  sctx.effect(() => () => {
1836
- configSource = () => config;
1837
- settingsScope = void 0;
2280
+ settingsEntryId = void 0;
2281
+ settingsProvider = void 0;
2282
+ settingsAttached = false;
2283
+ syncGateway("settings detach");
1838
2284
  });
1839
2285
  syncGateway("settings attach");
1840
2286
  });
1841
2287
  ctx.inject(["connection"], (ccx) => {
2288
+ const generation = ++connectionGeneration;
1842
2289
  upstreamSessionAvailable = true;
1843
2290
  ctx.logger.info("dsh-lan-gateway: connection service attached; upstream session relay enabled");
1844
2291
  makeRelay = (dshPort) => new UpstreamSessionRelay({
@@ -1846,9 +2293,26 @@ function apply(ctx, config) {
1846
2293
  authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
1847
2294
  log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`)
1848
2295
  });
2296
+ ccx.effect(() => () => {
2297
+ if (generation !== connectionGeneration) return;
2298
+ makeRelay = void 0;
2299
+ upstreamSessionAvailable = false;
2300
+ syncGateway("connection detach");
2301
+ });
1849
2302
  syncGateway("connection attach");
1850
2303
  });
1851
2304
  const configRouteHandler = async (req, res) => {
2305
+ const snapshot = () => {
2306
+ const cfg = effective();
2307
+ return {
2308
+ config: cfg,
2309
+ running: gateway !== void 0,
2310
+ port: cfg.gatewayPort,
2311
+ tls: tlsStatusLine(cfg),
2312
+ upstreamSessionAvailable,
2313
+ lastError: lastError ?? null
2314
+ };
2315
+ };
1852
2316
  const send = (status, body) => {
1853
2317
  res.writeHead(status, { "content-type": "application/json" });
1854
2318
  res.end(JSON.stringify(body));
@@ -1858,15 +2322,7 @@ function apply(ctx, config) {
1858
2322
  return;
1859
2323
  }
1860
2324
  if (req.method === "GET") {
1861
- const cfg = effective();
1862
- send(200, {
1863
- config: cfg,
1864
- running: gateway !== void 0,
1865
- port: cfg.gatewayPort,
1866
- tls: tlsStatusLine(cfg),
1867
- upstreamSessionAvailable,
1868
- lastError: lastError ?? null
1869
- });
2325
+ send(200, snapshot());
1870
2326
  return;
1871
2327
  }
1872
2328
  if (req.method !== "POST") {
@@ -1886,41 +2342,37 @@ function apply(ctx, config) {
1886
2342
  send(400, { error: "body must be a config object" });
1887
2343
  return;
1888
2344
  }
1889
- let candidate;
1890
- try {
1891
- candidate = Config(submitted);
1892
- } catch (error) {
1893
- send(400, { error: error instanceof Error ? error.message : String(error) });
1894
- return;
1895
- }
1896
- if (settingsScope === void 0) {
2345
+ const settings = settingsProvider;
2346
+ const entryId = settingsEntryId;
2347
+ if (settings === void 0 || entryId === void 0) {
1897
2348
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
1898
2349
  return;
1899
2350
  }
2351
+ const { patch, clear, unknown } = buildConfigPatch(submitted);
2352
+ const candidate = validateConfig({
2353
+ ...effective(),
2354
+ ...patch
2355
+ });
1900
2356
  const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
1901
2357
  const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
1902
2358
  if (structural || candidate.enabled && problems.length > 0) {
1903
2359
  send(409, { error: `config cannot start: ${problems.join(" ")}` });
1904
2360
  return;
1905
2361
  }
1906
- const section = {};
1907
- for (const [key, value] of Object.entries(candidate)) {
1908
- if (value === null || value === void 0) continue;
1909
- if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) continue;
1910
- section[key] = value;
1911
- }
1912
2362
  try {
1913
- await settingsScope.replace(section);
2363
+ const ops = [...Object.entries(patch).map(([key, value]) => ({
2364
+ op: "set",
2365
+ path: [key],
2366
+ value
2367
+ })), ...clear.map((key) => ({
2368
+ op: "unset",
2369
+ path: [key]
2370
+ }))];
2371
+ if (ops.length > 0) await settings.mutate(entryId, ops);
1914
2372
  await syncGateway("config route save");
1915
- const cfg = effective();
1916
- send(200, {
1917
- config: cfg,
1918
- running: gateway !== void 0,
1919
- port: cfg.gatewayPort,
1920
- tls: tlsStatusLine(cfg),
1921
- upstreamSessionAvailable,
1922
- lastError: lastError ?? null
1923
- });
2373
+ const next = { ...snapshot() };
2374
+ if (unknown.length > 0) next["ignored"] = unknown;
2375
+ send(200, next);
1924
2376
  } catch (error) {
1925
2377
  send(409, { error: error instanceof Error ? error.message : String(error) });
1926
2378
  }
@@ -1932,16 +2384,16 @@ function apply(ctx, config) {
1932
2384
  }), "dsh-lan-gateway: config route");
1933
2385
  ctx.tools.register(lanGatewayTool({
1934
2386
  status() {
1935
- const cfg = effective();
2387
+ const cfg = desiredConfig();
1936
2388
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1937
2389
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1938
2390
  return {
1939
2391
  ok: true,
1940
- message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
2392
+ message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 && !settingsAttached ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
1941
2393
  };
1942
2394
  },
1943
2395
  async enable() {
1944
- manualOverride = true;
2396
+ await setRunIntent(true);
1945
2397
  await syncGateway("tool enable");
1946
2398
  return gateway !== void 0 ? {
1947
2399
  ok: true,
@@ -1952,7 +2404,7 @@ function apply(ctx, config) {
1952
2404
  };
1953
2405
  },
1954
2406
  async disable() {
1955
- manualOverride = false;
2407
+ await setRunIntent(false);
1956
2408
  await syncGateway("tool disable");
1957
2409
  return {
1958
2410
  ok: true,
@@ -1965,23 +2417,21 @@ function apply(ctx, config) {
1965
2417
  message: "Password must be at least 8 characters."
1966
2418
  };
1967
2419
  const setting = password !== void 0 && password.length > 0;
1968
- const previous = state;
1969
- state = setPassword(state, setting ? password : void 0);
2420
+ const hadPassword = state.password !== void 0;
2421
+ state = await setPassword(state, setting ? password : void 0);
1970
2422
  saveState(state);
1971
2423
  gateway?.setState(state);
1972
2424
  if (!setting) {
1973
- manualOverride = false;
1974
- if (gateway !== void 0) {
1975
- await stopGateway();
2425
+ await setRunIntent(false);
2426
+ return enqueue("password cleared", async () => {
2427
+ if (gateway !== void 0) await stopGateway();
1976
2428
  lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
1977
- syncGateway("password cleared");
1978
- }
1979
- return {
2429
+ }).then(() => ({
1980
2430
  ok: true,
1981
2431
  message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
1982
- };
2432
+ }));
1983
2433
  }
1984
- previous === void 0 ? syncGateway("password set") : Promise.resolve();
2434
+ if (!hadPassword) await syncGateway("password set");
1985
2435
  return {
1986
2436
  ok: true,
1987
2437
  message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
@@ -2012,32 +2462,38 @@ function apply(ctx, config) {
2012
2462
  ok: false,
2013
2463
  message: "tlsSelfSignedHosts must name at least one host (DNS name or IP)."
2014
2464
  };
2015
- try {
2016
- regenerateSelfSigned({
2017
- hosts,
2018
- days: cfg.tlsCertMaxAgeDays
2019
- });
2020
- if (gateway !== void 0) {
2021
- await stopGateway();
2022
- await startGateway(effective());
2465
+ let failure;
2466
+ await enqueue("tls regenerate", async () => {
2467
+ try {
2468
+ regenerateSelfSigned({
2469
+ hosts,
2470
+ days: cfg.tlsCertMaxAgeDays
2471
+ });
2472
+ if (gateway !== void 0) {
2473
+ await stopGateway();
2474
+ await startGateway(effective());
2475
+ }
2023
2476
  lastError = void 0;
2477
+ } catch (error) {
2478
+ failure = error instanceof Error ? error.message : String(error);
2024
2479
  }
2025
- return {
2026
- ok: true,
2027
- message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
2028
- };
2029
- } catch (error) {
2030
- return {
2031
- ok: false,
2032
- message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`
2033
- };
2034
- }
2480
+ });
2481
+ return failure === void 0 ? {
2482
+ ok: true,
2483
+ message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
2484
+ } : {
2485
+ ok: false,
2486
+ message: `Failed to regenerate TLS certificate: ${failure}`
2487
+ };
2035
2488
  }
2036
2489
  }));
2037
2490
  ctx.effect(() => {
2038
2491
  syncGateway("boot");
2039
- return stopGateway;
2492
+ return async () => {
2493
+ disposed = true;
2494
+ await enqueue("dispose", stopGateway);
2495
+ };
2040
2496
  }, "dsh-lan-gateway: listener lifecycle");
2041
2497
  }
2042
2498
  //#endregion
2043
- export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
2499
+ export { Config, apply, buildConfigPatch, configRefs, gatewayStartProblems, inject, isTrustedConfigRequest, name, readConfig, resolveSecureCookies, validateConfig };