@riceawa/dsh-lan-gateway 0.4.0 → 0.5.1

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,6 +1,5 @@
1
1
  import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { settingsNamespace } from "@deepseek-ai/dsh-settings";
4
3
  import http from "node:http";
5
4
  import https from "node:https";
6
5
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -85,14 +84,25 @@ function base64url(input) {
85
84
  * Issue a signed session cookie value.
86
85
  * @param secret - the HMAC signing secret (base64 string).
87
86
  * @param expiresMs - epoch millis at which the session expires.
87
+ * @param epoch - the session revocation epoch the cookie is minted under; a
88
+ * cookie whose epoch no longer matches the live state is rejected by
89
+ * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
90
+ * do not participate in revocation.
88
91
  * @returns a `payload.signature` string suitable for the cookie value.
89
92
  */
90
- function signCookie(secret, expiresMs) {
91
- const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })));
93
+ function signCookie(secret, expiresMs, epoch = 0) {
94
+ const payload = base64url(Buffer.from(JSON.stringify({
95
+ exp: expiresMs,
96
+ epoch
97
+ })));
92
98
  return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
93
99
  }
94
- /** Whether a cookie value is a valid, unexpired session signed with `secret`. */
95
- function verifyCookie(secret, value, now) {
100
+ /**
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.
104
+ */
105
+ function verifyCookie(secret, value, now, epoch = 0) {
96
106
  if (value === void 0) return false;
97
107
  const dot = value.indexOf(".");
98
108
  if (dot === -1) return false;
@@ -109,7 +119,25 @@ function verifyCookie(secret, value, now) {
109
119
  if (!timingSafeEqual(expected, actual)) return false;
110
120
  try {
111
121
  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
112
- return typeof decoded.exp === "number" && decoded.exp > now;
122
+ if (typeof decoded.exp !== "number" || decoded.exp <= now) return false;
123
+ return (typeof decoded.epoch === "number" ? decoded.epoch : 0) === epoch;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ /**
129
+ * Whether a browser Origin header names the same authority (hostname:port) as
130
+ * a request Host header. Both sides run through WHATWG URL parsing so case and
131
+ * an implicit scheme-default port never decide the match — the comparison the
132
+ * gateway uses to tell same-origin browser requests from cross-site ones.
133
+ * @param origin - the `Origin` header value, or undefined.
134
+ * @param host - the `Host` header value, or undefined.
135
+ * @returns true only when both parse and name the same host[:port].
136
+ */
137
+ function originMatchesHost(origin, host) {
138
+ if (origin === void 0 || host === void 0) return false;
139
+ try {
140
+ return new URL(origin).host === new URL(`http://${host}`).host;
113
141
  } catch {
114
142
  return false;
115
143
  }
@@ -266,13 +294,24 @@ function verifyPassword(state, password) {
266
294
  return false;
267
295
  }
268
296
  }
269
- /** Set (or clear) the password, re-salted on every write. */
297
+ /**
298
+ * Set (or clear) the password, re-salted on every write. Both operations bump
299
+ * the session epoch so every cookie issued under the previous epoch dies — a
300
+ * password change must invalidate sessions the old password authorized.
301
+ */
270
302
  function setPassword(state, password) {
271
- if (password === void 0) return { cookieSecret: state.cookieSecret };
303
+ const base = {
304
+ ...state,
305
+ sessionEpoch: state.sessionEpoch + 1
306
+ };
307
+ if (password === void 0) return {
308
+ cookieSecret: base.cookieSecret,
309
+ sessionEpoch: base.sessionEpoch
310
+ };
272
311
  const salt = randomBytes(16);
273
312
  const hash = scryptSync(password, salt, 64);
274
313
  return {
275
- ...state,
314
+ ...base,
276
315
  password: {
277
316
  hash: hash.toString("hex"),
278
317
  salt: salt.toString("hex")
@@ -280,7 +319,10 @@ function setPassword(state, password) {
280
319
  };
281
320
  }
282
321
  function defaultState() {
283
- return { cookieSecret: randomBytes(32).toString("base64") };
322
+ return {
323
+ cookieSecret: randomBytes(32).toString("base64"),
324
+ sessionEpoch: 0
325
+ };
284
326
  }
285
327
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
286
328
  function loadState(home = homedir()) {
@@ -288,7 +330,15 @@ function loadState(home = homedir()) {
288
330
  try {
289
331
  const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
290
332
  const parsed = JSON.parse(raw);
291
- if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) return parsed;
333
+ if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
334
+ const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
335
+ const base = {
336
+ cookieSecret: parsed.cookieSecret,
337
+ sessionEpoch
338
+ };
339
+ if (parsed.password !== void 0) base.password = parsed.password;
340
+ return base;
341
+ }
292
342
  return defaultState();
293
343
  } catch {
294
344
  return defaultState();
@@ -309,24 +359,54 @@ function saveState(state, home = homedir()) {
309
359
  //#endregion
310
360
  //#region src/gateway.ts
311
361
  /**
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.
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.
315
364
  *
316
- * Security model:
365
+ * Security model (post-QVD / session-base):
317
366
  * - 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.
367
+ * `X-Forwarded-For`). Classification alone grants nothing: by default every
368
+ * source — loopback, LAN, internet — must present a valid gateway session.
369
+ * `lanPasswordless` (an explicit opt-in, false by default) is the one way a
370
+ * LAN/loopback source skips the gateway login, and it is only ever allowed
371
+ * against a session-capable dsh base (enforced by the plugin, which owns the
372
+ * fail-closed guard).
373
+ * - The gateway never forwards its own management surface (`/lan-gateway/*`)
374
+ * or its login/logout paths; those are handled locally or refused.
320
375
  * - 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).
376
+ * 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.
381
+ * - Against a session-capable dsh base the Host/Origin rewrite alone would
382
+ * still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
383
+ * authority-bound session cookie). The gateway therefore relays one shared
384
+ * upstream session acquired through the launch-token exchange and replays it
385
+ * 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.
324
389
  *
325
390
  * @module @riceawa/dsh-lan-gateway/gateway
326
391
  */
327
392
  const DEFAULT_BODY_LIMIT_BYTES = 65536;
328
393
  const LOGIN_ATTEMPTS_LIMIT = 5;
329
394
  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);
409
+ }
330
410
  /**
331
411
  * The running gateway: owns the HTTP server and the auth state needed per
332
412
  * request. Created by the plugin on enable; torn down by the plugin on
@@ -338,6 +418,8 @@ var LanGateway = class {
338
418
  loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
339
419
  state;
340
420
  disposed = false;
421
+ /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
422
+ activeDuplexes = /* @__PURE__ */ new Set();
341
423
  constructor(config, state) {
342
424
  this.config = config;
343
425
  this.state = state;
@@ -352,8 +434,9 @@ var LanGateway = class {
352
434
  this.handleUpgrade(req, socket, head);
353
435
  });
354
436
  }
355
- /** Replace the in-memory state (e.g. after a password change). */
437
+ /** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
356
438
  setState(state) {
439
+ if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
357
440
  this.state = state;
358
441
  }
359
442
  /** Start listening; rejects if the port is already in use. */
@@ -372,17 +455,28 @@ var LanGateway = class {
372
455
  this.server.listen(this.config.gatewayPort, "0.0.0.0");
373
456
  });
374
457
  }
375
- /** Close the server and stop accepting connections. */
458
+ /** Close the server, drop upgraded sockets, and stop accepting connections. */
376
459
  async close() {
377
460
  if (this.disposed) return;
378
461
  this.disposed = true;
462
+ this.destroyActiveDuplexes();
379
463
  return new Promise((resolve) => {
380
464
  this.server.close(() => resolve());
381
465
  this.server.closeAllConnections();
382
466
  });
383
467
  }
384
- sourceClass(req) {
385
- return classifySource(req.socket.remoteAddress, this.config.lanCidrs);
468
+ destroyActiveDuplexes() {
469
+ for (const socket of this.activeDuplexes) socket.destroy();
470
+ this.activeDuplexes.clear();
471
+ }
472
+ trackDuplex(socket) {
473
+ this.activeDuplexes.add(socket);
474
+ socket.on("close", () => {
475
+ this.activeDuplexes.delete(socket);
476
+ });
477
+ }
478
+ sourceOf(req) {
479
+ return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
386
480
  }
387
481
  /** Parse the session cookie out of a Cookie header. */
388
482
  sessionCookie(req) {
@@ -393,10 +487,14 @@ var LanGateway = class {
393
487
  if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
394
488
  }
395
489
  }
396
- /** Whether a request carries a valid session for its source. */
490
+ /** Whether a request carries a session valid under the current epoch. */
397
491
  authorized(req) {
398
492
  const cookie = this.sessionCookie(req);
399
- return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now());
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");
400
498
  }
401
499
  serveUnauthorized(res, limited) {
402
500
  res.writeHead(302, {
@@ -414,47 +512,58 @@ var LanGateway = class {
414
512
  });
415
513
  res.end(renderLoginPage(opts));
416
514
  }
417
- /** HSTS when the listener is HTTPS (never sent on plain HTTP). */
515
+ /** HSTS when the listener itself is HTTPS (never sent on plain HTTP). */
418
516
  securityHeaders() {
419
517
  return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
420
518
  }
421
- /** Handle one HTTP request: auth gate → CSRF fence → forward. */
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
+ sessionSetCookie(value, maxAgeSeconds) {
536
+ const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
537
+ return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
538
+ }
539
+ /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
422
540
  async handleHttp(req, res) {
423
- const source = this.sourceClass(req);
424
541
  const url = req.url ?? "/";
425
- const pathname = url.split("?")[0] ?? "/";
542
+ const pathname = pathOf(url);
543
+ const source = this.sourceOf(req);
426
544
  if (pathname === "/__login") {
427
545
  this.handleLogin(req, res);
428
546
  return;
429
547
  }
430
- if (source === "internet" && this.config.authRequired) {
431
- if (!this.authorized(req)) {
432
- this.serveUnauthorized(res, false);
433
- return;
434
- }
548
+ if (pathname === "/__logout") {
549
+ this.handleLogout(req, res);
550
+ return;
435
551
  }
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
- }
552
+ if (isOwnedPath(pathname)) {
553
+ res.writeHead(403, this.securityHeaders());
554
+ res.end("forbidden");
555
+ return;
442
556
  }
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;
557
+ if (this.requiresLogin(source) && !this.authorized(req)) {
558
+ this.serveUnauthorized(res, false);
559
+ return;
560
+ }
561
+ if (!this.sameSiteAllowed(req, false)) {
562
+ res.writeHead(403, this.securityHeaders());
563
+ res.end("forbidden");
564
+ return;
457
565
  }
566
+ await this.relayHttp(req, res, url);
458
567
  }
459
568
  /** Handle the login GET form / POST submission. */
460
569
  handleLogin(req, res) {
@@ -464,7 +573,7 @@ var LanGateway = class {
464
573
  return;
465
574
  }
466
575
  if (req.method !== "POST") {
467
- res.writeHead(405, { allow: "GET, POST" });
576
+ res.writeHead(405, { allow: "GET, HEAD, POST" });
468
577
  res.end();
469
578
  return;
470
579
  }
@@ -485,24 +594,64 @@ var LanGateway = class {
485
594
  this.serveLoginError(res, "Incorrect password.");
486
595
  return;
487
596
  }
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" : "";
597
+ const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
598
+ const expiresMs = Date.now() + maxAgeSeconds * 1e3;
599
+ const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
491
600
  res.writeHead(302, {
492
601
  location: "/",
493
602
  ...this.securityHeaders(),
494
- "set-cookie": [`${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86400}${secure}`]
603
+ "set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
495
604
  });
496
605
  res.end();
497
606
  });
498
607
  }
499
- /** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
500
- forward(req, res, url) {
608
+ /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
609
+ handleLogout(req, res) {
610
+ if (req.method !== "POST") {
611
+ res.writeHead(405, { allow: "POST" });
612
+ res.end();
613
+ return;
614
+ }
615
+ if (!this.sameSiteAllowed(req, false)) {
616
+ res.writeHead(403, this.securityHeaders());
617
+ res.end("forbidden");
618
+ return;
619
+ }
620
+ res.writeHead(302, {
621
+ location: "/",
622
+ ...this.securityHeaders(),
623
+ "set-cookie": [this.sessionSetCookie("", 0)]
624
+ });
625
+ res.end();
626
+ }
627
+ /** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
628
+ upstreamHeaders(req, keepUpgrade) {
501
629
  const headers = { ...req.headers };
502
630
  headers.host = `127.0.0.1:${this.config.dshPort}`;
503
631
  if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
504
632
  delete headers["proxy-connection"];
505
- delete headers.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;
648
+ }
649
+ /** Forward an HTTP request to dsh, replaying the shared upstream session. */
650
+ async relayHttp(req, res, url) {
651
+ 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);
506
655
  const proxyReq = http.request({
507
656
  host: "127.0.0.1",
508
657
  port: this.config.dshPort,
@@ -510,6 +659,7 @@ var LanGateway = class {
510
659
  path: url,
511
660
  headers
512
661
  }, (proxyRes) => {
662
+ if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
513
663
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
514
664
  proxyRes.pipe(res);
515
665
  });
@@ -519,25 +669,40 @@ var LanGateway = class {
519
669
  });
520
670
  req.pipe(proxyReq);
521
671
  }
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");
672
+ /** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
673
+ async handleUpgrade(req, socket, head) {
674
+ const url = req.url ?? "/";
675
+ const pathname = pathOf(url);
676
+ const source = this.sourceOf(req);
677
+ const refuse = (status) => {
678
+ socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
526
679
  socket.destroy();
680
+ };
681
+ if (pathname === "/__login" || pathname === "/__logout" || isOwnedPath(pathname)) {
682
+ refuse(403);
527
683
  return;
528
684
  }
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"];
685
+ if (this.requiresLogin(source) && !this.authorized(req)) {
686
+ refuse(401);
687
+ return;
688
+ }
689
+ if (!this.sameSiteAllowed(req, true)) {
690
+ refuse(403);
691
+ return;
692
+ }
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);
533
697
  const proxyReq = http.request({
534
698
  host: "127.0.0.1",
535
699
  port: this.config.dshPort,
536
700
  method: "GET",
537
- path: req.url ?? "/",
701
+ path: url,
538
702
  headers
539
703
  });
540
704
  proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
705
+ this.trackDuplex(socket);
541
706
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
542
707
  const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
543
708
  socket.write(`${statusLine}${headerLines}\r\n`);
@@ -551,12 +716,6 @@ var LanGateway = class {
551
716
  proxyReq.end();
552
717
  }
553
718
  };
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
719
  //#endregion
561
720
  //#region src/x509.ts
562
721
  /**
@@ -908,7 +1067,7 @@ const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
908
1067
  function lanGatewayTool(control) {
909
1068
  return defineTool({
910
1069
  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.",
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.",
912
1071
  parameters: {
913
1072
  command: {
914
1073
  type: "string",
@@ -920,7 +1079,7 @@ function lanGatewayTool(control) {
920
1079
  "rotate-secret",
921
1080
  "tls-regenerate"
922
1081
  ],
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."
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."
924
1083
  },
925
1084
  password: {
926
1085
  type: "string",
@@ -963,18 +1122,160 @@ function lanGatewayTool(control) {
963
1122
  });
964
1123
  }
965
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
+ /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
1159
+ function maxAgeSeconds(setCookie) {
1160
+ const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
1161
+ return match === null ? void 0 : Number(match[1]);
1162
+ }
1163
+ /**
1164
+ * Perform the token exchange over loopback: GET the launch-token URL with the
1165
+ * upstream authority as Host, read the Set-Cookie the index route mints, and
1166
+ * return its `name=value` plus expiry (or undefined when the exchange failed
1167
+ * or no session cookie came back — e.g. an older base without browser
1168
+ * sessions).
1169
+ */
1170
+ function exchange(url, authority, port) {
1171
+ return new Promise((resolve) => {
1172
+ let target;
1173
+ try {
1174
+ target = new URL(url);
1175
+ } catch {
1176
+ resolve(void 0);
1177
+ return;
1178
+ }
1179
+ const request = http.request({
1180
+ host: "127.0.0.1",
1181
+ port,
1182
+ method: "GET",
1183
+ path: `${target.pathname}${target.search}`,
1184
+ headers: {
1185
+ host: authority,
1186
+ accept: "text/html"
1187
+ }
1188
+ }, (response) => {
1189
+ const setCookies = response.headers["set-cookie"];
1190
+ response.resume();
1191
+ if (setCookies === void 0) {
1192
+ resolve(void 0);
1193
+ return;
1194
+ }
1195
+ const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`));
1196
+ if (raw === void 0) {
1197
+ resolve(void 0);
1198
+ return;
1199
+ }
1200
+ const header = nameValueOnly(raw);
1201
+ const maxAge = maxAgeSeconds(raw);
1202
+ resolve({
1203
+ header,
1204
+ expiresAt: Date.now() + (maxAge ?? 0) * 1e3
1205
+ });
1206
+ });
1207
+ request.on("error", () => resolve(void 0));
1208
+ request.setTimeout(5e3, () => request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout")));
1209
+ request.end();
1210
+ });
1211
+ }
1212
+ /**
1213
+ * A cached {@link UpstreamSession} acquired through the launch-token exchange.
1214
+ * Acquisition runs at most once concurrently and the result is cached until it
1215
+ * nears expiry or {@link invalidate} is called.
1216
+ */
1217
+ var UpstreamSessionRelay = class {
1218
+ port;
1219
+ authority;
1220
+ authenticatedUrl;
1221
+ held;
1222
+ inflight;
1223
+ constructor(options) {
1224
+ this.port = options.port;
1225
+ this.authority = options.authority ?? `127.0.0.1:${options.port}`;
1226
+ this.authenticatedUrl = options.authenticatedUrl;
1227
+ }
1228
+ /** Whether the held session is still comfortably inside its lifetime. */
1229
+ fresh() {
1230
+ const held = this.held;
1231
+ if (held === void 0) return false;
1232
+ return Date.now() < held.expiresAt - 6e4;
1233
+ }
1234
+ peek() {
1235
+ return this.held?.header;
1236
+ }
1237
+ invalidate() {
1238
+ this.held = void 0;
1239
+ }
1240
+ async cookie() {
1241
+ if (this.fresh()) return this.held?.header;
1242
+ return this.acquire();
1243
+ }
1244
+ acquire() {
1245
+ if (this.inflight !== void 0) return this.inflight;
1246
+ const pending = this.doExchange().finally(() => {
1247
+ this.inflight = void 0;
1248
+ });
1249
+ this.inflight = pending;
1250
+ return pending;
1251
+ }
1252
+ async doExchange() {
1253
+ const url = this.authenticatedUrl();
1254
+ if (url === void 0) return void 0;
1255
+ const result = await exchange(url, this.authority, this.port);
1256
+ if (result !== void 0) this.held = result;
1257
+ return this.held?.header;
1258
+ }
1259
+ };
1260
+ //#endregion
966
1261
  //#region src/index.ts
967
1262
  /** Stable Cordis plugin name. */
968
1263
  const name = "dsh-lan-gateway";
969
1264
  /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
970
1265
  const inject = ["webServer", "tools"];
971
- /** The `lan-gateway` user-settings namespace, mirroring the composition schema. */
972
- const NS = settingsNamespace("lan-gateway");
1266
+ /**
1267
+ * The `lan-gateway` user-settings namespace, mirroring the composition schema.
1268
+ * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
1269
+ * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
1270
+ * shape works against both that release line and the older branded one.
1271
+ */
1272
+ const NS = "lan-gateway";
973
1273
  /** Optional config keys: an empty submitted value clears them back to the composition layer. */
974
1274
  const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
975
1275
  "dshTargetPort",
976
1276
  "tlsCertPath",
977
- "tlsKeyPath"
1277
+ "tlsKeyPath",
1278
+ "trustedTerminator"
978
1279
  ]);
979
1280
  /** Schemastery configuration validated by the Loader. */
980
1281
  const Config = z.object({
@@ -982,6 +1283,7 @@ const Config = z.object({
982
1283
  gatewayPort: z.natural().min(1).max(65535).default(3081),
983
1284
  dshTargetPort: z.natural().min(1).max(65535),
984
1285
  lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
1286
+ lanPasswordless: z.boolean().default(false),
985
1287
  authRequired: z.boolean().default(true),
986
1288
  cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
987
1289
  cookieName: z.string().default("dsh_gw_auth"),
@@ -990,8 +1292,22 @@ const Config = z.object({
990
1292
  tlsCertPath: z.string(),
991
1293
  tlsKeyPath: z.string(),
992
1294
  tlsSelfSignedHosts: z.string().default("localhost"),
993
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825)
1295
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1296
+ allowInsecurePlaintext: z.boolean().default(false),
1297
+ trustedTerminator: z.string()
994
1298
  });
1299
+ /**
1300
+ * The fail-closed problems that prevent a config from enabling the listener.
1301
+ * Returns every problem (not just the first) so the operator sees the full
1302
+ * migration at once. Exported for tests.
1303
+ */
1304
+ function gatewayStartProblems(cfg, facts) {
1305
+ const problems = [];
1306
+ 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`.");
1307
+ 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.");
1308
+ 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).");
1309
+ return problems;
1310
+ }
995
1311
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
996
1312
  function resolveTls(cfg) {
997
1313
  if (!cfg.tlsEnabled) return void 0;
@@ -1010,7 +1326,7 @@ function listenerKey(cfg) {
1010
1326
  cfg.gatewayPort,
1011
1327
  cfg.dshTargetPort,
1012
1328
  cfg.lanCidrs,
1013
- cfg.authRequired,
1329
+ cfg.lanPasswordless,
1014
1330
  cfg.cookieMaxAgeDays,
1015
1331
  cfg.cookieName,
1016
1332
  cfg.tlsEnabled,
@@ -1018,7 +1334,9 @@ function listenerKey(cfg) {
1018
1334
  cfg.tlsCertPath,
1019
1335
  cfg.tlsKeyPath,
1020
1336
  cfg.tlsSelfSignedHosts,
1021
- cfg.tlsCertMaxAgeDays
1337
+ cfg.tlsCertMaxAgeDays,
1338
+ cfg.allowInsecurePlaintext,
1339
+ cfg.trustedTerminator
1022
1340
  ]);
1023
1341
  }
1024
1342
  /** One-line TLS description for status output. */
@@ -1042,13 +1360,20 @@ function isLoopbackHost(hostname) {
1042
1360
  const parts = hostname.split(".");
1043
1361
  return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1044
1362
  }
1363
+ const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
1364
+ "GET",
1365
+ "HEAD",
1366
+ "OPTIONS"
1367
+ ]);
1045
1368
  /**
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.
1369
+ * Same-origin loopback fence for the native `/lan-gateway/config` route. The
1370
+ * gateway refuses to relay this prefix, so the only way in is the native
1371
+ * loopback listener itself (a genuine local user, or a local process that could
1372
+ * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
1373
+ * cross-site fetches are refused, an Origin must match the Host the browser
1374
+ * used, and a state-changing method must carry that Origin. Exported for tests.
1050
1375
  */
1051
- function isTrustedRequest(req) {
1376
+ function isTrustedConfigRequest(req) {
1052
1377
  const host = req.headers?.host;
1053
1378
  if (typeof host !== "string" || host === "") return false;
1054
1379
  let hostUrl;
@@ -1060,12 +1385,10 @@ function isTrustedRequest(req) {
1060
1385
  if (!isLoopbackHost(hostUrl.hostname)) return false;
1061
1386
  if (req.headers?.["sec-fetch-site"] === "cross-site") return false;
1062
1387
  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
- }
1388
+ if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
1389
+ const method = req.method ?? "GET";
1390
+ if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
1391
+ return true;
1069
1392
  }
1070
1393
  function apply(ctx, config) {
1071
1394
  let state = loadState();
@@ -1073,6 +1396,10 @@ function apply(ctx, config) {
1073
1396
  let startedWith;
1074
1397
  let lastError;
1075
1398
  let manualOverride;
1399
+ /** Whether the base enforces browser-session auth; set once `connection` is seen. */
1400
+ let upstreamSessionAvailable = false;
1401
+ /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
1402
+ let makeRelay;
1076
1403
  /** The authoritative config: settings section when attached, else composition. */
1077
1404
  let configSource = () => config;
1078
1405
  /** Serializes listener start/stop/restart so settings changes cannot race. */
@@ -1080,22 +1407,27 @@ function apply(ctx, config) {
1080
1407
  const effective = () => configSource();
1081
1408
  const startGateway = async (cfg) => {
1082
1409
  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.");
1410
+ const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
1411
+ if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
1412
+ if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
1084
1413
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1085
1414
  const tls = resolveTls(cfg);
1415
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1086
1416
  const next = new LanGateway({
1087
1417
  gatewayPort: cfg.gatewayPort,
1088
1418
  dshPort,
1089
1419
  lanCidrs: cfg.lanCidrs,
1090
- authRequired: cfg.authRequired,
1420
+ lanPasswordless: cfg.lanPasswordless,
1091
1421
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
1092
1422
  cookieName: cfg.cookieName,
1093
- ...tls !== void 0 ? { tls } : {}
1423
+ secureCookies: encryptedIngress,
1424
+ ...tls !== void 0 ? { tls } : {},
1425
+ ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
1094
1426
  }, state);
1095
1427
  await next.listen();
1096
1428
  gateway = next;
1097
1429
  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}`);
1430
+ 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
1431
  };
1100
1432
  const stopGateway = async () => {
1101
1433
  const current = gateway;
@@ -1141,13 +1473,21 @@ function apply(ctx, config) {
1141
1473
  });
1142
1474
  syncGateway("settings attach");
1143
1475
  });
1476
+ ctx.inject(["connection"], (ccx) => {
1477
+ upstreamSessionAvailable = true;
1478
+ makeRelay = (dshPort) => new UpstreamSessionRelay({
1479
+ port: dshPort,
1480
+ authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`)
1481
+ });
1482
+ syncGateway("connection attach");
1483
+ });
1144
1484
  const configRouteHandler = async (req, res) => {
1145
1485
  const send = (status, body) => {
1146
1486
  res.writeHead(status, { "content-type": "application/json" });
1147
1487
  res.end(JSON.stringify(body));
1148
1488
  };
1149
- if (!isTrustedRequest(req)) {
1150
- send(403, { error: "request refused: this route answers loopback-origin requests only" });
1489
+ if (!isTrustedConfigRequest(req)) {
1490
+ send(403, { error: "request refused: this route answers same-origin loopback requests only" });
1151
1491
  return;
1152
1492
  }
1153
1493
  if (req.method === "GET") {
@@ -1157,6 +1497,7 @@ function apply(ctx, config) {
1157
1497
  running: gateway !== void 0,
1158
1498
  port: cfg.gatewayPort,
1159
1499
  tls: tlsStatusLine(cfg),
1500
+ upstreamSessionAvailable,
1160
1501
  lastError: lastError ?? null
1161
1502
  });
1162
1503
  return;
@@ -1189,6 +1530,12 @@ function apply(ctx, config) {
1189
1530
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
1190
1531
  return;
1191
1532
  }
1533
+ const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
1534
+ const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
1535
+ if (structural || candidate.enabled && problems.length > 0) {
1536
+ send(409, { error: `config cannot start: ${problems.join(" ")}` });
1537
+ return;
1538
+ }
1192
1539
  const section = {};
1193
1540
  for (const [key, value] of Object.entries(candidate)) {
1194
1541
  if (value === null || value === void 0) continue;
@@ -1204,6 +1551,7 @@ function apply(ctx, config) {
1204
1551
  running: gateway !== void 0,
1205
1552
  port: cfg.gatewayPort,
1206
1553
  tls: tlsStatusLine(cfg),
1554
+ upstreamSessionAvailable,
1207
1555
  lastError: lastError ?? null
1208
1556
  });
1209
1557
  } catch (error) {
@@ -1219,9 +1567,10 @@ function apply(ctx, config) {
1219
1567
  status() {
1220
1568
  const cfg = effective();
1221
1569
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1570
+ const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1222
1571
  return {
1223
1572
  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}` : "")
1573
+ 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
1574
  };
1226
1575
  },
1227
1576
  async enable() {
@@ -1243,29 +1592,46 @@ function apply(ctx, config) {
1243
1592
  message: "Gateway disabled."
1244
1593
  };
1245
1594
  },
1246
- setPassword(password) {
1595
+ async setPassword(password) {
1247
1596
  if (password !== void 0 && password.length > 0 && password.length < 8) return {
1248
1597
  ok: false,
1249
1598
  message: "Password must be at least 8 characters."
1250
1599
  };
1251
1600
  const setting = password !== void 0 && password.length > 0;
1601
+ const previous = state;
1252
1602
  state = setPassword(state, setting ? password : void 0);
1253
1603
  saveState(state);
1254
1604
  gateway?.setState(state);
1605
+ if (!setting) {
1606
+ manualOverride = false;
1607
+ if (gateway !== void 0) {
1608
+ await stopGateway();
1609
+ lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
1610
+ syncGateway("password cleared");
1611
+ }
1612
+ return {
1613
+ ok: true,
1614
+ message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
1615
+ };
1616
+ }
1617
+ previous === void 0 ? syncGateway("password set") : Promise.resolve();
1255
1618
  return {
1256
1619
  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)."
1620
+ message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
1258
1621
  };
1259
1622
  },
1260
1623
  rotateSecret() {
1261
- const next = { cookieSecret: randomBytes(32).toString("base64") };
1624
+ const next = {
1625
+ cookieSecret: randomBytes(32).toString("base64"),
1626
+ sessionEpoch: state.sessionEpoch + 1
1627
+ };
1262
1628
  if (state.password !== void 0) next.password = state.password;
1263
1629
  state = next;
1264
1630
  saveState(state);
1265
1631
  gateway?.setState(state);
1266
1632
  return {
1267
1633
  ok: true,
1268
- message: "Session secret rotated. All existing login cookies are now invalid."
1634
+ message: "Session secret rotated and epoch advanced. All existing login cookies and live WebSockets are now invalid."
1269
1635
  };
1270
1636
  },
1271
1637
  async regenerateTls() {
@@ -1301,10 +1667,10 @@ function apply(ctx, config) {
1301
1667
  }
1302
1668
  }
1303
1669
  }));
1304
- ctx.effect(async () => {
1305
- await syncGateway("boot");
1670
+ ctx.effect(() => {
1671
+ syncGateway("boot");
1306
1672
  return stopGateway;
1307
1673
  }, "dsh-lan-gateway: listener lifecycle");
1308
1674
  }
1309
1675
  //#endregion
1310
- export { Config, apply, inject, name };
1676
+ export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name };