@riceawa/dsh-lan-gateway 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -85,14 +85,25 @@ function base64url(input) {
85
85
  * Issue a signed session cookie value.
86
86
  * @param secret - the HMAC signing secret (base64 string).
87
87
  * @param expiresMs - epoch millis at which the session expires.
88
+ * @param epoch - the session revocation epoch the cookie is minted under; a
89
+ * cookie whose epoch no longer matches the live state is rejected by
90
+ * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
91
+ * do not participate in revocation.
88
92
  * @returns a `payload.signature` string suitable for the cookie value.
89
93
  */
90
- function signCookie(secret, expiresMs) {
91
- const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })));
94
+ function signCookie(secret, expiresMs, epoch = 0) {
95
+ const payload = base64url(Buffer.from(JSON.stringify({
96
+ exp: expiresMs,
97
+ epoch
98
+ })));
92
99
  return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
93
100
  }
94
- /** Whether a cookie value is a valid, unexpired session signed with `secret`. */
95
- function verifyCookie(secret, value, now) {
101
+ /**
102
+ * Whether a cookie value is a valid, unexpired session signed with `secret`
103
+ * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
104
+ * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
105
+ */
106
+ function verifyCookie(secret, value, now, epoch = 0) {
96
107
  if (value === void 0) return false;
97
108
  const dot = value.indexOf(".");
98
109
  if (dot === -1) return false;
@@ -109,7 +120,25 @@ function verifyCookie(secret, value, now) {
109
120
  if (!timingSafeEqual(expected, actual)) return false;
110
121
  try {
111
122
  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
112
- return typeof decoded.exp === "number" && decoded.exp > now;
123
+ if (typeof decoded.exp !== "number" || decoded.exp <= now) return false;
124
+ return (typeof decoded.epoch === "number" ? decoded.epoch : 0) === epoch;
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * Whether a browser Origin header names the same authority (hostname:port) as
131
+ * a request Host header. Both sides run through WHATWG URL parsing so case and
132
+ * an implicit scheme-default port never decide the match — the comparison the
133
+ * gateway uses to tell same-origin browser requests from cross-site ones.
134
+ * @param origin - the `Origin` header value, or undefined.
135
+ * @param host - the `Host` header value, or undefined.
136
+ * @returns true only when both parse and name the same host[:port].
137
+ */
138
+ function originMatchesHost(origin, host) {
139
+ if (origin === void 0 || host === void 0) return false;
140
+ try {
141
+ return new URL(origin).host === new URL(`http://${host}`).host;
113
142
  } catch {
114
143
  return false;
115
144
  }
@@ -266,13 +295,24 @@ function verifyPassword(state, password) {
266
295
  return false;
267
296
  }
268
297
  }
269
- /** Set (or clear) the password, re-salted on every write. */
298
+ /**
299
+ * Set (or clear) the password, re-salted on every write. Both operations bump
300
+ * the session epoch so every cookie issued under the previous epoch dies — a
301
+ * password change must invalidate sessions the old password authorized.
302
+ */
270
303
  function setPassword(state, password) {
271
- if (password === void 0) return { cookieSecret: state.cookieSecret };
304
+ const base = {
305
+ ...state,
306
+ sessionEpoch: state.sessionEpoch + 1
307
+ };
308
+ if (password === void 0) return {
309
+ cookieSecret: base.cookieSecret,
310
+ sessionEpoch: base.sessionEpoch
311
+ };
272
312
  const salt = randomBytes(16);
273
313
  const hash = scryptSync(password, salt, 64);
274
314
  return {
275
- ...state,
315
+ ...base,
276
316
  password: {
277
317
  hash: hash.toString("hex"),
278
318
  salt: salt.toString("hex")
@@ -280,7 +320,10 @@ function setPassword(state, password) {
280
320
  };
281
321
  }
282
322
  function defaultState() {
283
- return { cookieSecret: randomBytes(32).toString("base64") };
323
+ return {
324
+ cookieSecret: randomBytes(32).toString("base64"),
325
+ sessionEpoch: 0
326
+ };
284
327
  }
285
328
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
286
329
  function loadState(home = homedir()) {
@@ -288,7 +331,15 @@ function loadState(home = homedir()) {
288
331
  try {
289
332
  const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
290
333
  const parsed = JSON.parse(raw);
291
- if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) return parsed;
334
+ if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
335
+ const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
336
+ const base = {
337
+ cookieSecret: parsed.cookieSecret,
338
+ sessionEpoch
339
+ };
340
+ if (parsed.password !== void 0) base.password = parsed.password;
341
+ return base;
342
+ }
292
343
  return defaultState();
293
344
  } catch {
294
345
  return defaultState();
@@ -309,24 +360,54 @@ function saveState(state, home = homedir()) {
309
360
  //#endregion
310
361
  //#region src/gateway.ts
311
362
  /**
312
- * The reverse-proxy gateway: a `node:http` server bound to `0.0.0.0` that
313
- * forwards every request to the loopback dsh web server, rewriting Host and
314
- * Origin so the dsh `/api` trust fence (which only trusts loopback) passes.
363
+ * The reverse-proxy gateway: a `node:http(s)` server bound to `0.0.0.0` that
364
+ * forwards every request to the loopback dsh web server.
315
365
  *
316
- * Security model:
366
+ * Security model (post-QVD / session-base):
317
367
  * - Source is classified from `socket.remoteAddress` only (never
318
- * `X-Forwarded-For`). LAN/loopback sources are proxied without a password;
319
- * anything else must present a valid signed cookie or complete the login.
368
+ * `X-Forwarded-For`). Classification alone grants nothing: by default every
369
+ * source — loopback, LAN, internet — must present a valid gateway session.
370
+ * `lanPasswordless` (an explicit opt-in, false by default) is the one way a
371
+ * LAN/loopback source skips the gateway login, and it is only ever allowed
372
+ * against a session-capable dsh base (enforced by the plugin, which owns the
373
+ * fail-closed guard).
374
+ * - The gateway never forwards its own management surface (`/lan-gateway/*`)
375
+ * or its login/logout paths; those are handled locally or refused.
320
376
  * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
321
- * blinded — so the gateway runs its own origin check on `/api*` requests
322
- * BEFORE rewriting (reject `sec-fetch-site: cross-site` and any Origin that
323
- * does not match the gateway authority the browser actually used).
377
+ * blinded — so the gateway runs its own origin check on every relayed
378
+ * request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
379
+ * `sec-fetch-site: cross-site`, reject any Origin that does not name the
380
+ * gateway authority the browser actually used, and require an Origin on
381
+ * state-changing methods and on every WebSocket upgrade.
382
+ * - Against a session-capable dsh base the Host/Origin rewrite alone would
383
+ * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
384
+ * authority-bound session cookie). The gateway therefore relays one shared
385
+ * upstream session acquired through the launch-token exchange and replays it
386
+ * on every forwarded request. See `upstream-session.ts`.
387
+ * - Sessions carry a revocation epoch: a password change or secret rotation
388
+ * bumps the epoch, every previously issued cookie dies, and established
389
+ * WebSockets are torn down so the client re-authenticates.
324
390
  *
325
391
  * @module @riceawa/dsh-lan-gateway/gateway
326
392
  */
327
393
  const DEFAULT_BODY_LIMIT_BYTES = 65536;
328
394
  const LOGIN_ATTEMPTS_LIMIT = 5;
329
395
  const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
396
+ /** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
397
+ const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
398
+ "GET",
399
+ "HEAD",
400
+ "OPTIONS"
401
+ ]);
402
+ /** Prefixes the gateway owns and must never relay to dsh. */
403
+ function isOwnedPath(pathname) {
404
+ return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
405
+ }
406
+ /** The pathname of a request URL (query string stripped, not decoded). */
407
+ function pathOf(url) {
408
+ const query = url.indexOf("?");
409
+ return query === -1 ? url : url.slice(0, query);
410
+ }
330
411
  /**
331
412
  * The running gateway: owns the HTTP server and the auth state needed per
332
413
  * request. Created by the plugin on enable; torn down by the plugin on
@@ -338,6 +419,8 @@ var LanGateway = class {
338
419
  loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
339
420
  state;
340
421
  disposed = false;
422
+ /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
423
+ activeDuplexes = /* @__PURE__ */ new Set();
341
424
  constructor(config, state) {
342
425
  this.config = config;
343
426
  this.state = state;
@@ -352,8 +435,9 @@ var LanGateway = class {
352
435
  this.handleUpgrade(req, socket, head);
353
436
  });
354
437
  }
355
- /** Replace the in-memory state (e.g. after a password change). */
438
+ /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
356
439
  setState(state) {
440
+ if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
357
441
  this.state = state;
358
442
  }
359
443
  /** Start listening; rejects if the port is already in use. */
@@ -372,17 +456,28 @@ var LanGateway = class {
372
456
  this.server.listen(this.config.gatewayPort, "0.0.0.0");
373
457
  });
374
458
  }
375
- /** Close the server and stop accepting connections. */
459
+ /** Close the server, drop upgraded sockets, and stop accepting connections. */
376
460
  async close() {
377
461
  if (this.disposed) return;
378
462
  this.disposed = true;
463
+ this.destroyActiveDuplexes();
379
464
  return new Promise((resolve) => {
380
465
  this.server.close(() => resolve());
381
466
  this.server.closeAllConnections();
382
467
  });
383
468
  }
384
- sourceClass(req) {
385
- return classifySource(req.socket.remoteAddress, this.config.lanCidrs);
469
+ destroyActiveDuplexes() {
470
+ for (const socket of this.activeDuplexes) socket.destroy();
471
+ this.activeDuplexes.clear();
472
+ }
473
+ trackDuplex(socket) {
474
+ this.activeDuplexes.add(socket);
475
+ socket.on("close", () => {
476
+ this.activeDuplexes.delete(socket);
477
+ });
478
+ }
479
+ sourceOf(req) {
480
+ return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
386
481
  }
387
482
  /** Parse the session cookie out of a Cookie header. */
388
483
  sessionCookie(req) {
@@ -393,10 +488,14 @@ var LanGateway = class {
393
488
  if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
394
489
  }
395
490
  }
396
- /** Whether a request carries a valid session for its source. */
491
+ /** Whether a request carries a session valid under the current epoch. */
397
492
  authorized(req) {
398
493
  const cookie = this.sessionCookie(req);
399
- return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now());
494
+ return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
495
+ }
496
+ /** Whether this source must present a gateway session (default: everyone). */
497
+ requiresLogin(source) {
498
+ return !(this.config.lanPasswordless && source !== "internet");
400
499
  }
401
500
  serveUnauthorized(res, limited) {
402
501
  res.writeHead(302, {
@@ -414,47 +513,58 @@ var LanGateway = class {
414
513
  });
415
514
  res.end(renderLoginPage(opts));
416
515
  }
417
- /** HSTS when the listener is HTTPS (never sent on plain HTTP). */
516
+ /** HSTS when the listener itself is HTTPS (never sent on plain HTTP). */
418
517
  securityHeaders() {
419
518
  return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
420
519
  }
421
- /** Handle one HTTP request: auth gate → CSRF fence → forward. */
520
+ /**
521
+ * The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
522
+ * and applied before any Host/Origin rewriting. Browsers attach Origin to
523
+ * state-changing requests and to every WebSocket handshake; reads without an
524
+ * Origin (navigations, non-browser clients holding a session) stay allowed.
525
+ */
526
+ sameSiteAllowed(req, upgrade) {
527
+ const headers = req.headers;
528
+ if (headers["sec-fetch-site"] === "cross-site") return false;
529
+ const origin = headers.origin;
530
+ const host = headers.host;
531
+ if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
532
+ if (upgrade) return origin !== void 0;
533
+ if (!READ_ONLY_METHODS$1.has(req.method ?? "GET")) return origin !== void 0;
534
+ return true;
535
+ }
536
+ sessionSetCookie(value, maxAgeSeconds) {
537
+ const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
538
+ return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
539
+ }
540
+ /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
422
541
  async handleHttp(req, res) {
423
- const source = this.sourceClass(req);
424
542
  const url = req.url ?? "/";
425
- const pathname = url.split("?")[0] ?? "/";
543
+ const pathname = pathOf(url);
544
+ const source = this.sourceOf(req);
426
545
  if (pathname === "/__login") {
427
546
  this.handleLogin(req, res);
428
547
  return;
429
548
  }
430
- if (source === "internet" && this.config.authRequired) {
431
- if (!this.authorized(req)) {
432
- this.serveUnauthorized(res, false);
433
- return;
434
- }
549
+ if (pathname === "/__logout") {
550
+ this.handleLogout(req, res);
551
+ return;
435
552
  }
436
- if (pathname === "/api" || pathname.startsWith("/api/")) {
437
- if (!this.passesCsrfFence(req)) {
438
- res.writeHead(403, this.securityHeaders());
439
- res.end("forbidden");
440
- return;
441
- }
553
+ if (isOwnedPath(pathname)) {
554
+ res.writeHead(403, this.securityHeaders());
555
+ res.end("forbidden");
556
+ return;
442
557
  }
443
- this.forward(req, res, url);
444
- }
445
- /** Reject cross-site API traffic: the gateway's own origin check. */
446
- passesCsrfFence(req) {
447
- const headers = req.headers;
448
- if (headers["sec-fetch-site"] === "cross-site") return false;
449
- const origin = headers.origin;
450
- if (origin === void 0) return true;
451
- try {
452
- const originHost = new URL(origin).host;
453
- const requestHost = typeof headers.host === "string" ? headers.host : "";
454
- return originHost === requestHost || originHost === stripDefaultPort(requestHost);
455
- } catch {
456
- return false;
558
+ if (this.requiresLogin(source) && !this.authorized(req)) {
559
+ this.serveUnauthorized(res, false);
560
+ return;
561
+ }
562
+ if (!this.sameSiteAllowed(req, false)) {
563
+ res.writeHead(403, this.securityHeaders());
564
+ res.end("forbidden");
565
+ return;
457
566
  }
567
+ await this.relayHttp(req, res, url);
458
568
  }
459
569
  /** Handle the login GET form / POST submission. */
460
570
  handleLogin(req, res) {
@@ -464,7 +574,7 @@ var LanGateway = class {
464
574
  return;
465
575
  }
466
576
  if (req.method !== "POST") {
467
- res.writeHead(405, { allow: "GET, POST" });
577
+ res.writeHead(405, { allow: "GET, HEAD, POST" });
468
578
  res.end();
469
579
  return;
470
580
  }
@@ -485,24 +595,64 @@ var LanGateway = class {
485
595
  this.serveLoginError(res, "Incorrect password.");
486
596
  return;
487
597
  }
488
- const expiresMs = Date.now() + this.config.cookieMaxAgeDays * 864e5;
489
- const cookie = signCookie(this.state.cookieSecret, expiresMs);
490
- const secure = this.config.tls !== void 0 ? "; Secure" : "";
598
+ const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
599
+ const expiresMs = Date.now() + maxAgeSeconds * 1e3;
600
+ const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
491
601
  res.writeHead(302, {
492
602
  location: "/",
493
603
  ...this.securityHeaders(),
494
- "set-cookie": [`${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86400}${secure}`]
604
+ "set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
495
605
  });
496
606
  res.end();
497
607
  });
498
608
  }
499
- /** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
500
- forward(req, res, url) {
609
+ /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
610
+ handleLogout(req, res) {
611
+ if (req.method !== "POST") {
612
+ res.writeHead(405, { allow: "POST" });
613
+ res.end();
614
+ return;
615
+ }
616
+ if (!this.sameSiteAllowed(req, false)) {
617
+ res.writeHead(403, this.securityHeaders());
618
+ res.end("forbidden");
619
+ return;
620
+ }
621
+ res.writeHead(302, {
622
+ location: "/",
623
+ ...this.securityHeaders(),
624
+ "set-cookie": [this.sessionSetCookie("", 0)]
625
+ });
626
+ res.end();
627
+ }
628
+ /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
629
+ upstreamHeaders(req, keepUpgrade) {
501
630
  const headers = { ...req.headers };
502
631
  headers.host = `127.0.0.1:${this.config.dshPort}`;
503
632
  if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
504
633
  delete headers["proxy-connection"];
505
- delete headers.connection;
634
+ if (!keepUpgrade) {
635
+ delete headers.connection;
636
+ delete headers.upgrade;
637
+ }
638
+ return headers;
639
+ }
640
+ /** Attach the shared upstream session cookie to the outbound headers, if any. */
641
+ attachUpstreamSession(headers) {
642
+ const session = this.config.upstreamSession;
643
+ if (session === void 0) return false;
644
+ const cookie = session.peek();
645
+ if (cookie === void 0) return false;
646
+ const existing = headers.cookie;
647
+ headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
648
+ return true;
649
+ }
650
+ /** Forward an HTTP request to dsh, replaying the shared upstream session. */
651
+ async relayHttp(req, res, url) {
652
+ const session = this.config.upstreamSession;
653
+ if (session !== void 0) await session.cookie();
654
+ const headers = this.upstreamHeaders(req, false);
655
+ const attached = this.attachUpstreamSession(headers);
506
656
  const proxyReq = http.request({
507
657
  host: "127.0.0.1",
508
658
  port: this.config.dshPort,
@@ -510,6 +660,7 @@ var LanGateway = class {
510
660
  path: url,
511
661
  headers
512
662
  }, (proxyRes) => {
663
+ if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
513
664
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
514
665
  proxyRes.pipe(res);
515
666
  });
@@ -519,25 +670,40 @@ var LanGateway = class {
519
670
  });
520
671
  req.pipe(proxyReq);
521
672
  }
522
- /** Forward a WebSocket upgrade, splicing the raw duplex through to dsh. */
523
- handleUpgrade(req, socket, head) {
524
- if (this.sourceClass(req) === "internet" && this.config.authRequired && !this.authorized(req)) {
525
- socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
673
+ /** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
674
+ async handleUpgrade(req, socket, head) {
675
+ const url = req.url ?? "/";
676
+ const pathname = pathOf(url);
677
+ const source = this.sourceOf(req);
678
+ const refuse = (status) => {
679
+ socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
526
680
  socket.destroy();
681
+ };
682
+ if (pathname === "/__login" || pathname === "/__logout" || isOwnedPath(pathname)) {
683
+ refuse(403);
527
684
  return;
528
685
  }
529
- const headers = { ...req.headers };
530
- headers.host = `127.0.0.1:${this.config.dshPort}`;
531
- if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
532
- delete headers["proxy-connection"];
686
+ if (this.requiresLogin(source) && !this.authorized(req)) {
687
+ refuse(401);
688
+ return;
689
+ }
690
+ if (!this.sameSiteAllowed(req, true)) {
691
+ refuse(403);
692
+ return;
693
+ }
694
+ const session = this.config.upstreamSession;
695
+ if (session !== void 0) await session.cookie();
696
+ const headers = this.upstreamHeaders(req, true);
697
+ this.attachUpstreamSession(headers);
533
698
  const proxyReq = http.request({
534
699
  host: "127.0.0.1",
535
700
  port: this.config.dshPort,
536
701
  method: "GET",
537
- path: req.url ?? "/",
702
+ path: url,
538
703
  headers
539
704
  });
540
705
  proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
706
+ this.trackDuplex(socket);
541
707
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
542
708
  const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
543
709
  socket.write(`${statusLine}${headerLines}\r\n`);
@@ -551,12 +717,6 @@ var LanGateway = class {
551
717
  proxyReq.end();
552
718
  }
553
719
  };
554
- /** Strip an explicit default port from a Host authority, if present. */
555
- function stripDefaultPort(host) {
556
- const parsed = /^(.+?)(?::(\d+))?$/.exec(host);
557
- if (parsed?.[2] === "80" || parsed?.[2] === "443") return parsed[1];
558
- return host;
559
- }
560
720
  //#endregion
561
721
  //#region src/x509.ts
562
722
  /**
@@ -908,7 +1068,7 @@ const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
908
1068
  function lanGatewayTool(control) {
909
1069
  return defineTool({
910
1070
  name: LAN_GATEWAY_TOOL_NAME,
911
- 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 trusted LAN CIDRs, and the TLS state. `enable` starts listening on 0.0.0.0 (loopback and LAN sources need no password; anything else must sign in). `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password for non-LAN access. `rotate-secret` invalidates every issued login cookie. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
1071
+ 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.",
912
1072
  parameters: {
913
1073
  command: {
914
1074
  type: "string",
@@ -920,7 +1080,7 @@ function lanGatewayTool(control) {
920
1080
  "rotate-secret",
921
1081
  "tls-regenerate"
922
1082
  ],
923
- description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password. `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
1083
+ 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."
924
1084
  },
925
1085
  password: {
926
1086
  type: "string",
@@ -963,6 +1123,142 @@ function lanGatewayTool(control) {
963
1123
  });
964
1124
  }
965
1125
  //#endregion
1126
+ //#region src/upstream-session.ts
1127
+ /**
1128
+ * Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
1129
+ *
1130
+ * When dsh added browser-session authentication it stopped trusting a loopback
1131
+ * Host header alone: every `/api` request (and the remote WebSocket mux) must
1132
+ * now present a signed cookie bound to the authority it names
1133
+ * (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
1134
+ * process launch token. A reverse proxy that rewrites Host to loopback — which
1135
+ * is what this gateway does — therefore gets a 401 no matter how the Host is
1136
+ * forged. The gateway cannot mint that cookie itself (the signing secret lives
1137
+ * in dsh's credential provider), so it does exactly what a browser does: on the
1138
+ * loopback transport it visits the launch-token URL, keeps the Set-Cookie it
1139
+ * earns, and replays that one shared session on every request it forwards.
1140
+ *
1141
+ * Semantics match the pre-existing "single password = single operator" model:
1142
+ * whoever passes the gateway's own login rides this one upstream session. It is
1143
+ * not multi-user authorization, and upstream (which holds the secret) remains
1144
+ * the actual authority over what the session may do.
1145
+ *
1146
+ * The relay is a no-op on a base without browser sessions: acquisition fails
1147
+ * and `cookie()` returns undefined, so the gateway simply forwards without a
1148
+ * session cookie exactly as it did against an older dsh.
1149
+ *
1150
+ * @module @riceawa/dsh-lan-gateway/upstream-session
1151
+ */
1152
+ /** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
1153
+ const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
1154
+ /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
1155
+ function nameValueOnly(setCookie) {
1156
+ const semi = setCookie.indexOf(";");
1157
+ return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
1158
+ }
1159
+ /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
1160
+ function maxAgeSeconds(setCookie) {
1161
+ const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
1162
+ return match === null ? void 0 : Number(match[1]);
1163
+ }
1164
+ /**
1165
+ * Perform the token exchange over loopback: GET the launch-token URL with the
1166
+ * upstream authority as Host, read the Set-Cookie the index route mints, and
1167
+ * return its `name=value` plus expiry (or undefined when the exchange failed
1168
+ * or no session cookie came back — e.g. an older base without browser
1169
+ * sessions).
1170
+ */
1171
+ function exchange(url, authority, port) {
1172
+ return new Promise((resolve) => {
1173
+ let target;
1174
+ try {
1175
+ target = new URL(url);
1176
+ } catch {
1177
+ resolve(void 0);
1178
+ return;
1179
+ }
1180
+ const request = http.request({
1181
+ host: "127.0.0.1",
1182
+ port,
1183
+ method: "GET",
1184
+ path: `${target.pathname}${target.search}`,
1185
+ headers: {
1186
+ host: authority,
1187
+ accept: "text/html"
1188
+ }
1189
+ }, (response) => {
1190
+ const setCookies = response.headers["set-cookie"];
1191
+ response.resume();
1192
+ if (setCookies === void 0) {
1193
+ resolve(void 0);
1194
+ return;
1195
+ }
1196
+ const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`));
1197
+ if (raw === void 0) {
1198
+ resolve(void 0);
1199
+ return;
1200
+ }
1201
+ const header = nameValueOnly(raw);
1202
+ const maxAge = maxAgeSeconds(raw);
1203
+ resolve({
1204
+ header,
1205
+ expiresAt: Date.now() + (maxAge ?? 0) * 1e3
1206
+ });
1207
+ });
1208
+ request.on("error", () => resolve(void 0));
1209
+ request.setTimeout(5e3, () => request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout")));
1210
+ request.end();
1211
+ });
1212
+ }
1213
+ /**
1214
+ * A cached {@link UpstreamSession} acquired through the launch-token exchange.
1215
+ * Acquisition runs at most once concurrently and the result is cached until it
1216
+ * nears expiry or {@link invalidate} is called.
1217
+ */
1218
+ var UpstreamSessionRelay = class {
1219
+ port;
1220
+ authority;
1221
+ authenticatedUrl;
1222
+ held;
1223
+ inflight;
1224
+ constructor(options) {
1225
+ this.port = options.port;
1226
+ this.authority = options.authority ?? `127.0.0.1:${options.port}`;
1227
+ this.authenticatedUrl = options.authenticatedUrl;
1228
+ }
1229
+ /** Whether the held session is still comfortably inside its lifetime. */
1230
+ fresh() {
1231
+ const held = this.held;
1232
+ if (held === void 0) return false;
1233
+ return Date.now() < held.expiresAt - 6e4;
1234
+ }
1235
+ peek() {
1236
+ return this.held?.header;
1237
+ }
1238
+ invalidate() {
1239
+ this.held = void 0;
1240
+ }
1241
+ async cookie() {
1242
+ if (this.fresh()) return this.held?.header;
1243
+ return this.acquire();
1244
+ }
1245
+ acquire() {
1246
+ if (this.inflight !== void 0) return this.inflight;
1247
+ const pending = this.doExchange().finally(() => {
1248
+ this.inflight = void 0;
1249
+ });
1250
+ this.inflight = pending;
1251
+ return pending;
1252
+ }
1253
+ async doExchange() {
1254
+ const url = this.authenticatedUrl();
1255
+ if (url === void 0) return void 0;
1256
+ const result = await exchange(url, this.authority, this.port);
1257
+ if (result !== void 0) this.held = result;
1258
+ return this.held?.header;
1259
+ }
1260
+ };
1261
+ //#endregion
966
1262
  //#region src/index.ts
967
1263
  /** Stable Cordis plugin name. */
968
1264
  const name = "dsh-lan-gateway";
@@ -974,7 +1270,8 @@ const NS = settingsNamespace("lan-gateway");
974
1270
  const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
975
1271
  "dshTargetPort",
976
1272
  "tlsCertPath",
977
- "tlsKeyPath"
1273
+ "tlsKeyPath",
1274
+ "trustedTerminator"
978
1275
  ]);
979
1276
  /** Schemastery configuration validated by the Loader. */
980
1277
  const Config = z.object({
@@ -982,6 +1279,7 @@ const Config = z.object({
982
1279
  gatewayPort: z.natural().min(1).max(65535).default(3081),
983
1280
  dshTargetPort: z.natural().min(1).max(65535),
984
1281
  lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
1282
+ lanPasswordless: z.boolean().default(false),
985
1283
  authRequired: z.boolean().default(true),
986
1284
  cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
987
1285
  cookieName: z.string().default("dsh_gw_auth"),
@@ -990,8 +1288,22 @@ const Config = z.object({
990
1288
  tlsCertPath: z.string(),
991
1289
  tlsKeyPath: z.string(),
992
1290
  tlsSelfSignedHosts: z.string().default("localhost"),
993
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825)
1291
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1292
+ allowInsecurePlaintext: z.boolean().default(false),
1293
+ trustedTerminator: z.string()
994
1294
  });
1295
+ /**
1296
+ * The fail-closed problems that prevent a config from enabling the listener.
1297
+ * Returns every problem (not just the first) so the operator sees the full
1298
+ * migration at once. Exported for tests.
1299
+ */
1300
+ function gatewayStartProblems(cfg, facts) {
1301
+ const problems = [];
1302
+ if (cfg.authRequired === false) problems.push("authRequired=false is no longer supported — authentication is always required. Remove `authRequired` (or set it true); for password-free LAN access set `lanPasswordless: true`.");
1303
+ if (cfg.lanPasswordless && !facts.upstreamSessionAvailable) problems.push("lanPasswordless requires a dsh base with browser-session auth (>= 0.1.2-rc.1): the gateway relaxes only its own login, never dsh authorization. Upgrade dsh, or set lanPasswordless: false.");
1304
+ if (!(cfg.tlsEnabled || cfg.trustedTerminator !== void 0) && !cfg.allowInsecurePlaintext) problems.push("Refusing to serve over plaintext HTTP: enable TLS (tlsEnabled: true), declare a trusted TLS-terminating proxy (trustedTerminator), or set allowInsecurePlaintext: true to accept the plaintext exposure (passwords and sessions would travel in clear).");
1305
+ return problems;
1306
+ }
995
1307
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
996
1308
  function resolveTls(cfg) {
997
1309
  if (!cfg.tlsEnabled) return void 0;
@@ -1010,7 +1322,7 @@ function listenerKey(cfg) {
1010
1322
  cfg.gatewayPort,
1011
1323
  cfg.dshTargetPort,
1012
1324
  cfg.lanCidrs,
1013
- cfg.authRequired,
1325
+ cfg.lanPasswordless,
1014
1326
  cfg.cookieMaxAgeDays,
1015
1327
  cfg.cookieName,
1016
1328
  cfg.tlsEnabled,
@@ -1018,7 +1330,9 @@ function listenerKey(cfg) {
1018
1330
  cfg.tlsCertPath,
1019
1331
  cfg.tlsKeyPath,
1020
1332
  cfg.tlsSelfSignedHosts,
1021
- cfg.tlsCertMaxAgeDays
1333
+ cfg.tlsCertMaxAgeDays,
1334
+ cfg.allowInsecurePlaintext,
1335
+ cfg.trustedTerminator
1022
1336
  ]);
1023
1337
  }
1024
1338
  /** One-line TLS description for status output. */
@@ -1042,13 +1356,20 @@ function isLoopbackHost(hostname) {
1042
1356
  const parts = hostname.split(".");
1043
1357
  return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1044
1358
  }
1359
+ const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1360
+ "GET",
1361
+ "HEAD",
1362
+ "OPTIONS"
1363
+ ]);
1045
1364
  /**
1046
- * Same-origin loopback fence for the config route (mirrors the fence the dsh
1047
- * host uses for its own /api, and what dsh-lan-gateway's sibling plugins do):
1048
- * the Host must be loopback (the gateway rewrites it), cross-site fetches are
1049
- * refused, and any Origin must match the Host the browser actually used.
1365
+ * Same-origin loopback fence for the native `/lan-gateway/config` route. The
1366
+ * gateway refuses to relay this prefix, so the only way in is the native
1367
+ * loopback listener itself (a genuine local user, or a local process that could
1368
+ * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
1369
+ * cross-site fetches are refused, an Origin must match the Host the browser
1370
+ * used, and a state-changing method must carry that Origin. Exported for tests.
1050
1371
  */
1051
- function isTrustedRequest(req) {
1372
+ function isTrustedConfigRequest(req) {
1052
1373
  const host = req.headers?.host;
1053
1374
  if (typeof host !== "string" || host === "") return false;
1054
1375
  let hostUrl;
@@ -1060,12 +1381,10 @@ function isTrustedRequest(req) {
1060
1381
  if (!isLoopbackHost(hostUrl.hostname)) return false;
1061
1382
  if (req.headers?.["sec-fetch-site"] === "cross-site") return false;
1062
1383
  const origin = req.headers?.origin;
1063
- if (origin === void 0) return true;
1064
- try {
1065
- return new URL(origin).host === hostUrl.host;
1066
- } catch {
1067
- return false;
1068
- }
1384
+ if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
1385
+ const method = req.method ?? "GET";
1386
+ if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
1387
+ return true;
1069
1388
  }
1070
1389
  function apply(ctx, config) {
1071
1390
  let state = loadState();
@@ -1073,6 +1392,10 @@ function apply(ctx, config) {
1073
1392
  let startedWith;
1074
1393
  let lastError;
1075
1394
  let manualOverride;
1395
+ /** Whether the base enforces browser-session auth; set once `connection` is seen. */
1396
+ let upstreamSessionAvailable = false;
1397
+ /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
1398
+ let makeRelay;
1076
1399
  /** The authoritative config: settings section when attached, else composition. */
1077
1400
  let configSource = () => config;
1078
1401
  /** Serializes listener start/stop/restart so settings changes cannot race. */
@@ -1080,22 +1403,27 @@ function apply(ctx, config) {
1080
1403
  const effective = () => configSource();
1081
1404
  const startGateway = async (cfg) => {
1082
1405
  if (gateway !== void 0) return;
1083
- if (cfg.authRequired && state.password === void 0) throw new Error("dsh-lan-gateway: no password set — run `lan_gateway set-password` (or set authRequired=false in the plugin config) before enabling.");
1406
+ const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
1407
+ if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
1408
+ if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
1084
1409
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1085
1410
  const tls = resolveTls(cfg);
1411
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1086
1412
  const next = new LanGateway({
1087
1413
  gatewayPort: cfg.gatewayPort,
1088
1414
  dshPort,
1089
1415
  lanCidrs: cfg.lanCidrs,
1090
- authRequired: cfg.authRequired,
1416
+ lanPasswordless: cfg.lanPasswordless,
1091
1417
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
1092
1418
  cookieName: cfg.cookieName,
1093
- ...tls !== void 0 ? { tls } : {}
1419
+ secureCookies: encryptedIngress,
1420
+ ...tls !== void 0 ? { tls } : {},
1421
+ ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
1094
1422
  }, state);
1095
1423
  await next.listen();
1096
1424
  gateway = next;
1097
1425
  startedWith = listenerKey(cfg);
1098
- ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}`);
1426
+ 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]"}`);
1099
1427
  };
1100
1428
  const stopGateway = async () => {
1101
1429
  const current = gateway;
@@ -1141,13 +1469,21 @@ function apply(ctx, config) {
1141
1469
  });
1142
1470
  syncGateway("settings attach");
1143
1471
  });
1472
+ ctx.inject(["connection"], (ccx) => {
1473
+ upstreamSessionAvailable = true;
1474
+ makeRelay = (dshPort) => new UpstreamSessionRelay({
1475
+ port: dshPort,
1476
+ authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`)
1477
+ });
1478
+ syncGateway("connection attach");
1479
+ });
1144
1480
  const configRouteHandler = async (req, res) => {
1145
1481
  const send = (status, body) => {
1146
1482
  res.writeHead(status, { "content-type": "application/json" });
1147
1483
  res.end(JSON.stringify(body));
1148
1484
  };
1149
- if (!isTrustedRequest(req)) {
1150
- send(403, { error: "request refused: this route answers loopback-origin requests only" });
1485
+ if (!isTrustedConfigRequest(req)) {
1486
+ send(403, { error: "request refused: this route answers same-origin loopback requests only" });
1151
1487
  return;
1152
1488
  }
1153
1489
  if (req.method === "GET") {
@@ -1157,6 +1493,7 @@ function apply(ctx, config) {
1157
1493
  running: gateway !== void 0,
1158
1494
  port: cfg.gatewayPort,
1159
1495
  tls: tlsStatusLine(cfg),
1496
+ upstreamSessionAvailable,
1160
1497
  lastError: lastError ?? null
1161
1498
  });
1162
1499
  return;
@@ -1189,6 +1526,12 @@ function apply(ctx, config) {
1189
1526
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
1190
1527
  return;
1191
1528
  }
1529
+ const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
1530
+ const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
1531
+ if (structural || candidate.enabled && problems.length > 0) {
1532
+ send(409, { error: `config cannot start: ${problems.join(" ")}` });
1533
+ return;
1534
+ }
1192
1535
  const section = {};
1193
1536
  for (const [key, value] of Object.entries(candidate)) {
1194
1537
  if (value === null || value === void 0) continue;
@@ -1204,6 +1547,7 @@ function apply(ctx, config) {
1204
1547
  running: gateway !== void 0,
1205
1548
  port: cfg.gatewayPort,
1206
1549
  tls: tlsStatusLine(cfg),
1550
+ upstreamSessionAvailable,
1207
1551
  lastError: lastError ?? null
1208
1552
  });
1209
1553
  } catch (error) {
@@ -1219,9 +1563,10 @@ function apply(ctx, config) {
1219
1563
  status() {
1220
1564
  const cfg = effective();
1221
1565
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1566
+ const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1222
1567
  return {
1223
1568
  ok: true,
1224
- 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- auth required for non-LAN: ${cfg.authRequired}\n- trusted LAN CIDRs: ${cfg.lanCidrs.join(", ") || "(none)"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d\n- TLS: ${tlsStatusLine(cfg)}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
1569
+ 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 ? `TLS terminated by trusted proxy (${cfg.trustedTerminator})` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
1225
1570
  };
1226
1571
  },
1227
1572
  async enable() {
@@ -1243,29 +1588,46 @@ function apply(ctx, config) {
1243
1588
  message: "Gateway disabled."
1244
1589
  };
1245
1590
  },
1246
- setPassword(password) {
1591
+ async setPassword(password) {
1247
1592
  if (password !== void 0 && password.length > 0 && password.length < 8) return {
1248
1593
  ok: false,
1249
1594
  message: "Password must be at least 8 characters."
1250
1595
  };
1251
1596
  const setting = password !== void 0 && password.length > 0;
1597
+ const previous = state;
1252
1598
  state = setPassword(state, setting ? password : void 0);
1253
1599
  saveState(state);
1254
1600
  gateway?.setState(state);
1601
+ if (!setting) {
1602
+ manualOverride = false;
1603
+ if (gateway !== void 0) {
1604
+ await stopGateway();
1605
+ lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
1606
+ syncGateway("password cleared");
1607
+ }
1608
+ return {
1609
+ ok: true,
1610
+ message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
1611
+ };
1612
+ }
1613
+ previous === void 0 ? syncGateway("password set") : Promise.resolve();
1255
1614
  return {
1256
1615
  ok: true,
1257
- message: setting ? "Password set. Non-LAN access now requires it." : "Password cleared. Non-LAN access is now password-free (only safe if authRequired is false or no non-LAN sources exist)."
1616
+ message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
1258
1617
  };
1259
1618
  },
1260
1619
  rotateSecret() {
1261
- const next = { cookieSecret: randomBytes(32).toString("base64") };
1620
+ const next = {
1621
+ cookieSecret: randomBytes(32).toString("base64"),
1622
+ sessionEpoch: state.sessionEpoch + 1
1623
+ };
1262
1624
  if (state.password !== void 0) next.password = state.password;
1263
1625
  state = next;
1264
1626
  saveState(state);
1265
1627
  gateway?.setState(state);
1266
1628
  return {
1267
1629
  ok: true,
1268
- message: "Session secret rotated. All existing login cookies are now invalid."
1630
+ message: "Session secret rotated and epoch advanced. All existing login cookies and live WebSockets are now invalid."
1269
1631
  };
1270
1632
  },
1271
1633
  async regenerateTls() {
@@ -1301,10 +1663,10 @@ function apply(ctx, config) {
1301
1663
  }
1302
1664
  }
1303
1665
  }));
1304
- ctx.effect(async () => {
1305
- await syncGateway("boot");
1666
+ ctx.effect(() => {
1667
+ syncGateway("boot");
1306
1668
  return stopGateway;
1307
1669
  }, "dsh-lan-gateway: listener lifecycle");
1308
1670
  }
1309
1671
  //#endregion
1310
- export { Config, apply, inject, name };
1672
+ export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name };