@riceawa/dsh-lan-gateway 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
1
+ import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, scryptSync, timingSafeEqual } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import http from "node:http";
4
4
  import https from "node:https";
@@ -73,9 +73,19 @@ function classifySource(remoteAddress, lanCidrs = DEFAULT_LAN_CIDR_STRINGS) {
73
73
  return "internet";
74
74
  }
75
75
  if (address === "::1") return "loopback";
76
- if (address.toLowerCase().startsWith("fe80:")) return "lan";
76
+ if (inIpv6LinkLocal(address)) return "lan";
77
77
  return "internet";
78
78
  }
79
+ /**
80
+ * Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
81
+ * `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
82
+ * test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
83
+ */
84
+ function inIpv6LinkLocal(address) {
85
+ const match = /^([0-9a-fA-F]{1,4}):/.exec(address);
86
+ if (match === null) return false;
87
+ return (Number.parseInt(match[1], 16) & 65472) === 65152;
88
+ }
79
89
  /** Encode a byte buffer as URL-safe base64 without padding. */
80
90
  function base64url(input) {
81
91
  return input.toString("base64url");
@@ -88,24 +98,29 @@ function base64url(input) {
88
98
  * cookie whose epoch no longer matches the live state is rejected by
89
99
  * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
90
100
  * do not participate in revocation.
101
+ * @param sid - optional per-session id (see {@link SessionClaims.sid}).
91
102
  * @returns a `payload.signature` string suitable for the cookie value.
92
103
  */
93
- function signCookie(secret, expiresMs, epoch = 0) {
94
- const payload = base64url(Buffer.from(JSON.stringify({
104
+ function signCookie(secret, expiresMs, epoch = 0, sid) {
105
+ const claims = sid === void 0 ? {
95
106
  exp: expiresMs,
96
107
  epoch
97
- })));
108
+ } : {
109
+ exp: expiresMs,
110
+ epoch,
111
+ sid
112
+ };
113
+ const payload = base64url(Buffer.from(JSON.stringify(claims)));
98
114
  return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
99
115
  }
100
116
  /**
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.
117
+ * Verify a cookie's signature, expiry and epoch.
118
+ * @returns the claims it carries, or undefined when it is not a valid session.
104
119
  */
105
- function verifyCookie(secret, value, now, epoch = 0) {
106
- if (value === void 0) return false;
120
+ function verifySession(secret, value, now, epoch = 0) {
121
+ if (value === void 0) return void 0;
107
122
  const dot = value.indexOf(".");
108
- if (dot === -1) return false;
123
+ if (dot === -1) return void 0;
109
124
  const payload = value.slice(0, dot);
110
125
  const sig = value.slice(dot + 1);
111
126
  const expected = createHmac("sha256", secret).update(payload).digest();
@@ -113,16 +128,22 @@ function verifyCookie(secret, value, now, epoch = 0) {
113
128
  try {
114
129
  actual = Buffer.from(sig, "base64url");
115
130
  } catch {
116
- return false;
131
+ return;
117
132
  }
118
- if (expected.length !== actual.length) return false;
119
- if (!timingSafeEqual(expected, actual)) return false;
133
+ if (expected.length !== actual.length) return void 0;
134
+ if (!timingSafeEqual(expected, actual)) return void 0;
120
135
  try {
121
136
  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
122
- if (typeof decoded.exp !== "number" || decoded.exp <= now) return false;
123
- return (typeof decoded.epoch === "number" ? decoded.epoch : 0) === epoch;
137
+ if (typeof decoded.exp !== "number" || decoded.exp <= now) return void 0;
138
+ const cookieEpoch = typeof decoded.epoch === "number" ? decoded.epoch : 0;
139
+ if (cookieEpoch !== epoch) return void 0;
140
+ return {
141
+ exp: decoded.exp,
142
+ epoch: cookieEpoch,
143
+ ...typeof decoded.sid === "string" ? { sid: decoded.sid } : {}
144
+ };
124
145
  } catch {
125
- return false;
146
+ return;
126
147
  }
127
148
  }
128
149
  /**
@@ -143,10 +164,18 @@ function originMatchesHost(origin, host) {
143
164
  }
144
165
  }
145
166
  /** A token bucket limiter keyed by source address. */
146
- var RateLimiter = class {
167
+ var RateLimiter = class RateLimiter {
147
168
  maxTokens;
148
169
  windowMs;
170
+ /**
171
+ * Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
172
+ * `prune` runs, and a spray from many distinct addresses inside one window
173
+ * outruns it, so the map also sheds its soonest-expiring entries past this.
174
+ */
175
+ static MAX_BUCKETS = 1e4;
149
176
  buckets = /* @__PURE__ */ new Map();
177
+ /** Epoch millis at which the next opportunistic sweep is due. */
178
+ nextPruneAt = 0;
150
179
  constructor(maxTokens, windowMs) {
151
180
  this.maxTokens = maxTokens;
152
181
  this.windowMs = windowMs;
@@ -158,24 +187,33 @@ var RateLimiter = class {
158
187
  */
159
188
  allow(key) {
160
189
  const now = Date.now();
161
- const bucket = this.buckets.get(key);
162
- if (bucket === void 0 || bucket.resetAt <= now) {
163
- this.buckets.set(key, {
164
- tokens: this.maxTokens - 1,
165
- resetAt: now + this.windowMs
166
- });
167
- return true;
190
+ if (now >= this.nextPruneAt) {
191
+ this.prune(now);
192
+ this.nextPruneAt = now + this.windowMs;
168
193
  }
169
- if (bucket.tokens > 0) {
170
- bucket.tokens -= 1;
194
+ const existing = this.buckets.get(key);
195
+ if (existing !== void 0 && existing.resetAt > now) {
196
+ if (existing.tokens <= 0) return false;
197
+ existing.tokens -= 1;
171
198
  return true;
172
199
  }
173
- return false;
200
+ if (existing === void 0 && this.buckets.size >= RateLimiter.MAX_BUCKETS) this.evictSoonestToExpire();
201
+ this.buckets.set(key, {
202
+ tokens: this.maxTokens - 1,
203
+ resetAt: now + this.windowMs
204
+ });
205
+ return true;
174
206
  }
175
- /** Drop expired buckets to bound memory. */
207
+ /** Drop expired buckets to bound memory. Called from {@link allow}. */
176
208
  prune(now = Date.now()) {
177
209
  for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
178
210
  }
211
+ /** Trim back to 90% of the ceiling, oldest expiry first. */
212
+ evictSoonestToExpire() {
213
+ const target = Math.floor(RateLimiter.MAX_BUCKETS * .9);
214
+ const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
215
+ for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) this.buckets.delete(key);
216
+ }
179
217
  };
180
218
  //#endregion
181
219
  //#region src/login.ts
@@ -282,13 +320,27 @@ function stateDir(home = homedir()) {
282
320
  return join(home, ".dsh", "lan-gateway");
283
321
  }
284
322
  const STATE_FILENAME = "state.json";
285
- /** Whether a password is present and passes scrypt verification. */
286
- function verifyPassword(state, password) {
323
+ /** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
324
+ function deriveKey(password, salt, keylen) {
325
+ return new Promise((resolve, reject) => {
326
+ scrypt(password, salt, keylen, (error, derived) => {
327
+ if (error !== null) reject(error);
328
+ else resolve(derived);
329
+ });
330
+ });
331
+ }
332
+ /**
333
+ * Whether a password is present and passes scrypt verification. Asynchronous
334
+ * on purpose: `scryptSync` occupies the event loop for tens of milliseconds
335
+ * per attempt, and that loop is shared with the dsh process the gateway is
336
+ * forwarding to.
337
+ */
338
+ async function verifyPassword(state, password) {
287
339
  if (state.password === void 0) return false;
288
340
  const { hash, salt } = state.password;
289
341
  try {
290
342
  const expected = Buffer.from(hash, "hex");
291
- const actual = scryptSync(password, Buffer.from(salt, "hex"), expected.length);
343
+ const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
292
344
  return expected.length === actual.length && timingSafeEqual(expected, actual);
293
345
  } catch {
294
346
  return false;
@@ -301,13 +353,10 @@ function verifyPassword(state, password) {
301
353
  */
302
354
  function setPassword(state, password) {
303
355
  const base = {
304
- ...state,
356
+ cookieSecret: state.cookieSecret,
305
357
  sessionEpoch: state.sessionEpoch + 1
306
358
  };
307
- if (password === void 0) return {
308
- cookieSecret: base.cookieSecret,
309
- sessionEpoch: base.sessionEpoch
310
- };
359
+ if (password === void 0) return base;
311
360
  const salt = randomBytes(16);
312
361
  const hash = scryptSync(password, salt, 64);
313
362
  return {
@@ -318,12 +367,45 @@ function setPassword(state, password) {
318
367
  }
319
368
  };
320
369
  }
370
+ /**
371
+ * Record a session id as revoked.
372
+ * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
373
+ * rejected on its own account, so the entry is no longer needed; dropping
374
+ * expired entries here is what keeps the list bounded.
375
+ */
376
+ function revokeSession(state, sid, expiresMs) {
377
+ const now = Date.now();
378
+ const revoked = {};
379
+ for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
380
+ revoked[sid] = expiresMs;
381
+ return {
382
+ ...state,
383
+ revokedSessions: revoked
384
+ };
385
+ }
386
+ /** Whether `sid` names a session that has been signed out. */
387
+ function isSessionRevoked(state, sid) {
388
+ if (sid === void 0) return false;
389
+ return Object.hasOwn(state.revokedSessions ?? {}, sid);
390
+ }
321
391
  function defaultState() {
322
392
  return {
323
393
  cookieSecret: randomBytes(32).toString("base64"),
324
394
  sessionEpoch: 0
325
395
  };
326
396
  }
397
+ /** Keep the still-live entries of a persisted revocation list, or undefined. */
398
+ function parseRevokedSessions(raw) {
399
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
400
+ const now = Date.now();
401
+ const out = {};
402
+ let anyLive = false;
403
+ for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
404
+ out[sid] = exp;
405
+ anyLive = true;
406
+ }
407
+ return anyLive ? out : void 0;
408
+ }
327
409
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
328
410
  function loadState(home = homedir()) {
329
411
  const dir = stateDir(home);
@@ -337,6 +419,8 @@ function loadState(home = homedir()) {
337
419
  sessionEpoch
338
420
  };
339
421
  if (parsed.password !== void 0) base.password = parsed.password;
422
+ const revoked = parseRevokedSessions(parsed.revokedSessions);
423
+ if (revoked !== void 0) base.revokedSessions = revoked;
340
424
  return base;
341
425
  }
342
426
  return defaultState();
@@ -359,8 +443,9 @@ function saveState(state, home = homedir()) {
359
443
  //#endregion
360
444
  //#region src/gateway.ts
361
445
  /**
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.
446
+ * The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
447
+ * address (dual-stack, so IPv6 clients reach it too) that forwards every
448
+ * request to the loopback dsh web server.
364
449
  *
365
450
  * Security model (post-QVD / session-base):
366
451
  * - Source is classified from `socket.remoteAddress` only (never
@@ -383,9 +468,10 @@ function saveState(state, home = homedir()) {
383
468
  * authority-bound session cookie). The gateway therefore relays one shared
384
469
  * upstream session acquired through the launch-token exchange and replays it
385
470
  * 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.
471
+ * - Sessions are revocable two ways. Each carries a random id, so signing out
472
+ * retires exactly that session and the WebSockets it opened; and each
473
+ * carries a revocation epoch, so a password change or secret rotation kills
474
+ * every session at once — cookie, socket, and all.
389
475
  *
390
476
  * @module @riceawa/dsh-lan-gateway/gateway
391
477
  */
@@ -398,14 +484,89 @@ const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
398
484
  "HEAD",
399
485
  "OPTIONS"
400
486
  ]);
487
+ /** The upstream browser-session cookie name prefix; the relay owns this namespace. */
488
+ const UPSTREAM_COOKIE_PREFIX$1 = "dsh-auth-";
489
+ /**
490
+ * Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
491
+ * the non-standard proxy-connection.
492
+ */
493
+ const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
494
+ "connection",
495
+ "keep-alive",
496
+ "proxy-authenticate",
497
+ "proxy-authorization",
498
+ "proxy-connection",
499
+ "te",
500
+ "trailer",
501
+ "transfer-encoding",
502
+ "upgrade"
503
+ ]);
504
+ /**
505
+ * Headers by which a client asserts where a request came from. The gateway
506
+ * classifies on `socket.remoteAddress` and never reads these, so relaying a
507
+ * caller's own values only hands the next hop a forgeable claim.
508
+ */
509
+ const FORWARDING_HEADERS = [
510
+ "forwarded",
511
+ "x-forwarded-for",
512
+ "x-forwarded-host",
513
+ "x-forwarded-port",
514
+ "x-forwarded-proto",
515
+ "x-real-ip"
516
+ ];
517
+ /** Whether a Cookie fragment names the upstream session cookie. */
518
+ function isUpstreamSessionPair(pair) {
519
+ return pair.startsWith(UPSTREAM_COOKIE_PREFIX$1);
520
+ }
521
+ /**
522
+ * Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
523
+ * (possibly '').
524
+ *
525
+ * `attachUpstreamSession` appends the relay's session to the client's own
526
+ * cookie, and upstream reads the FIRST name match. A client that holds any
527
+ * `dsh-auth-<hash>` — typically one minted before dsh's signing secret was
528
+ * reset, so still present but no longer verifying — would therefore shadow the
529
+ * relay's session on every request. That draws a 401, the gateway reads the
530
+ * 401 as "upstream revoked our session" and discards it, the next request
531
+ * re-acquires, and the client's stale cookie shadows that one too: a loop that
532
+ * never converges. Stripping the namespace makes the relay's copy the only one.
533
+ */
534
+ function withoutUpstreamSessionPairs(cookie) {
535
+ return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamSessionPair(pair)).join("; ");
536
+ }
401
537
  /** Prefixes the gateway owns and must never relay to dsh. */
402
538
  function isOwnedPath(pathname) {
403
539
  return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
404
540
  }
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);
541
+ /**
542
+ * The pathname a request is routed by: the one dsh's router resolves it to
543
+ * (WHATWG URL parsing, which strips the query and collapses dot segments),
544
+ * with trailing slashes then removed for the gateway's own surface tests.
545
+ *
546
+ * The decision paths below (owned prefix, login, logout) must use this rather
547
+ * than the raw request target. dsh normalizes before matching, so a raw-string
548
+ * test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
549
+ * path by string prefix, stays in the relay, and lands on the plugin's own
550
+ * config route once Host has been rewritten to loopback. Forwarding still
551
+ * relays the raw target: dsh applies the same normalization itself.
552
+ *
553
+ * WHATWG parsing does not drop a trailing slash, and neither does dsh's
554
+ * router, so `/__login/` is not the login page to either of them. The gateway
555
+ * recognizes its own surfaces there anyway: `/__logout/` must still sign out,
556
+ * and `/lan-gateway/config/` must be refused rather than relayed into dsh's
557
+ * single-page fallback. Blocking a trailing-slash spelling of an owned prefix
558
+ * errs toward refusing, which costs nothing — no upstream route lives under it.
559
+ */
560
+ function pathOf$1(url) {
561
+ try {
562
+ return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
563
+ } catch {
564
+ return url;
565
+ }
566
+ }
567
+ /** A fresh per-session id: 128 random bits, URL-safe. */
568
+ function newSessionId() {
569
+ return randomBytes(16).toString("base64url");
409
570
  }
410
571
  /**
411
572
  * The running gateway: owns the HTTP server and the auth state needed per
@@ -418,8 +579,14 @@ var LanGateway = class {
418
579
  loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
419
580
  state;
420
581
  disposed = false;
421
- /** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
422
- activeDuplexes = /* @__PURE__ */ new Set();
582
+ /**
583
+ * Established WebSockets (upgraded client sockets), each keyed by the session
584
+ * that opened it. A socket outlives the request that authenticated it, so it
585
+ * has to be closable by session: on an epoch bump every socket dies, and on
586
+ * sign-out only that session's. The value is undefined for a cookie minted
587
+ * before per-session ids existed, which only a wholesale revocation reaches.
588
+ */
589
+ activeDuplexes = /* @__PURE__ */ new Map();
423
590
  constructor(config, state) {
424
591
  this.config = config;
425
592
  this.state = state;
@@ -439,7 +606,14 @@ var LanGateway = class {
439
606
  if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
440
607
  this.state = state;
441
608
  }
442
- /** Start listening; rejects if the port is already in use. */
609
+ /**
610
+ * Start listening on the configured port. The listener is dual-stack: with
611
+ * no host given, node binds the unspecified IPv6 address `::` — which also
612
+ * accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
613
+ * unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
614
+ * not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
615
+ * unable to reach a gateway that classifies them.
616
+ */
443
617
  async listen() {
444
618
  return new Promise((resolve, reject) => {
445
619
  const onError = (err) => {
@@ -452,9 +626,15 @@ var LanGateway = class {
452
626
  };
453
627
  this.server.once("error", onError);
454
628
  this.server.once("listening", onListening);
455
- this.server.listen(this.config.gatewayPort, "0.0.0.0");
629
+ this.server.listen(this.config.gatewayPort);
456
630
  });
457
631
  }
632
+ /** The address actually bound, for logs and status (never a claim about it). */
633
+ boundAddress() {
634
+ const address = this.server.address();
635
+ if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
636
+ return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
637
+ }
458
638
  /** Close the server, drop upgraded sockets, and stop accepting connections. */
459
639
  async close() {
460
640
  if (this.disposed) return;
@@ -466,11 +646,19 @@ var LanGateway = class {
466
646
  });
467
647
  }
468
648
  destroyActiveDuplexes() {
469
- for (const socket of this.activeDuplexes) socket.destroy();
649
+ for (const socket of this.activeDuplexes.keys()) socket.destroy();
470
650
  this.activeDuplexes.clear();
471
651
  }
472
- trackDuplex(socket) {
473
- this.activeDuplexes.add(socket);
652
+ /** Close the sockets one session opened, so signing out ends its live streams too. */
653
+ destroyDuplexesFor(sid) {
654
+ for (const [socket, owner] of this.activeDuplexes) {
655
+ if (owner !== sid) continue;
656
+ this.activeDuplexes.delete(socket);
657
+ socket.destroy();
658
+ }
659
+ }
660
+ trackDuplex(socket, sid) {
661
+ this.activeDuplexes.set(socket, sid);
474
662
  socket.on("close", () => {
475
663
  this.activeDuplexes.delete(socket);
476
664
  });
@@ -487,10 +675,21 @@ var LanGateway = class {
487
675
  if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
488
676
  }
489
677
  }
678
+ /**
679
+ * The session a request carries, or undefined when it presents none, presents
680
+ * one that no longer verifies under the current epoch, or presents one whose
681
+ * id has been signed out.
682
+ */
683
+ session(req) {
684
+ const cookie = this.sessionCookie(req);
685
+ if (cookie === void 0) return void 0;
686
+ const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
687
+ if (claims === void 0) return void 0;
688
+ return isSessionRevoked(this.state, claims.sid) ? void 0 : claims;
689
+ }
490
690
  /** Whether a request carries a session valid under the current epoch. */
491
691
  authorized(req) {
492
- const cookie = this.sessionCookie(req);
493
- return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
692
+ return this.session(req) !== void 0;
494
693
  }
495
694
  /** Whether this source must present a gateway session (default: everyone). */
496
695
  requiresLogin(source) {
@@ -539,7 +738,7 @@ var LanGateway = class {
539
738
  /** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
540
739
  async handleHttp(req, res) {
541
740
  const url = req.url ?? "/";
542
- const pathname = pathOf(url);
741
+ const pathname = pathOf$1(url);
543
742
  const source = this.sourceOf(req);
544
743
  if (pathname === "/__login") {
545
744
  this.handleLogin(req, res);
@@ -582,7 +781,7 @@ var LanGateway = class {
582
781
  this.serveLoginError(res, "Too many attempts — please wait a minute.");
583
782
  return;
584
783
  }
585
- readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
784
+ readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
586
785
  if (body === void 0) return;
587
786
  let password;
588
787
  try {
@@ -590,22 +789,36 @@ var LanGateway = class {
590
789
  } catch {
591
790
  password = void 0;
592
791
  }
593
- if (password === void 0 || !verifyPassword(this.state, password)) {
792
+ if (!(password !== void 0 && await verifyPassword(this.state, password))) {
594
793
  this.serveLoginError(res, "Incorrect password.");
595
794
  return;
596
795
  }
597
796
  const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
598
797
  const expiresMs = Date.now() + maxAgeSeconds * 1e3;
599
- const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
798
+ const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch, newSessionId());
600
799
  res.writeHead(302, {
601
800
  location: "/",
602
801
  ...this.securityHeaders(),
603
802
  "set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
604
803
  });
605
804
  res.end();
805
+ }).catch(() => {
806
+ if (!res.headersSent) {
807
+ res.writeHead(500, this.securityHeaders());
808
+ res.end("login failed");
809
+ }
606
810
  });
607
811
  }
608
- /** POST /__logout: sign an immediately-expired cookie and bounce to / . */
812
+ /**
813
+ * POST /__logout: revoke this session and clear the cookie.
814
+ *
815
+ * The session is stateless, so clearing the cookie only stops the browser
816
+ * that ran the sign-out; a copy of the same value held anywhere else would
817
+ * keep working until it expired. Revoking the id in the cookie retires that
818
+ * one session for good, and leaves the account's other sessions — other
819
+ * devices, other browsers — alone. Bumping the session epoch here would be
820
+ * the blunter instrument: it signs out every session there is.
821
+ */
609
822
  handleLogout(req, res) {
610
823
  if (req.method !== "POST") {
611
824
  res.writeHead(405, { allow: "POST" });
@@ -617,6 +830,12 @@ var LanGateway = class {
617
830
  res.end("forbidden");
618
831
  return;
619
832
  }
833
+ const claims = this.session(req);
834
+ if (claims?.sid !== void 0) {
835
+ this.state = revokeSession(this.state, claims.sid, claims.exp);
836
+ this.config.onStateChange?.(this.state);
837
+ this.destroyDuplexesFor(claims.sid);
838
+ }
620
839
  res.writeHead(302, {
621
840
  location: "/",
622
841
  ...this.securityHeaders(),
@@ -634,6 +853,12 @@ var LanGateway = class {
634
853
  delete headers.connection;
635
854
  delete headers.upgrade;
636
855
  }
856
+ for (const name of FORWARDING_HEADERS) delete headers[name];
857
+ if (typeof headers.cookie === "string") {
858
+ const kept = withoutUpstreamSessionPairs(headers.cookie);
859
+ if (kept === "") delete headers.cookie;
860
+ else headers.cookie = kept;
861
+ }
637
862
  return headers;
638
863
  }
639
864
  /** Attach the shared upstream session cookie to the outbound headers, if any. */
@@ -646,6 +871,29 @@ var LanGateway = class {
646
871
  headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
647
872
  return true;
648
873
  }
874
+ /**
875
+ * The headers to send back to the client: hop-by-hop headers dropped, and
876
+ * the upstream session cookie withheld. Upstream's one cookie-minting route
877
+ * is the launch-token exchange at `/`, so a client that already holds a
878
+ * gateway session could otherwise post the token through the gateway and
879
+ * walk away with a durable upstream credential the relay exists to keep on
880
+ * this side. Cookies from other routes (plugins) still pass through.
881
+ */
882
+ downstreamHeaders(upstream) {
883
+ const headers = {};
884
+ for (const [key, value] of Object.entries(upstream)) {
885
+ if (value === void 0) continue;
886
+ const lower = key.toLowerCase();
887
+ if (HOP_BY_HOP_HEADERS.has(lower)) continue;
888
+ if (lower === "set-cookie") {
889
+ const list = (Array.isArray(value) ? value : [value]).filter((entry) => !isUpstreamSessionPair(entry.trim()));
890
+ if (list.length > 0) headers[key] = list;
891
+ continue;
892
+ }
893
+ headers[key] = value;
894
+ }
895
+ return headers;
896
+ }
649
897
  /** Forward an HTTP request to dsh, replaying the shared upstream session. */
650
898
  async relayHttp(req, res, url) {
651
899
  const session = this.config.upstreamSession;
@@ -660,7 +908,7 @@ var LanGateway = class {
660
908
  headers
661
909
  }, (proxyRes) => {
662
910
  if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
663
- res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
911
+ res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers));
664
912
  proxyRes.pipe(res);
665
913
  });
666
914
  proxyReq.on("error", () => {
@@ -672,7 +920,7 @@ var LanGateway = class {
672
920
  /** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
673
921
  async handleUpgrade(req, socket, head) {
674
922
  const url = req.url ?? "/";
675
- const pathname = pathOf(url);
923
+ const pathname = pathOf$1(url);
676
924
  const source = this.sourceOf(req);
677
925
  const refuse = (status) => {
678
926
  socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
@@ -682,7 +930,8 @@ var LanGateway = class {
682
930
  refuse(403);
683
931
  return;
684
932
  }
685
- if (this.requiresLogin(source) && !this.authorized(req)) {
933
+ const claims = this.session(req);
934
+ if (this.requiresLogin(source) && claims === void 0) {
686
935
  refuse(401);
687
936
  return;
688
937
  }
@@ -702,7 +951,7 @@ var LanGateway = class {
702
951
  headers
703
952
  });
704
953
  proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
705
- this.trackDuplex(socket);
954
+ this.trackDuplex(socket, claims?.sid);
706
955
  const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
707
956
  const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
708
957
  socket.write(`${statusLine}${headerLines}\r\n`);
@@ -982,6 +1231,29 @@ function loadOrCreateSelfSigned(opts, home = homedir()) {
982
1231
  };
983
1232
  }
984
1233
  /**
1234
+ * Load the self-signed material a listener should serve: generate it on first
1235
+ * use, reuse the persisted pair otherwise, and replace a persisted certificate
1236
+ * whose validity has already lapsed.
1237
+ *
1238
+ * Nothing renews a self-signed certificate in place, and a browser refuses a
1239
+ * lapsed one outright, so without this a certificate that ran out would keep
1240
+ * being served until an operator happened to read the expiry date out of
1241
+ * `status` and act on it. Renewing mints a fresh key, so a client that had
1242
+ * trusted the old certificate has to trust the new one — but that is the case
1243
+ * either way, the old one having lapsed.
1244
+ */
1245
+ function loadOrRenewSelfSigned(opts, home = homedir()) {
1246
+ const { material, created } = loadOrCreateSelfSigned(opts, home);
1247
+ if (created || !isCertExpired(material.cert)) return {
1248
+ material,
1249
+ renewed: false
1250
+ };
1251
+ return {
1252
+ material: regenerateSelfSigned(opts, home),
1253
+ renewed: true
1254
+ };
1255
+ }
1256
+ /**
985
1257
  * Force-regenerate the self-signed certificate (new key + cert), replacing
986
1258
  * the persisted files. Used by `lan_gateway tls-regenerate`.
987
1259
  */
@@ -1041,6 +1313,15 @@ function loadCustomCert(certPath, keyPath) {
1041
1313
  function parseSelfSignedHosts(text) {
1042
1314
  return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
1043
1315
  }
1316
+ /**
1317
+ * Whether a PEM certificate's validity window has already closed. A lapsed
1318
+ * certificate is a hard failure browsers will not let the user proceed past,
1319
+ * so the listener replaces one rather than keep serving it.
1320
+ */
1321
+ function isCertExpired(certPem, now = Date.now()) {
1322
+ const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
1323
+ return Number.isFinite(expiresAt) && expiresAt <= now;
1324
+ }
1044
1325
  /** Describe a PEM certificate (throws on malformed input). */
1045
1326
  function describeCert(certPem) {
1046
1327
  const cert = new X509Certificate(certPem);
@@ -1155,6 +1436,18 @@ function nameValueOnly(setCookie) {
1155
1436
  const semi = setCookie.indexOf(";");
1156
1437
  return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
1157
1438
  }
1439
+ /**
1440
+ * The pathname of a URL, for logging. Never the whole URL: the authenticated
1441
+ * URL carries the launch token as a query parameter, and that token is a
1442
+ * bearer credential for the upstream harness.
1443
+ */
1444
+ function pathOf(url) {
1445
+ try {
1446
+ return new URL(url).pathname;
1447
+ } catch {
1448
+ return "<unparseable>";
1449
+ }
1450
+ }
1158
1451
  /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
1159
1452
  function cookieNameOf(setCookie) {
1160
1453
  const eq = setCookie.indexOf("=");
@@ -1183,12 +1476,13 @@ function maxAgeSeconds(setCookie) {
1183
1476
  * or no session cookie came back — e.g. an older base without browser
1184
1477
  * sessions).
1185
1478
  */
1186
- function exchange(url, authority, port) {
1479
+ function exchange(url, authority, port, log) {
1187
1480
  return new Promise((resolve) => {
1188
1481
  let target;
1189
1482
  try {
1190
1483
  target = new URL(url);
1191
1484
  } catch {
1485
+ log("exchange: authenticatedUrl is not parseable");
1192
1486
  resolve(void 0);
1193
1487
  return;
1194
1488
  }
@@ -1205,23 +1499,34 @@ function exchange(url, authority, port) {
1205
1499
  const setCookies = response.headers["set-cookie"];
1206
1500
  response.resume();
1207
1501
  if (setCookies === void 0) {
1502
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
1208
1503
  resolve(void 0);
1209
1504
  return;
1210
1505
  }
1211
- const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find(isUpstreamSessionCookie);
1506
+ const all = Array.isArray(setCookies) ? setCookies : [setCookies];
1507
+ const raw = all.find(isUpstreamSessionCookie);
1212
1508
  if (raw === void 0) {
1509
+ const names = all.map(cookieNameOf).filter((name) => name !== "");
1510
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
1213
1511
  resolve(void 0);
1214
1512
  return;
1215
1513
  }
1216
1514
  const header = nameValueOnly(raw);
1217
1515
  const maxAge = maxAgeSeconds(raw);
1516
+ log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
1218
1517
  resolve({
1219
1518
  header,
1220
1519
  expiresAt: Date.now() + (maxAge ?? 0) * 1e3
1221
1520
  });
1222
1521
  });
1223
- request.on("error", () => resolve(void 0));
1224
- request.setTimeout(5e3, () => request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout")));
1522
+ request.on("error", (error) => {
1523
+ log(`exchange error: ${error.message}`);
1524
+ resolve(void 0);
1525
+ });
1526
+ request.setTimeout(5e3, () => {
1527
+ log("exchange timeout (5s)");
1528
+ request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
1529
+ });
1225
1530
  request.end();
1226
1531
  });
1227
1532
  }
@@ -1234,12 +1539,14 @@ var UpstreamSessionRelay = class {
1234
1539
  port;
1235
1540
  authority;
1236
1541
  authenticatedUrl;
1542
+ log;
1237
1543
  held;
1238
1544
  inflight;
1239
1545
  constructor(options) {
1240
1546
  this.port = options.port;
1241
1547
  this.authority = options.authority ?? `127.0.0.1:${options.port}`;
1242
1548
  this.authenticatedUrl = options.authenticatedUrl;
1549
+ this.log = options.log ?? (() => {});
1243
1550
  }
1244
1551
  /** Whether the held session is still comfortably inside its lifetime. */
1245
1552
  fresh() {
@@ -1251,6 +1558,7 @@ var UpstreamSessionRelay = class {
1251
1558
  return this.held?.header;
1252
1559
  }
1253
1560
  invalidate() {
1561
+ if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
1254
1562
  this.held = void 0;
1255
1563
  }
1256
1564
  async cookie() {
@@ -1267,9 +1575,16 @@ var UpstreamSessionRelay = class {
1267
1575
  }
1268
1576
  async doExchange() {
1269
1577
  const url = this.authenticatedUrl();
1270
- if (url === void 0) return void 0;
1271
- const result = await exchange(url, this.authority, this.port);
1272
- if (result !== void 0) this.held = result;
1578
+ if (url === void 0) {
1579
+ this.log("authenticatedUrl() returned undefined; keeping current session");
1580
+ return this.held?.header;
1581
+ }
1582
+ this.log(`acquiring session from ${pathOf(url)}`);
1583
+ const result = await exchange(url, this.authority, this.port, this.log);
1584
+ if (result !== void 0) {
1585
+ this.held = result;
1586
+ this.log("session acquired and cached");
1587
+ } else this.log("exchange failed; keeping current session");
1273
1588
  return this.held?.header;
1274
1589
  }
1275
1590
  };
@@ -1310,7 +1625,8 @@ const Config = z.object({
1310
1625
  tlsSelfSignedHosts: z.string().default("localhost"),
1311
1626
  tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1312
1627
  allowInsecurePlaintext: z.boolean().default(false),
1313
- trustedTerminator: z.string()
1628
+ trustedTerminator: z.string(),
1629
+ secureCookies: z.boolean()
1314
1630
  });
1315
1631
  /**
1316
1632
  * The fail-closed problems that prevent a config from enabling the listener.
@@ -1324,17 +1640,34 @@ function gatewayStartProblems(cfg, facts) {
1324
1640
  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).");
1325
1641
  return problems;
1326
1642
  }
1643
+ /**
1644
+ * Resolve the effective `Secure` attribute for the session cookie: an explicit
1645
+ * `secureCookies` always wins; unset falls back to automatic — Secure when the
1646
+ * gateway terminates TLS itself or a trusted terminator is declared. The
1647
+ * override exists for a trusted proxy that authenticates users but speaks plain
1648
+ * HTTP to browsers: `encryptedIngress` is a fair proxy for "a proxy is in front"
1649
+ * but not for "the browser leg is encrypted", and a Secure cookie on a plain
1650
+ * HTTP origin is silently dropped, looping the login.
1651
+ *
1652
+ * Exported for tests.
1653
+ */
1654
+ function resolveSecureCookies(cfg) {
1655
+ if (typeof cfg.secureCookies === "boolean") return cfg.secureCookies;
1656
+ return cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1657
+ }
1327
1658
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
1328
1659
  function resolveTls(cfg) {
1329
1660
  if (!cfg.tlsEnabled) return void 0;
1330
- if (cfg.tlsMode === "custom") return loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? "");
1661
+ if (cfg.tlsMode === "custom") return {
1662
+ material: loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? ""),
1663
+ renewed: false
1664
+ };
1331
1665
  const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
1332
1666
  if (hosts.length === 0) throw new Error("tlsSelfSignedHosts must name at least one host (DNS name or IP)");
1333
- const { material } = loadOrCreateSelfSigned({
1667
+ return loadOrRenewSelfSigned({
1334
1668
  hosts,
1335
1669
  days: cfg.tlsCertMaxAgeDays
1336
1670
  });
1337
- return material;
1338
1671
  }
1339
1672
  /**
1340
1673
  * Config fields that require a listener restart when they change, plus whether
@@ -1360,6 +1693,7 @@ function listenerKey(cfg, relayAvailable) {
1360
1693
  cfg.tlsCertMaxAgeDays,
1361
1694
  cfg.allowInsecurePlaintext,
1362
1695
  cfg.trustedTerminator,
1696
+ cfg.secureCookies,
1363
1697
  relayAvailable
1364
1698
  ]);
1365
1699
  }
@@ -1435,8 +1769,10 @@ function apply(ctx, config) {
1435
1769
  if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
1436
1770
  if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
1437
1771
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1438
- const tls = resolveTls(cfg);
1772
+ const resolved = resolveTls(cfg);
1773
+ const tls = resolved?.material;
1439
1774
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1775
+ const secureCookies = resolveSecureCookies(cfg);
1440
1776
  const next = new LanGateway({
1441
1777
  gatewayPort: cfg.gatewayPort,
1442
1778
  dshPort,
@@ -1444,14 +1780,19 @@ function apply(ctx, config) {
1444
1780
  lanPasswordless: cfg.lanPasswordless,
1445
1781
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
1446
1782
  cookieName: cfg.cookieName,
1447
- secureCookies: encryptedIngress,
1783
+ secureCookies,
1448
1784
  ...tls !== void 0 ? { tls } : {},
1449
- ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
1785
+ ...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {},
1786
+ onStateChange: (updated) => {
1787
+ state = updated;
1788
+ saveState(state);
1789
+ }
1450
1790
  }, state);
1451
1791
  await next.listen();
1452
1792
  gateway = next;
1453
1793
  startedWith = listenerKey(cfg, makeRelay !== void 0);
1454
- 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]"}`);
1794
+ ctx.logger.info(`dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}${encryptedIngress ? "" : " (plaintext, explicit allowInsecurePlaintext)"}${makeRelay !== void 0 ? " [shared upstream session relay]" : " [no upstream session relay: base has no browser-session auth]"}`);
1795
+ if (resolved?.renewed === true) ctx.logger.warn("dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one — clients that had trusted the old certificate must trust the new one.");
1455
1796
  };
1456
1797
  const stopGateway = async () => {
1457
1798
  const current = gateway;
@@ -1499,9 +1840,11 @@ function apply(ctx, config) {
1499
1840
  });
1500
1841
  ctx.inject(["connection"], (ccx) => {
1501
1842
  upstreamSessionAvailable = true;
1843
+ ctx.logger.info("dsh-lan-gateway: connection service attached; upstream session relay enabled");
1502
1844
  makeRelay = (dshPort) => new UpstreamSessionRelay({
1503
1845
  port: dshPort,
1504
- authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`)
1846
+ authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
1847
+ log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`)
1505
1848
  });
1506
1849
  syncGateway("connection attach");
1507
1850
  });
@@ -1594,7 +1937,7 @@ function apply(ctx, config) {
1594
1937
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
1595
1938
  return {
1596
1939
  ok: true,
1597
- 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}` : "")
1940
+ message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
1598
1941
  };
1599
1942
  },
1600
1943
  async enable() {
@@ -1602,7 +1945,7 @@ function apply(ctx, config) {
1602
1945
  await syncGateway("tool enable");
1603
1946
  return gateway !== void 0 ? {
1604
1947
  ok: true,
1605
- message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}`
1948
+ message: `Gateway enabled: listening on ${gateway.boundAddress()}`
1606
1949
  } : {
1607
1950
  ok: false,
1608
1951
  message: `Failed to enable gateway: ${lastError ?? "unknown error"}`
@@ -1697,4 +2040,4 @@ function apply(ctx, config) {
1697
2040
  }, "dsh-lan-gateway: listener lifecycle");
1698
2041
  }
1699
2042
  //#endregion
1700
- export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name };
2043
+ export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };