@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5

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, 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.
@@ -73,9 +80,19 @@ function classifySource(remoteAddress, lanCidrs = DEFAULT_LAN_CIDR_STRINGS) {
73
80
  return "internet";
74
81
  }
75
82
  if (address === "::1") return "loopback";
76
- if (address.toLowerCase().startsWith("fe80:")) return "lan";
83
+ if (inIpv6LinkLocal(address)) return "lan";
77
84
  return "internet";
78
85
  }
86
+ /**
87
+ * Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
88
+ * `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
89
+ * test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
90
+ */
91
+ function inIpv6LinkLocal(address) {
92
+ const match = /^([0-9a-fA-F]{1,4}):/.exec(address);
93
+ if (match === null) return false;
94
+ return (Number.parseInt(match[1], 16) & 65472) === 65152;
95
+ }
79
96
  /** Encode a byte buffer as URL-safe base64 without padding. */
80
97
  function base64url(input) {
81
98
  return input.toString("base64url");
@@ -88,24 +105,29 @@ function base64url(input) {
88
105
  * cookie whose epoch no longer matches the live state is rejected by
89
106
  * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
90
107
  * do not participate in revocation.
108
+ * @param sid - optional per-session id (see {@link SessionClaims.sid}).
91
109
  * @returns a `payload.signature` string suitable for the cookie value.
92
110
  */
93
- function signCookie(secret, expiresMs, epoch = 0) {
94
- const payload = base64url(Buffer.from(JSON.stringify({
111
+ function signCookie(secret, expiresMs, epoch = 0, sid) {
112
+ const claims = sid === void 0 ? {
95
113
  exp: expiresMs,
96
114
  epoch
97
- })));
115
+ } : {
116
+ exp: expiresMs,
117
+ epoch,
118
+ sid
119
+ };
120
+ const payload = base64url(Buffer.from(JSON.stringify(claims)));
98
121
  return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
99
122
  }
100
123
  /**
101
- * Whether a cookie value is a valid, unexpired session signed with `secret`
102
- * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
103
- * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
124
+ * Verify a cookie's signature, expiry and epoch.
125
+ * @returns the claims it carries, or undefined when it is not a valid session.
104
126
  */
105
- function verifyCookie(secret, value, now, epoch = 0) {
106
- if (value === void 0) return false;
127
+ function verifySession(secret, value, now, epoch = 0) {
128
+ if (value === void 0) return void 0;
107
129
  const dot = value.indexOf(".");
108
- if (dot === -1) return false;
130
+ if (dot === -1) return void 0;
109
131
  const payload = value.slice(0, dot);
110
132
  const sig = value.slice(dot + 1);
111
133
  const expected = createHmac("sha256", secret).update(payload).digest();
@@ -113,16 +135,22 @@ function verifyCookie(secret, value, now, epoch = 0) {
113
135
  try {
114
136
  actual = Buffer.from(sig, "base64url");
115
137
  } catch {
116
- return false;
138
+ return;
117
139
  }
118
- if (expected.length !== actual.length) return false;
119
- if (!timingSafeEqual(expected, actual)) return false;
140
+ if (expected.length !== actual.length) return void 0;
141
+ if (!timingSafeEqual(expected, actual)) return void 0;
120
142
  try {
121
143
  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
122
- if (typeof decoded.exp !== "number" || decoded.exp <= now) return false;
123
- return (typeof decoded.epoch === "number" ? decoded.epoch : 0) === epoch;
144
+ if (typeof decoded.exp !== "number" || decoded.exp <= now) return void 0;
145
+ const cookieEpoch = typeof decoded.epoch === "number" ? decoded.epoch : 0;
146
+ if (cookieEpoch !== epoch) return void 0;
147
+ return {
148
+ exp: decoded.exp,
149
+ epoch: cookieEpoch,
150
+ ...typeof decoded.sid === "string" ? { sid: decoded.sid } : {}
151
+ };
124
152
  } catch {
125
- return false;
153
+ return;
126
154
  }
127
155
  }
128
156
  /**
@@ -143,10 +171,18 @@ function originMatchesHost(origin, host) {
143
171
  }
144
172
  }
145
173
  /** A token bucket limiter keyed by source address. */
146
- var RateLimiter = class {
174
+ var RateLimiter = class RateLimiter {
147
175
  maxTokens;
148
176
  windowMs;
177
+ /**
178
+ * Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
179
+ * `prune` runs, and a spray from many distinct addresses inside one window
180
+ * outruns it, so the map also sheds its soonest-expiring entries past this.
181
+ */
182
+ static MAX_BUCKETS = 1e4;
149
183
  buckets = /* @__PURE__ */ new Map();
184
+ /** Epoch millis at which the next opportunistic sweep is due. */
185
+ nextPruneAt = 0;
150
186
  constructor(maxTokens, windowMs) {
151
187
  this.maxTokens = maxTokens;
152
188
  this.windowMs = windowMs;
@@ -158,26 +194,114 @@ var RateLimiter = class {
158
194
  */
159
195
  allow(key) {
160
196
  const now = Date.now();
161
- const bucket = this.buckets.get(key);
162
- if (bucket === void 0 || bucket.resetAt <= now) {
163
- this.buckets.set(key, {
164
- tokens: this.maxTokens - 1,
165
- resetAt: now + this.windowMs
166
- });
167
- return true;
197
+ if (now >= this.nextPruneAt) {
198
+ this.prune(now);
199
+ this.nextPruneAt = now + this.windowMs;
168
200
  }
169
- if (bucket.tokens > 0) {
170
- bucket.tokens -= 1;
201
+ const existing = this.buckets.get(key);
202
+ if (existing !== void 0 && existing.resetAt > now) {
203
+ if (existing.tokens <= 0) return false;
204
+ existing.tokens -= 1;
171
205
  return true;
172
206
  }
173
- return false;
207
+ if (existing === void 0 && this.buckets.size >= RateLimiter.MAX_BUCKETS) this.evictSoonestToExpire();
208
+ this.buckets.set(key, {
209
+ tokens: this.maxTokens - 1,
210
+ resetAt: now + this.windowMs
211
+ });
212
+ return true;
174
213
  }
175
- /** Drop expired buckets to bound memory. */
214
+ /** Drop expired buckets to bound memory. Called from {@link allow}. */
176
215
  prune(now = Date.now()) {
177
216
  for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
178
217
  }
218
+ /** Trim back to 90% of the ceiling, oldest expiry first. */
219
+ evictSoonestToExpire() {
220
+ const target = Math.floor(RateLimiter.MAX_BUCKETS * .9);
221
+ const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
222
+ for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) this.buckets.delete(key);
223
+ }
179
224
  };
180
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
181
305
  //#region src/login.ts
182
306
  /** Path the gateway owns and never forwards. */
183
307
  const LOGIN_PATH = "/__login";
@@ -268,6 +392,450 @@ function readBody(req, maxBytes, res) {
268
392
  });
269
393
  }
270
394
  //#endregion
395
+ //#region src/upstream-session.ts
396
+ /**
397
+ * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
398
+ *
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
420
+ */
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();
427
+ }
428
+ /**
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.
432
+ */
433
+ function pathOf$1(url) {
434
+ try {
435
+ return new URL(url).pathname;
436
+ } catch {
437
+ return "<unparseable>";
438
+ }
439
+ }
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();
444
+ }
445
+ /**
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.
450
+ */
451
+ function isUpstreamCookiePair(pair) {
452
+ return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX);
453
+ }
454
+ /**
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.
460
+ *
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.
466
+ */
467
+ function isUpstreamSessionCookie(setCookie) {
468
+ const name = cookieNameOf(setCookie);
469
+ return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
470
+ }
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]);
475
+ }
476
+ /**
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
646
+ * path by string prefix, stays in the relay, and lands on the plugin's own
647
+ * config route once Host has been rewritten to loopback. Forwarding still
648
+ * relays the raw target: dsh applies the same normalization itself.
649
+ *
650
+ * WHATWG parsing does not drop a trailing slash, and neither does dsh's
651
+ * router, so `/__login/` is not the login page to either of them. The gateway
652
+ * recognizes its own surfaces there anyway: `/__logout/` must still sign out,
653
+ * and `/lan-gateway/config/` must be refused rather than relayed into dsh's
654
+ * single-page fallback. Blocking a trailing-slash spelling of an owned prefix
655
+ * errs toward refusing, which costs nothing — no upstream route lives under it.
656
+ */
657
+ function pathOf(url) {
658
+ try {
659
+ return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
660
+ } catch {
661
+ return url;
662
+ }
663
+ }
664
+ /**
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.
674
+ */
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);
691
+ }
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;
769
+ }
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;
775
+ }
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;
780
+ }
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
271
839
  //#region src/state.ts
272
840
  /**
273
841
  * Persistent runtime state for the LAN gateway: the cookie-signing secret and
@@ -282,13 +850,27 @@ function stateDir(home = homedir()) {
282
850
  return join(home, ".dsh", "lan-gateway");
283
851
  }
284
852
  const STATE_FILENAME = "state.json";
285
- /** Whether a password is present and passes scrypt verification. */
286
- function verifyPassword(state, password) {
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) {
287
869
  if (state.password === void 0) return false;
288
870
  const { hash, salt } = state.password;
289
871
  try {
290
872
  const expected = Buffer.from(hash, "hex");
291
- const actual = scryptSync(password, Buffer.from(salt, "hex"), expected.length);
873
+ const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
292
874
  return expected.length === actual.length && timingSafeEqual(expected, actual);
293
875
  } catch {
294
876
  return false;
@@ -298,18 +880,20 @@ function verifyPassword(state, password) {
298
880
  * Set (or clear) the password, re-salted on every write. Both operations bump
299
881
  * the session epoch so every cookie issued under the previous epoch dies — a
300
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.
301
888
  */
302
- function setPassword(state, password) {
889
+ async function setPassword(state, password) {
303
890
  const base = {
304
- ...state,
891
+ cookieSecret: state.cookieSecret,
305
892
  sessionEpoch: state.sessionEpoch + 1
306
893
  };
307
- if (password === void 0) return {
308
- cookieSecret: base.cookieSecret,
309
- sessionEpoch: base.sessionEpoch
310
- };
894
+ if (password === void 0) return base;
311
895
  const salt = randomBytes(16);
312
- const hash = scryptSync(password, salt, 64);
896
+ const hash = await deriveKey(password, salt, 64);
313
897
  return {
314
898
  ...base,
315
899
  password: {
@@ -318,12 +902,46 @@ function setPassword(state, password) {
318
902
  }
319
903
  };
320
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
+ }
321
927
  function defaultState() {
322
928
  return {
323
929
  cookieSecret: randomBytes(32).toString("base64"),
324
930
  sessionEpoch: 0
325
931
  };
326
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
+ }
327
945
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
328
946
  function loadState(home = homedir()) {
329
947
  const dir = stateDir(home);
@@ -337,6 +955,8 @@ function loadState(home = homedir()) {
337
955
  sessionEpoch
338
956
  };
339
957
  if (parsed.password !== void 0) base.password = parsed.password;
958
+ const revoked = parseRevokedSessions(parsed.revokedSessions);
959
+ if (revoked !== void 0) base.revokedSessions = revoked;
340
960
  return base;
341
961
  }
342
962
  return defaultState();
@@ -359,8 +979,9 @@ function saveState(state, home = homedir()) {
359
979
  //#endregion
360
980
  //#region src/gateway.ts
361
981
  /**
362
- * The reverse-proxy gateway: a `node:http(s)` server bound to `0.0.0.0` that
363
- * forwards every request to the loopback dsh web server.
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.
364
985
  *
365
986
  * Security model (post-QVD / session-base):
366
987
  * - Source is classified from `socket.remoteAddress` only (never
@@ -374,38 +995,34 @@ function saveState(state, home = homedir()) {
374
995
  * or its login/logout paths; those are handled locally or refused.
375
996
  * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
376
997
  * blinded — so the gateway runs its own origin check on every relayed
377
- * request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
378
- * `sec-fetch-site: cross-site`, reject any Origin that does not name the
379
- * gateway authority the browser actually used, and require an Origin on
380
- * state-changing methods and on every WebSocket upgrade.
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.
381
1001
  * - Against a session-capable dsh base the Host/Origin rewrite alone would
382
1002
  * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
383
1003
  * authority-bound session cookie). The gateway therefore relays one shared
384
1004
  * upstream session acquired through the launch-token exchange and replays it
385
1005
  * on every forwarded request. See `upstream-session.ts`.
386
- * - Sessions carry a revocation epoch: a password change or secret rotation
387
- * bumps the epoch, every previously issued cookie dies, and established
388
- * WebSockets are torn down so the client re-authenticates.
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.
389
1010
  *
390
1011
  * @module @riceawa/dsh-lan-gateway/gateway
391
1012
  */
392
1013
  const DEFAULT_BODY_LIMIT_BYTES = 65536;
393
1014
  const LOGIN_ATTEMPTS_LIMIT = 5;
394
1015
  const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
395
- /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
396
- const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
397
- "GET",
398
- "HEAD",
399
- "OPTIONS"
400
- ]);
401
- /** Prefixes the gateway owns and must never relay to dsh. */
402
- function isOwnedPath(pathname) {
403
- return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
404
- }
405
- /** The pathname of a request URL (query string stripped, not decoded). */
406
- function pathOf(url) {
407
- const query = url.indexOf("?");
408
- return query === -1 ? url : url.slice(0, query);
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");
409
1026
  }
410
1027
  /**
411
1028
  * The running gateway: owns the HTTP server and the auth state needed per
@@ -418,8 +1035,26 @@ var LanGateway = class {
418
1035
  loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
419
1036
  state;
420
1037
  disposed = false;
421
- /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
422
- activeDuplexes = /* @__PURE__ */ new Set();
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;
423
1058
  constructor(config, state) {
424
1059
  this.config = config;
425
1060
  this.state = state;
@@ -436,10 +1071,20 @@ var LanGateway = class {
436
1071
  }
437
1072
  /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
438
1073
  setState(state) {
439
- if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
1074
+ if (state.sessionEpoch !== this.state.sessionEpoch) {
1075
+ this.gate += 1;
1076
+ this.destroyAllSockets();
1077
+ }
440
1078
  this.state = state;
441
1079
  }
442
- /** Start listening; rejects if the port is already in use. */
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
+ */
443
1088
  async listen() {
444
1089
  return new Promise((resolve, reject) => {
445
1090
  const onError = (err) => {
@@ -452,59 +1097,87 @@ var LanGateway = class {
452
1097
  };
453
1098
  this.server.once("error", onError);
454
1099
  this.server.once("listening", onListening);
455
- this.server.listen(this.config.gatewayPort, "0.0.0.0");
1100
+ this.server.listen(this.config.gatewayPort);
456
1101
  });
457
1102
  }
458
- /** Close the server, drop upgraded sockets, and stop accepting connections. */
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. */
459
1110
  async close() {
460
1111
  if (this.disposed) return;
461
1112
  this.disposed = true;
462
- this.destroyActiveDuplexes();
1113
+ this.gate += 1;
1114
+ this.destroyAllSockets();
463
1115
  return new Promise((resolve) => {
464
1116
  this.server.close(() => resolve());
465
1117
  this.server.closeAllConnections();
466
1118
  });
467
1119
  }
468
- destroyActiveDuplexes() {
469
- for (const socket of this.activeDuplexes) socket.destroy();
470
- this.activeDuplexes.clear();
1120
+ destroyAllSockets() {
1121
+ for (const socket of this.sockets.keys()) socket.destroy();
1122
+ this.sockets.clear();
1123
+ }
1124
+ /** Close the sockets one session opened, so signing out ends its live streams too. */
1125
+ destroySocketsFor(sid) {
1126
+ for (const [socket, tracked] of this.sockets) {
1127
+ if (tracked.sid !== sid) continue;
1128
+ this.sockets.delete(socket);
1129
+ socket.destroy();
1130
+ }
471
1131
  }
472
- trackDuplex(socket) {
473
- this.activeDuplexes.add(socket);
1132
+ /** Track a socket from admission to close. */
1133
+ trackSocket(socket, sid) {
1134
+ this.sockets.set(socket, {
1135
+ sid,
1136
+ gate: this.gate
1137
+ });
474
1138
  socket.on("close", () => {
475
- this.activeDuplexes.delete(socket);
1139
+ this.sockets.delete(socket);
476
1140
  });
477
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
+ }
478
1148
  sourceOf(req) {
479
1149
  return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
480
1150
  }
481
- /** Parse the session cookie out of a Cookie header. */
482
- sessionCookie(req) {
483
- const header = req.headers.cookie;
484
- if (typeof header !== "string") return void 0;
485
- for (const part of header.split(";")) {
486
- const trimmed = part.trim();
487
- if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
488
- }
1151
+ /**
1152
+ * The session a request carries, or undefined when it presents none, presents
1153
+ * one that no longer verifies under the current epoch, or presents one whose
1154
+ * id has been signed out.
1155
+ */
1156
+ session(req) {
1157
+ const cookie = sessionCookie(req.headers, this.config.cookieName);
1158
+ if (cookie === void 0) return void 0;
1159
+ const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
1160
+ if (claims === void 0) return void 0;
1161
+ return isSessionRevoked(this.state, claims.sid) ? void 0 : claims;
489
1162
  }
490
1163
  /** Whether a request carries a session valid under the current epoch. */
491
1164
  authorized(req) {
492
- const cookie = this.sessionCookie(req);
493
- return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
494
- }
495
- /** Whether this source must present a gateway session (default: everyone). */
496
- requiresLogin(source) {
497
- return !(this.config.lanPasswordless && source !== "internet");
1165
+ return this.session(req) !== void 0;
498
1166
  }
499
- 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) {
500
1173
  res.writeHead(302, {
501
- location: `${LOGIN_PATH}${limited ? "?limited=1" : ""}`,
1174
+ location: LOGIN_PATH,
502
1175
  ...this.securityHeaders()
503
1176
  });
504
1177
  res.end();
505
1178
  }
506
- serveLoginError(res, message) {
507
- const opts = { error: message };
1179
+ serveLoginError(res, message, limited = false) {
1180
+ const opts = limited ? { limited: true } : { error: message };
508
1181
  res.writeHead(401, {
509
1182
  "content-type": "text/html; charset=utf-8",
510
1183
  "cache-control": "no-store",
@@ -516,27 +1189,11 @@ var LanGateway = class {
516
1189
  securityHeaders() {
517
1190
  return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
518
1191
  }
519
- /**
520
- * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
521
- * and applied before any Host/Origin rewriting. Browsers attach Origin to
522
- * state-changing requests and to every WebSocket handshake; reads without an
523
- * Origin (navigations, non-browser clients holding a session) stay allowed.
524
- */
525
- sameSiteAllowed(req, upgrade) {
526
- const headers = req.headers;
527
- if (headers["sec-fetch-site"] === "cross-site") return false;
528
- const origin = headers.origin;
529
- const host = headers.host;
530
- if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
531
- if (upgrade) return origin !== void 0;
532
- if (!READ_ONLY_METHODS$1.has(req.method ?? "GET")) return origin !== void 0;
533
- return true;
534
- }
535
1192
  sessionSetCookie(value, maxAgeSeconds) {
536
1193
  const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
537
1194
  return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
538
1195
  }
539
- /** 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. */
540
1197
  async handleHttp(req, res) {
541
1198
  const url = req.url ?? "/";
542
1199
  const pathname = pathOf(url);
@@ -554,11 +1211,11 @@ var LanGateway = class {
554
1211
  res.end("forbidden");
555
1212
  return;
556
1213
  }
557
- if (this.requiresLogin(source) && !this.authorized(req)) {
558
- this.serveUnauthorized(res, false);
1214
+ if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
1215
+ this.serveUnauthorized(res);
559
1216
  return;
560
1217
  }
561
- if (!this.sameSiteAllowed(req, false)) {
1218
+ if (!sameSiteAllowed(req, false)) {
562
1219
  res.writeHead(403, this.securityHeaders());
563
1220
  res.end("forbidden");
564
1221
  return;
@@ -567,7 +1224,6 @@ var LanGateway = class {
567
1224
  }
568
1225
  /** Handle the login GET form / POST submission. */
569
1226
  handleLogin(req, res) {
570
- req.url?.includes("limited=1");
571
1227
  if (req.method === "GET" || req.method === "HEAD") {
572
1228
  serveLoginGet(res, this.securityHeaders());
573
1229
  return;
@@ -577,12 +1233,17 @@ var LanGateway = class {
577
1233
  res.end();
578
1234
  return;
579
1235
  }
1236
+ if (!loginOriginAllowed(req.headers)) {
1237
+ res.writeHead(403, this.securityHeaders());
1238
+ res.end("forbidden");
1239
+ return;
1240
+ }
580
1241
  const key = req.socket.remoteAddress ?? "unknown";
581
1242
  if (!this.loginLimiter.allow(key)) {
582
- this.serveLoginError(res, "Too many attempts — please wait a minute.");
1243
+ this.serveLoginError(res, "Too many attempts — please wait a minute.", true);
583
1244
  return;
584
1245
  }
585
- readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
1246
+ readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
586
1247
  if (body === void 0) return;
587
1248
  let password;
588
1249
  try {
@@ -590,33 +1251,59 @@ var LanGateway = class {
590
1251
  } catch {
591
1252
  password = void 0;
592
1253
  }
593
- if (password === void 0 || !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) {
594
1261
  this.serveLoginError(res, "Incorrect password.");
595
1262
  return;
596
1263
  }
597
1264
  const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
598
1265
  const expiresMs = Date.now() + maxAgeSeconds * 1e3;
599
- const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
1266
+ const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch, newSessionId());
600
1267
  res.writeHead(302, {
601
1268
  location: "/",
602
1269
  ...this.securityHeaders(),
603
1270
  "set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
604
1271
  });
605
1272
  res.end();
1273
+ }).catch(() => {
1274
+ if (!res.headersSent) {
1275
+ res.writeHead(500, this.securityHeaders());
1276
+ res.end("login failed");
1277
+ }
606
1278
  });
607
1279
  }
608
- /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
1280
+ /**
1281
+ * POST /__logout: revoke this session and clear the cookie.
1282
+ *
1283
+ * The session is stateless, so clearing the cookie only stops the browser
1284
+ * that ran the sign-out; a copy of the same value held anywhere else would
1285
+ * keep working until it expired. Revoking the id in the cookie retires that
1286
+ * one session for good, and leaves the account's other sessions — other
1287
+ * devices, other browsers — alone. Bumping the session epoch here would be
1288
+ * the blunter instrument: it signs out every session there is.
1289
+ */
609
1290
  handleLogout(req, res) {
610
1291
  if (req.method !== "POST") {
611
1292
  res.writeHead(405, { allow: "POST" });
612
1293
  res.end();
613
1294
  return;
614
1295
  }
615
- if (!this.sameSiteAllowed(req, false)) {
1296
+ if (!sameSiteAllowed(req, false)) {
616
1297
  res.writeHead(403, this.securityHeaders());
617
1298
  res.end("forbidden");
618
1299
  return;
619
1300
  }
1301
+ const claims = this.session(req);
1302
+ if (claims?.sid !== void 0) {
1303
+ this.state = revokeSession(this.state, claims.sid, claims.exp);
1304
+ this.config.onStateChange?.(this.state);
1305
+ this.destroySocketsFor(claims.sid);
1306
+ }
620
1307
  res.writeHead(302, {
621
1308
  location: "/",
622
1309
  ...this.securityHeaders(),
@@ -624,34 +1311,20 @@ var LanGateway = class {
624
1311
  });
625
1312
  res.end();
626
1313
  }
627
- /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
628
- upstreamHeaders(req, keepUpgrade) {
629
- const headers = { ...req.headers };
630
- headers.host = `127.0.0.1:${this.config.dshPort}`;
631
- if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
632
- delete headers["proxy-connection"];
633
- if (!keepUpgrade) {
634
- delete headers.connection;
635
- delete headers.upgrade;
636
- }
637
- return headers;
638
- }
639
- /** Attach the shared upstream session cookie to the outbound headers, if any. */
640
- attachUpstreamSession(headers) {
641
- const session = this.config.upstreamSession;
642
- if (session === void 0) return false;
643
- const cookie = session.peek();
644
- if (cookie === void 0) return false;
645
- const existing = headers.cookie;
646
- headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
647
- return true;
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();
648
1317
  }
649
1318
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
650
1319
  async relayHttp(req, res, url) {
651
1320
  const session = this.config.upstreamSession;
652
- if (session !== void 0) await session.cookie();
653
- const headers = this.upstreamHeaders(req, false);
654
- 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;
655
1328
  const proxyReq = http.request({
656
1329
  host: "127.0.0.1",
657
1330
  port: this.config.dshPort,
@@ -660,7 +1333,7 @@ var LanGateway = class {
660
1333
  headers
661
1334
  }, (proxyRes) => {
662
1335
  if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
663
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
1336
+ res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers));
664
1337
  proxyRes.pipe(res);
665
1338
  });
666
1339
  proxyReq.on("error", () => {
@@ -682,18 +1355,30 @@ var LanGateway = class {
682
1355
  refuse(403);
683
1356
  return;
684
1357
  }
685
- if (this.requiresLogin(source) && !this.authorized(req)) {
1358
+ const claims = this.session(req);
1359
+ if (requiresLogin(source, this.config.lanPasswordless) && claims === void 0) {
686
1360
  refuse(401);
687
1361
  return;
688
1362
  }
689
- if (!this.sameSiteAllowed(req, true)) {
1363
+ if (!sameSiteAllowed(req, true)) {
690
1364
  refuse(403);
691
1365
  return;
692
1366
  }
693
- const session = this.config.upstreamSession;
694
- if (session !== void 0) await session.cookie();
695
- const headers = this.upstreamHeaders(req, true);
696
- 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
+ });
697
1382
  const proxyReq = http.request({
698
1383
  host: "127.0.0.1",
699
1384
  port: this.config.dshPort,
@@ -701,10 +1386,22 @@ var LanGateway = class {
701
1386
  path: url,
702
1387
  headers
703
1388
  });
1389
+ const timer = setTimeout(() => {
1390
+ proxyReq.destroy();
1391
+ retire();
1392
+ }, UPGRADE_HANDSHAKE_TIMEOUT_MS);
1393
+ const settle = () => {
1394
+ clearTimeout(timer);
1395
+ };
704
1396
  proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
705
- this.trackDuplex(socket);
1397
+ settle();
1398
+ if (!this.stillAdmitted(socket)) {
1399
+ proxySocket.destroy();
1400
+ retire();
1401
+ return;
1402
+ }
706
1403
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
707
- 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("");
708
1405
  socket.write(`${statusLine}${headerLines}\r\n`);
709
1406
  if (head !== void 0 && head.length > 0) proxySocket.write(head);
710
1407
  proxySocket.pipe(socket).pipe(proxySocket);
@@ -712,7 +1409,22 @@ var LanGateway = class {
712
1409
  socket.on("error", () => proxySocket.destroy());
713
1410
  proxySocket.on("error", () => socket.destroy());
714
1411
  });
715
- 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
+ });
716
1428
  proxyReq.end();
717
1429
  }
718
1430
  };
@@ -982,6 +1694,29 @@ function loadOrCreateSelfSigned(opts, home = homedir()) {
982
1694
  };
983
1695
  }
984
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
+ };
1718
+ }
1719
+ /**
985
1720
  * Force-regenerate the self-signed certificate (new key + cert), replacing
986
1721
  * the persisted files. Used by `lan_gateway tls-regenerate`.
987
1722
  */
@@ -993,6 +1728,31 @@ function regenerateSelfSigned(opts, home = homedir()) {
993
1728
  privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
994
1729
  return material;
995
1730
  }
1731
+ /**
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.
1742
+ */
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
+ }
996
1756
  function generateSelfSignedMaterial(opts) {
997
1757
  const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
998
1758
  if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
@@ -1006,295 +1766,130 @@ function generateSelfSignedMaterial(opts) {
1006
1766
  key: keyPem
1007
1767
  };
1008
1768
  }
1009
- /**
1010
- * Load a user-supplied certificate + key pair from PEM files.
1011
- * @param certPath - path to the PEM certificate (or chain).
1012
- * @param keyPath - path to the PEM private key.
1013
- * @returns the material.
1014
- */
1015
- function loadCustomCert(certPath, keyPath) {
1016
- if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
1017
- if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
1018
- let cert;
1019
- try {
1020
- cert = readFileSync(certPath, "utf8");
1021
- } catch (error) {
1022
- throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
1023
- }
1024
- let key;
1025
- try {
1026
- key = readFileSync(keyPath, "utf8");
1027
- } catch (error) {
1028
- throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
1029
- }
1030
- try {
1031
- new X509Certificate(cert);
1032
- } catch {
1033
- throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
1034
- }
1035
- return {
1036
- cert,
1037
- key
1038
- };
1039
- }
1040
- /** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
1041
- function parseSelfSignedHosts(text) {
1042
- return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
1043
- }
1044
- /** Describe a PEM certificate (throws on malformed input). */
1045
- function describeCert(certPem) {
1046
- const cert = new X509Certificate(certPem);
1047
- return {
1048
- subject: cert.subject,
1049
- issuer: cert.issuer,
1050
- validFrom: cert.validFrom,
1051
- validTo: cert.validTo,
1052
- fingerprint256: cert.fingerprint256,
1053
- ...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
1054
- };
1055
- }
1056
- function errorMessage(error) {
1057
- return error instanceof Error ? error.message : String(error);
1058
- }
1059
- //#endregion
1060
- //#region src/tool.ts
1061
- const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
1062
- /**
1063
- * Build the `lan_gateway` tool over a controller interface implemented by the
1064
- * plugin entry. Split so the tool stays testable and the plugin decides how
1065
- * the controller mutates state.
1066
- */
1067
- function lanGatewayTool(control) {
1068
- return defineTool({
1069
- name: LAN_GATEWAY_TOOL_NAME,
1070
- 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.",
1071
- parameters: {
1072
- command: {
1073
- type: "string",
1074
- enum: [
1075
- "status",
1076
- "enable",
1077
- "disable",
1078
- "set-password",
1079
- "rotate-secret",
1080
- "tls-regenerate"
1081
- ],
1082
- 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."
1083
- },
1084
- password: {
1085
- type: "string",
1086
- description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
1087
- }
1088
- },
1089
- output: {
1090
- schema: {
1091
- type: "object",
1092
- additionalProperties: false,
1093
- properties: {
1094
- ok: {
1095
- type: "boolean",
1096
- required: true
1097
- },
1098
- message: {
1099
- type: "string",
1100
- required: true
1101
- }
1102
- }
1103
- },
1104
- render: (_args, value) => [{
1105
- type: "text",
1106
- text: value.message
1107
- }]
1108
- },
1109
- async execute(args, _exec) {
1110
- switch (args.command ?? "status") {
1111
- case "status": return control.status();
1112
- case "enable": return control.enable();
1113
- case "disable": return control.disable();
1114
- case "set-password": {
1115
- const password = args.password;
1116
- return control.setPassword(typeof password === "string" ? password : void 0);
1117
- }
1118
- case "rotate-secret": return control.rotateSecret();
1119
- case "tls-regenerate": return control.regenerateTls();
1120
- }
1121
- }
1122
- });
1123
- }
1124
- //#endregion
1125
- //#region src/upstream-session.ts
1126
- /**
1127
- * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
1128
- *
1129
- * When dsh added browser-session authentication it stopped trusting a loopback
1130
- * Host header alone: every `/api` request (and the remote WebSocket mux) must
1131
- * now present a signed cookie bound to the authority it names
1132
- * (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
1133
- * process launch token. A reverse proxy that rewrites Host to loopback — which
1134
- * is what this gateway does — therefore gets a 401 no matter how the Host is
1135
- * forged. The gateway cannot mint that cookie itself (the signing secret lives
1136
- * in dsh's credential provider), so it does exactly what a browser does: on the
1137
- * loopback transport it visits the launch-token URL, keeps the Set-Cookie it
1138
- * earns, and replays that one shared session on every request it forwards.
1139
- *
1140
- * Semantics match the pre-existing "single password = single operator" model:
1141
- * whoever passes the gateway's own login rides this one upstream session. It is
1142
- * not multi-user authorization, and upstream (which holds the secret) remains
1143
- * the actual authority over what the session may do.
1144
- *
1145
- * The relay is a no-op on a base without browser sessions: acquisition fails
1146
- * and `cookie()` returns undefined, so the gateway simply forwards without a
1147
- * session cookie exactly as it did against an older dsh.
1148
- *
1149
- * @module @riceawa/dsh-lan-gateway/upstream-session
1150
- */
1151
- /** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
1152
- const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
1153
- /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
1154
- function nameValueOnly(setCookie) {
1155
- const semi = setCookie.indexOf(";");
1156
- return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
1157
- }
1158
- /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
1159
- function cookieNameOf(setCookie) {
1160
- const eq = setCookie.indexOf("=");
1161
- return eq === -1 ? "" : setCookie.slice(0, eq).trim();
1162
- }
1163
- /**
1164
- * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
1165
- * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
1166
- * is followed by the authority hash, never by `=` itself, so the test is a
1167
- * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
1168
- * and silently relays every request anonymously.
1169
- */
1170
- function isUpstreamSessionCookie(setCookie) {
1171
- const name = cookieNameOf(setCookie);
1172
- return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
1173
- }
1174
- /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
1175
- function maxAgeSeconds(setCookie) {
1176
- const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
1177
- return match === null ? void 0 : Number(match[1]);
1178
- }
1179
- /**
1180
- * Perform the token exchange over loopback: GET the launch-token URL with the
1181
- * upstream authority as Host, read the Set-Cookie the index route mints, and
1182
- * return its `name=value` plus expiry (or undefined when the exchange failed
1183
- * or no session cookie came back — e.g. an older base without browser
1184
- * sessions).
1185
- */
1186
- function exchange(url, authority, port, log) {
1187
- return new Promise((resolve) => {
1188
- let target;
1189
- try {
1190
- target = new URL(url);
1191
- } catch {
1192
- log(`exchange: unparseable authenticatedUrl ${url}`);
1193
- resolve(void 0);
1194
- return;
1195
- }
1196
- const request = http.request({
1197
- host: "127.0.0.1",
1198
- port,
1199
- method: "GET",
1200
- path: `${target.pathname}${target.search}`,
1201
- headers: {
1202
- host: authority,
1203
- accept: "text/html"
1204
- }
1205
- }, (response) => {
1206
- const setCookies = response.headers["set-cookie"];
1207
- response.resume();
1208
- if (setCookies === void 0) {
1209
- log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
1210
- resolve(void 0);
1211
- return;
1212
- }
1213
- const all = Array.isArray(setCookies) ? setCookies : [setCookies];
1214
- const raw = all.find(isUpstreamSessionCookie);
1215
- if (raw === void 0) {
1216
- const names = all.map(cookieNameOf).filter((name) => name !== "");
1217
- log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
1218
- resolve(void 0);
1219
- return;
1220
- }
1221
- const header = nameValueOnly(raw);
1222
- const maxAge = maxAgeSeconds(raw);
1223
- log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
1224
- resolve({
1225
- header,
1226
- expiresAt: Date.now() + (maxAge ?? 0) * 1e3
1227
- });
1228
- });
1229
- request.on("error", (error) => {
1230
- log(`exchange error: ${error.message}`);
1231
- resolve(void 0);
1232
- });
1233
- request.setTimeout(5e3, () => {
1234
- log("exchange timeout (5s)");
1235
- request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
1236
- });
1237
- request.end();
1238
- });
1239
- }
1240
- /**
1241
- * A cached {@link UpstreamSession} acquired through the launch-token exchange.
1242
- * Acquisition runs at most once concurrently and the result is cached until it
1243
- * nears expiry or {@link invalidate} is called.
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.
1244
1774
  */
1245
- var UpstreamSessionRelay = class {
1246
- port;
1247
- authority;
1248
- authenticatedUrl;
1249
- log;
1250
- held;
1251
- inflight;
1252
- constructor(options) {
1253
- this.port = options.port;
1254
- this.authority = options.authority ?? `127.0.0.1:${options.port}`;
1255
- this.authenticatedUrl = options.authenticatedUrl;
1256
- this.log = options.log ?? (() => {});
1257
- }
1258
- /** Whether the held session is still comfortably inside its lifetime. */
1259
- fresh() {
1260
- const held = this.held;
1261
- if (held === void 0) return false;
1262
- return Date.now() < held.expiresAt - 6e4;
1263
- }
1264
- peek() {
1265
- return this.held?.header;
1266
- }
1267
- invalidate() {
1268
- if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
1269
- this.held = void 0;
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)}`);
1270
1783
  }
1271
- async cookie() {
1272
- if (this.fresh()) return this.held?.header;
1273
- 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)}`);
1274
1789
  }
1275
- acquire() {
1276
- if (this.inflight !== void 0) return this.inflight;
1277
- const pending = this.doExchange().finally(() => {
1278
- this.inflight = void 0;
1279
- });
1280
- this.inflight = pending;
1281
- return pending;
1790
+ try {
1791
+ new X509Certificate(cert);
1792
+ } catch {
1793
+ throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
1282
1794
  }
1283
- async doExchange() {
1284
- const url = this.authenticatedUrl();
1285
- if (url === void 0) {
1286
- this.log("authenticatedUrl() returned undefined; keeping current session");
1287
- 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
+ }
1288
1890
  }
1289
- this.log(`acquiring session from ${url}`);
1290
- const result = await exchange(url, this.authority, this.port, this.log);
1291
- if (result !== void 0) {
1292
- this.held = result;
1293
- this.log("session acquired and cached");
1294
- } else this.log("exchange failed; keeping current session");
1295
- return this.held?.header;
1296
- }
1297
- };
1891
+ });
1892
+ }
1298
1893
  //#endregion
1299
1894
  //#region src/index.ts
1300
1895
  /** Stable Cordis plugin name. */
@@ -1308,13 +1903,6 @@ const inject = ["webServer", "tools"];
1308
1903
  * shape works against both that release line and the older branded one.
1309
1904
  */
1310
1905
  const NS = "lan-gateway";
1311
- /** Optional config keys: an empty submitted value clears them back to the composition layer. */
1312
- const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
1313
- "dshTargetPort",
1314
- "tlsCertPath",
1315
- "tlsKeyPath",
1316
- "trustedTerminator"
1317
- ]);
1318
1906
  /** Schemastery configuration validated by the Loader. */
1319
1907
  const Config = z.object({
1320
1908
  enabled: z.boolean().default(false),
@@ -1365,14 +1953,16 @@ function resolveSecureCookies(cfg) {
1365
1953
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
1366
1954
  function resolveTls(cfg) {
1367
1955
  if (!cfg.tlsEnabled) return void 0;
1368
- if (cfg.tlsMode === "custom") return loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? "");
1956
+ if (cfg.tlsMode === "custom") return {
1957
+ material: loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? ""),
1958
+ renewed: false
1959
+ };
1369
1960
  const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
1370
1961
  if (hosts.length === 0) throw new Error("tlsSelfSignedHosts must name at least one host (DNS name or IP)");
1371
- const { material } = loadOrCreateSelfSigned({
1962
+ return loadOrRenewSelfSigned({
1372
1963
  hosts,
1373
1964
  days: cfg.tlsCertMaxAgeDays
1374
1965
  });
1375
- return material;
1376
1966
  }
1377
1967
  /**
1378
1968
  * Config fields that require a listener restart when they change, plus whether
@@ -1402,32 +1992,26 @@ function listenerKey(cfg, relayAvailable) {
1402
1992
  relayAvailable
1403
1993
  ]);
1404
1994
  }
1405
- /** One-line TLS description for status output. */
1995
+ /**
1996
+ * One-line TLS description for status output.
1997
+ *
1998
+ * Never generates: this is the read path behind `GET /lan-gateway/config` and
1999
+ * `lan_gateway status`, and a status query that mints an RSA key and writes a
2000
+ * certificate to disk is not a read. The material is created when the listener
2001
+ * starts, or by `lan_gateway tls-regenerate`.
2002
+ */
1406
2003
  function tlsStatusLine(cfg) {
1407
2004
  if (!cfg.tlsEnabled) return "off";
1408
2005
  if (cfg.tlsMode === "custom") return `custom (${cfg.tlsCertPath ?? "?"}, ${cfg.tlsKeyPath ?? "?"})`;
1409
2006
  try {
1410
- const { material } = loadOrCreateSelfSigned({
1411
- hosts: parseSelfSignedHosts(cfg.tlsSelfSignedHosts),
1412
- days: cfg.tlsCertMaxAgeDays
1413
- });
1414
- const info = describeCert(material.cert);
2007
+ const status = readSelfSignedStatus();
2008
+ if (status === void 0) return "self-signed (not generated yet — created when the listener starts)";
2009
+ const info = describeCert(status.cert);
1415
2010
  return `self-signed [${info.subject}] exp ${info.validTo}`;
1416
2011
  } catch (error) {
1417
2012
  return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`;
1418
2013
  }
1419
2014
  }
1420
- /** Whether `hostname` is loopback (127/8, localhost, ::1). */
1421
- function isLoopbackHost(hostname) {
1422
- if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
1423
- const parts = hostname.split(".");
1424
- return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1425
- }
1426
- const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1427
- "GET",
1428
- "HEAD",
1429
- "OPTIONS"
1430
- ]);
1431
2015
  /**
1432
2016
  * Same-origin loopback fence for the native `/lan-gateway/config` route. The
1433
2017
  * gateway refuses to relay this prefix, so the only way in is the native
@@ -1453,28 +2037,107 @@ function isTrustedConfigRequest(req) {
1453
2037
  if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
1454
2038
  return true;
1455
2039
  }
2040
+ /**
2041
+ * Turn a submitted config patch into the next user section: only keys the card
2042
+ * can edit, only real values, and `null` (or an emptied optional) removes the
2043
+ * key rather than storing it.
2044
+ *
2045
+ * The patch is built from the *submitted* object, never from a schema call's
2046
+ * output. Schemastery fills defaults into whatever it validates and passes
2047
+ * unknown keys through, so deriving the section from `Config(submitted)` wrote
2048
+ * `authRequired: true` (a capability that exists only to be refused) and any
2049
+ * stray key into the user's settings on every save — and, because it also
2050
+ * materialized `cookieName`, reset an operator's custom cookie name to the
2051
+ * schema default.
2052
+ *
2053
+ * A `null` value is the card's clear: the key is dropped from the patch, which
2054
+ * leaves it absent from the section, so it re-inherits the composition layer.
2055
+ */
2056
+ function buildConfigPatch(submitted) {
2057
+ const patch = {};
2058
+ const clear = [];
2059
+ const unknown = [];
2060
+ for (const [key, value] of Object.entries(submitted)) {
2061
+ if (!CONFIG_FIELD_KEYS.has(key)) {
2062
+ unknown.push(key);
2063
+ continue;
2064
+ }
2065
+ if (value === null || value === void 0) {
2066
+ clear.push(key);
2067
+ continue;
2068
+ }
2069
+ if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) {
2070
+ clear.push(key);
2071
+ continue;
2072
+ }
2073
+ patch[key] = value;
2074
+ }
2075
+ return {
2076
+ patch,
2077
+ clear,
2078
+ unknown
2079
+ };
2080
+ }
1456
2081
  function apply(ctx, config) {
1457
2082
  let state = loadState();
1458
2083
  let gateway;
1459
2084
  let startedWith;
1460
2085
  let lastError;
2086
+ /**
2087
+ * The operator's run intent, used only while no settings service is attached.
2088
+ * With settings present, `enabled` in the settings section *is* the intent —
2089
+ * the card and the tool write the same field, so there is one truth rather
2090
+ * than two that disagree.
2091
+ */
1461
2092
  let manualOverride;
1462
2093
  /** Whether the base enforces browser-session auth; set once `connection` is seen. */
1463
2094
  let upstreamSessionAvailable = false;
2095
+ /**
2096
+ * Identifies the current `connection` handler. A provider that detaches and a
2097
+ * new one that attaches run their disposers in an order the plugin does not
2098
+ * control, and a stale disposer clearing `makeRelay` would strand the live
2099
+ * provider — so a disposer only acts if it is still the latest generation.
2100
+ */
2101
+ let connectionGeneration = 0;
1464
2102
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
1465
2103
  let makeRelay;
1466
2104
  /** The authoritative config: settings section when attached, else composition. */
1467
2105
  let configSource = () => config;
1468
- /** Serializes listener start/stop/restart so settings changes cannot race. */
1469
- let syncing = Promise.resolve();
2106
+ /** Whether writes go to the settings section rather than staying in memory. */
2107
+ let settingsAttached = false;
2108
+ /** The settings scope for the `lan-gateway` namespace, while one is attached. */
2109
+ let settingsScope;
2110
+ /**
2111
+ * The settings provider, for the one write a scope cannot express: a section
2112
+ * key must be *removed* to re-inherit the composition layer, and only the
2113
+ * provider's path-addressed `mutate` can unset one.
2114
+ */
2115
+ let settingsProvider;
2116
+ /**
2117
+ * One queue for every lifecycle side effect. Settings changes, tool commands,
2118
+ * credential changes, TLS regeneration and plugin disposal all land here, so
2119
+ * two of them can never interleave a stop with a start.
2120
+ */
2121
+ let lifecycle = Promise.resolve();
2122
+ /** Set by the dispose hook; a start that completes after it must undo itself. */
2123
+ let disposed = false;
1470
2124
  const effective = () => configSource();
2125
+ /** Queue one lifecycle action behind every action already running. */
2126
+ const enqueue = (reason, action) => {
2127
+ lifecycle = lifecycle.then(action).catch((error) => {
2128
+ lastError = error instanceof Error ? error.message : String(error);
2129
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
2130
+ });
2131
+ return lifecycle;
2132
+ };
1471
2133
  const startGateway = async (cfg) => {
1472
2134
  if (gateway !== void 0) return;
1473
2135
  const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
1474
2136
  if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
1475
2137
  if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
1476
2138
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1477
- const tls = resolveTls(cfg);
2139
+ const resolved = resolveTls(cfg);
2140
+ const tls = resolved?.material;
1478
2141
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1479
2142
  const secureCookies = resolveSecureCookies(cfg);
1480
2143
  const next = new LanGateway({
@@ -1486,12 +2149,21 @@ function apply(ctx, config) {
1486
2149
  cookieName: cfg.cookieName,
1487
2150
  secureCookies,
1488
2151
  ...tls !== void 0 ? { tls } : {},
1489
- ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
2152
+ ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {},
2153
+ onStateChange: (updated) => {
2154
+ state = updated;
2155
+ saveState(state);
2156
+ }
1490
2157
  }, state);
1491
2158
  await next.listen();
2159
+ if (disposed) {
2160
+ await next.close();
2161
+ return;
2162
+ }
1492
2163
  gateway = next;
1493
2164
  startedWith = listenerKey(cfg, makeRelay !== void 0);
1494
- ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${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]"}`);
2165
+ 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]"}`);
2166
+ if (resolved?.renewed === true) ctx.logger.warn("dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one — clients that had trusted the old certificate must trust the new one.");
1495
2167
  };
1496
2168
  const stopGateway = async () => {
1497
2169
  const current = gateway;
@@ -1502,31 +2174,43 @@ function apply(ctx, config) {
1502
2174
  ctx.logger.info("dsh-lan-gateway: stopped");
1503
2175
  }
1504
2176
  };
2177
+ /** The config the listener should be running under, intent included. */
2178
+ const desiredConfig = () => {
2179
+ const cfg = effective();
2180
+ if (settingsAttached) return cfg;
2181
+ return manualOverride === void 0 ? cfg : {
2182
+ ...cfg,
2183
+ enabled: manualOverride
2184
+ };
2185
+ };
1505
2186
  /** Reconcile the listener with the effective config (start/stop/restart). */
1506
2187
  const syncGateway = (reason) => {
1507
- syncing = syncing.then(async () => {
2188
+ return enqueue(reason, async () => {
1508
2189
  lastError = void 0;
1509
- const cfg = effective();
1510
- const shouldRun = manualOverride ?? cfg.enabled;
1511
- try {
1512
- if (gateway === void 0) {
1513
- if (shouldRun) await startGateway(cfg);
1514
- } else if (!shouldRun) await stopGateway();
1515
- else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
1516
- await stopGateway();
1517
- await startGateway(cfg);
1518
- }
1519
- } catch (error) {
1520
- lastError = error instanceof Error ? error.message : String(error);
1521
- ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
2190
+ if (disposed) return;
2191
+ const cfg = desiredConfig();
2192
+ if (gateway === void 0) {
2193
+ if (cfg.enabled) await startGateway(cfg);
2194
+ } else if (!cfg.enabled) await stopGateway();
2195
+ else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
2196
+ await stopGateway();
2197
+ await startGateway(cfg);
1522
2198
  }
1523
2199
  });
1524
- return syncing;
1525
2200
  };
1526
- let settingsScope;
2201
+ /** Record the run intent where it will survive: the settings section, or memory. */
2202
+ const setRunIntent = async (enabled) => {
2203
+ if (settingsAttached && settingsScope !== void 0) {
2204
+ await settingsScope.update({ enabled });
2205
+ return;
2206
+ }
2207
+ manualOverride = enabled;
2208
+ };
1527
2209
  ctx.inject(["settings"], (sctx) => {
1528
2210
  const scope = sctx.settings.register(NS, Config, { base: config });
1529
2211
  settingsScope = scope;
2212
+ settingsProvider = sctx.settings;
2213
+ settingsAttached = true;
1530
2214
  configSource = () => scope.get();
1531
2215
  sctx.effect(() => scope.watch(() => {
1532
2216
  syncGateway("settings change");
@@ -1534,10 +2218,14 @@ function apply(ctx, config) {
1534
2218
  sctx.effect(() => () => {
1535
2219
  configSource = () => config;
1536
2220
  settingsScope = void 0;
2221
+ settingsProvider = void 0;
2222
+ settingsAttached = false;
2223
+ syncGateway("settings detach");
1537
2224
  });
1538
2225
  syncGateway("settings attach");
1539
2226
  });
1540
2227
  ctx.inject(["connection"], (ccx) => {
2228
+ const generation = ++connectionGeneration;
1541
2229
  upstreamSessionAvailable = true;
1542
2230
  ctx.logger.info("dsh-lan-gateway: connection service attached; upstream session relay enabled");
1543
2231
  makeRelay = (dshPort) => new UpstreamSessionRelay({
@@ -1545,9 +2233,26 @@ function apply(ctx, config) {
1545
2233
  authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
1546
2234
  log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`)
1547
2235
  });
2236
+ ccx.effect(() => () => {
2237
+ if (generation !== connectionGeneration) return;
2238
+ makeRelay = void 0;
2239
+ upstreamSessionAvailable = false;
2240
+ syncGateway("connection detach");
2241
+ });
1548
2242
  syncGateway("connection attach");
1549
2243
  });
1550
2244
  const configRouteHandler = async (req, res) => {
2245
+ const snapshot = () => {
2246
+ const cfg = effective();
2247
+ return {
2248
+ config: cfg,
2249
+ running: gateway !== void 0,
2250
+ port: cfg.gatewayPort,
2251
+ tls: tlsStatusLine(cfg),
2252
+ upstreamSessionAvailable,
2253
+ lastError: lastError ?? null
2254
+ };
2255
+ };
1551
2256
  const send = (status, body) => {
1552
2257
  res.writeHead(status, { "content-type": "application/json" });
1553
2258
  res.end(JSON.stringify(body));
@@ -1557,15 +2262,7 @@ function apply(ctx, config) {
1557
2262
  return;
1558
2263
  }
1559
2264
  if (req.method === "GET") {
1560
- const cfg = effective();
1561
- send(200, {
1562
- config: cfg,
1563
- running: gateway !== void 0,
1564
- port: cfg.gatewayPort,
1565
- tls: tlsStatusLine(cfg),
1566
- upstreamSessionAvailable,
1567
- lastError: lastError ?? null
1568
- });
2265
+ send(200, snapshot());
1569
2266
  return;
1570
2267
  }
1571
2268
  if (req.method !== "POST") {
@@ -1585,41 +2282,35 @@ function apply(ctx, config) {
1585
2282
  send(400, { error: "body must be a config object" });
1586
2283
  return;
1587
2284
  }
1588
- let candidate;
1589
- try {
1590
- candidate = Config(submitted);
1591
- } catch (error) {
1592
- send(400, { error: error instanceof Error ? error.message : String(error) });
1593
- return;
1594
- }
1595
- if (settingsScope === void 0) {
2285
+ if (settingsProvider === void 0) {
1596
2286
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
1597
2287
  return;
1598
2288
  }
2289
+ const { patch, clear, unknown } = buildConfigPatch(submitted);
2290
+ const candidate = Config({
2291
+ ...effective(),
2292
+ ...patch
2293
+ });
1599
2294
  const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
1600
2295
  const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
1601
2296
  if (structural || candidate.enabled && problems.length > 0) {
1602
2297
  send(409, { error: `config cannot start: ${problems.join(" ")}` });
1603
2298
  return;
1604
2299
  }
1605
- const section = {};
1606
- for (const [key, value] of Object.entries(candidate)) {
1607
- if (value === null || value === void 0) continue;
1608
- if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) continue;
1609
- section[key] = value;
1610
- }
1611
2300
  try {
1612
- await settingsScope.replace(section);
2301
+ const ops = [...Object.entries(patch).map(([key, value]) => ({
2302
+ op: "set",
2303
+ path: [key],
2304
+ value
2305
+ })), ...clear.map((key) => ({
2306
+ op: "unset",
2307
+ path: [key]
2308
+ }))];
2309
+ if (ops.length > 0) await settingsProvider.mutate(NS, ops);
1613
2310
  await syncGateway("config route save");
1614
- const cfg = effective();
1615
- send(200, {
1616
- config: cfg,
1617
- running: gateway !== void 0,
1618
- port: cfg.gatewayPort,
1619
- tls: tlsStatusLine(cfg),
1620
- upstreamSessionAvailable,
1621
- lastError: lastError ?? null
1622
- });
2311
+ const next = { ...snapshot() };
2312
+ if (unknown.length > 0) next["ignored"] = unknown;
2313
+ send(200, next);
1623
2314
  } catch (error) {
1624
2315
  send(409, { error: error instanceof Error ? error.message : String(error) });
1625
2316
  }
@@ -1631,27 +2322,27 @@ function apply(ctx, config) {
1631
2322
  }), "dsh-lan-gateway: config route");
1632
2323
  ctx.tools.register(lanGatewayTool({
1633
2324
  status() {
1634
- const cfg = effective();
2325
+ const cfg = desiredConfig();
1635
2326
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1636
2327
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1637
2328
  return {
1638
2329
  ok: true,
1639
- message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : "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- 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}` : "")
2330
+ 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}` : "")
1640
2331
  };
1641
2332
  },
1642
2333
  async enable() {
1643
- manualOverride = true;
2334
+ await setRunIntent(true);
1644
2335
  await syncGateway("tool enable");
1645
2336
  return gateway !== void 0 ? {
1646
2337
  ok: true,
1647
- message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}`
2338
+ message: `Gateway enabled: listening on ${gateway.boundAddress()}`
1648
2339
  } : {
1649
2340
  ok: false,
1650
2341
  message: `Failed to enable gateway: ${lastError ?? "unknown error"}`
1651
2342
  };
1652
2343
  },
1653
2344
  async disable() {
1654
- manualOverride = false;
2345
+ await setRunIntent(false);
1655
2346
  await syncGateway("tool disable");
1656
2347
  return {
1657
2348
  ok: true,
@@ -1664,23 +2355,21 @@ function apply(ctx, config) {
1664
2355
  message: "Password must be at least 8 characters."
1665
2356
  };
1666
2357
  const setting = password !== void 0 && password.length > 0;
1667
- const previous = state;
1668
- state = setPassword(state, setting ? password : void 0);
2358
+ const hadPassword = state.password !== void 0;
2359
+ state = await setPassword(state, setting ? password : void 0);
1669
2360
  saveState(state);
1670
2361
  gateway?.setState(state);
1671
2362
  if (!setting) {
1672
- manualOverride = false;
1673
- if (gateway !== void 0) {
1674
- await stopGateway();
2363
+ await setRunIntent(false);
2364
+ return enqueue("password cleared", async () => {
2365
+ if (gateway !== void 0) await stopGateway();
1675
2366
  lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
1676
- syncGateway("password cleared");
1677
- }
1678
- return {
2367
+ }).then(() => ({
1679
2368
  ok: true,
1680
2369
  message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
1681
- };
2370
+ }));
1682
2371
  }
1683
- previous === void 0 ? syncGateway("password set") : Promise.resolve();
2372
+ if (!hadPassword) await syncGateway("password set");
1684
2373
  return {
1685
2374
  ok: true,
1686
2375
  message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
@@ -1711,32 +2400,38 @@ function apply(ctx, config) {
1711
2400
  ok: false,
1712
2401
  message: "tlsSelfSignedHosts must name at least one host (DNS name or IP)."
1713
2402
  };
1714
- try {
1715
- regenerateSelfSigned({
1716
- hosts,
1717
- days: cfg.tlsCertMaxAgeDays
1718
- });
1719
- if (gateway !== void 0) {
1720
- await stopGateway();
1721
- await startGateway(effective());
2403
+ let failure;
2404
+ await enqueue("tls regenerate", async () => {
2405
+ try {
2406
+ regenerateSelfSigned({
2407
+ hosts,
2408
+ days: cfg.tlsCertMaxAgeDays
2409
+ });
2410
+ if (gateway !== void 0) {
2411
+ await stopGateway();
2412
+ await startGateway(effective());
2413
+ }
1722
2414
  lastError = void 0;
2415
+ } catch (error) {
2416
+ failure = error instanceof Error ? error.message : String(error);
1723
2417
  }
1724
- return {
1725
- ok: true,
1726
- message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
1727
- };
1728
- } catch (error) {
1729
- return {
1730
- ok: false,
1731
- message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`
1732
- };
1733
- }
2418
+ });
2419
+ return failure === void 0 ? {
2420
+ ok: true,
2421
+ message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
2422
+ } : {
2423
+ ok: false,
2424
+ message: `Failed to regenerate TLS certificate: ${failure}`
2425
+ };
1734
2426
  }
1735
2427
  }));
1736
2428
  ctx.effect(() => {
1737
2429
  syncGateway("boot");
1738
- return stopGateway;
2430
+ return async () => {
2431
+ disposed = true;
2432
+ await enqueue("dispose", stopGateway);
2433
+ };
1739
2434
  }, "dsh-lan-gateway: listener lifecycle");
1740
2435
  }
1741
2436
  //#endregion
1742
- export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
2437
+ export { Config, apply, buildConfigPatch, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };