@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * dsh's web CLI hard-refuses `--host 0.0.0.0` (exposing remote code execution
6
6
  * to the network), so this plugin leaves dsh bound to 127.0.0.1 and starts its
7
- * own reverse-proxy gateway on 0.0.0.0 that forwards to the loopback dsh port,
7
+ * own reverse-proxy gateway on the unspecified address — both families, so
8
+ * IPv6 clients reach it too — that forwards to the loopback dsh port,
8
9
  * rewriting Host/Origin so the request reaches the dsh web server as if it came
9
10
  * from the loopback authority it names.
10
11
  *
@@ -22,8 +23,10 @@
22
23
  * TLS-terminating proxy, or an explicit `allowInsecurePlaintext` opt-in is
23
24
  * present.
24
25
  * - The gateway never relays its own surface (`/lan-gateway/*`, the login and
25
- * logout pages); sessions carry a revocation epoch that a password change or
26
- * secret rotation bumps, killing old cookies and established WebSockets.
26
+ * logout pages). Sessions are revocable: each carries a random id, so signing
27
+ * out retires that one session and the WebSockets it opened, and each carries
28
+ * a revocation epoch, so a password change or secret rotation kills every
29
+ * session at once.
27
30
  *
28
31
  * Every tunable is also exposed as the `lan-gateway` user-settings namespace
29
32
  * (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
@@ -40,12 +43,20 @@
40
43
 
41
44
  import type { Context } from '@deepseek-ai/cordis'
42
45
  import { randomBytes } from 'node:crypto'
43
- import type { IncomingMessage, ServerResponse } from 'node:http'
46
+ import type { IncomingMessage, ServerResponse } from 'http'
44
47
  import z from '@deepseek-ai/schemastery'
45
- import type { SettingsScope } from '@deepseek-ai/dsh-settings'
48
+ import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
46
49
  import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
50
+ import {
51
+ CONFIG_FIELD_KEYS,
52
+ OPTIONAL_CONFIG_KEYS,
53
+ } from './config-fields.ts'
47
54
  import { LanGateway } from './gateway.ts'
48
55
  import { readBody } from './login.ts'
56
+ import {
57
+ isLoopbackHost,
58
+ READ_ONLY_METHODS,
59
+ } from './request-policy.ts'
49
60
  import {
50
61
  loadState,
51
62
  saveState,
@@ -54,9 +65,10 @@ import {
54
65
  } from './state.ts'
55
66
  import {
56
67
  describeCert,
68
+ loadOrRenewSelfSigned,
57
69
  loadCustomCert,
58
- loadOrCreateSelfSigned,
59
70
  parseSelfSignedHosts,
71
+ readSelfSignedStatus,
60
72
  regenerateSelfSigned,
61
73
  type TlsMaterial,
62
74
  } from './tls.ts'
@@ -113,7 +125,7 @@ export interface GatewayController {
113
125
  export interface Config {
114
126
  /** Whether the gateway listener is started at boot. Default false (safe). */
115
127
  enabled: boolean
116
- /** Port to bind on 0.0.0.0. */
128
+ /** Port to bind on the unspecified address, both address families. */
117
129
  gatewayPort: number
118
130
  /** Explicit dsh target port; defaults to the live `ctx.webServer.port`. */
119
131
  dshTargetPort?: number
@@ -128,12 +140,17 @@ export interface Config {
128
140
  /**
129
141
  * Removed capability: authentication is always required. Retained only so an
130
142
  * explicit legacy `authRequired: false` is rejected loudly instead of
131
- * silently ignored.
143
+ * silently ignored. Not card-editable and never written back to the user
144
+ * section — the config route builds its patch from the editable key set.
132
145
  */
133
146
  authRequired?: boolean
134
147
  /** Session cookie lifetime in days. */
135
148
  cookieMaxAgeDays: number
136
- /** Cookie name. */
149
+ /**
150
+ * Cookie name. Deliberately not card-editable — see the editable key set in
151
+ * `config-fields.ts`; the config route applies a patch, so an operator's
152
+ * custom name survives every save from the Settings card.
153
+ */
137
154
  cookieName: string
138
155
  /** Whether the gateway listener speaks TLS. */
139
156
  tlsEnabled: boolean
@@ -143,9 +160,28 @@ export interface Config {
143
160
  tlsCertPath?: string
144
161
  /** Custom mode: path to the PEM private key. */
145
162
  tlsKeyPath?: string
146
- /** Self-signed mode: comma/space separated DNS names and IPs for the SANs. */
163
+ /**
164
+ * Self-signed mode: comma/space separated DNS names and IPs for the SANs.
165
+ *
166
+ * Read when a certificate is generated, not when it is served: changing it
167
+ * does not replace a certificate that already exists and is still valid. Use
168
+ * `lan_gateway tls-regenerate` for that.
169
+ */
147
170
  tlsSelfSignedHosts?: string
148
- /** Self-signed certificate validity in days (default 825 ≈ 27 months). */
171
+ /**
172
+ * Self-signed certificate validity in days (default 825 ≈ 27 months).
173
+ *
174
+ * 825 is the ceiling Apple states for TLS server certificates, and the
175
+ * well-known 398-day limit — which this default is sometimes mistaken for
176
+ * exceeding — applies only to certificates chaining to a root preinstalled
177
+ * by the platform: Apple exempts user- and administrator-added roots
178
+ * outright, and a self-signed certificate is always one of those. Since a
179
+ * self-signed certificate is either clicked through or trusted by hand,
180
+ * there is nothing to gain from the shorter window and a re-trust to lose
181
+ * every time it lapses.
182
+ *
183
+ * Like `tlsSelfSignedHosts`, this applies to the next generation only.
184
+ */
149
185
  tlsCertMaxAgeDays: number
150
186
  /**
151
187
  * Escape hatch (default false): permit plaintext HTTP. Never derived from
@@ -156,6 +192,11 @@ export interface Config {
156
192
  * An identifier for a trusted TLS-terminating proxy in front of the gateway.
157
193
  * Declaring one marks the ingress encrypted (Secure cookies, passes the
158
194
  * encrypted-ingress gate) without this listener sending HSTS.
195
+ *
196
+ * Note the coupling with login rate limiting: the limiter is keyed by
197
+ * `socket.remoteAddress`, and `X-Forwarded-For` is deliberately untrusted, so
198
+ * behind such a proxy every browser shares one bucket — the login budget
199
+ * becomes per-deployment, not per-client.
159
200
  */
160
201
  trustedTerminator?: string
161
202
  /**
@@ -176,9 +217,6 @@ export interface Config {
176
217
  */
177
218
  const NS = 'lan-gateway'
178
219
 
179
- /** Optional config keys: an empty submitted value clears them back to the composition layer. */
180
- const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
181
-
182
220
  /** Schemastery configuration validated by the Loader. */
183
221
  export const Config: z<Config> = z.object({
184
222
  enabled: z.boolean().default(false),
@@ -259,17 +297,16 @@ export function resolveSecureCookies(
259
297
  }
260
298
 
261
299
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
262
- function resolveTls(cfg: Config): TlsMaterial | undefined {
300
+ function resolveTls(cfg: Config): { material: TlsMaterial; renewed: boolean } | undefined {
263
301
  if (!cfg.tlsEnabled) return undefined
264
302
  if (cfg.tlsMode === 'custom') {
265
- return loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? '')
303
+ return { material: loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? ''), renewed: false }
266
304
  }
267
305
  const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
268
306
  if (hosts.length === 0) {
269
307
  throw new Error('tlsSelfSignedHosts must name at least one host (DNS name or IP)')
270
308
  }
271
- const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
272
- return material
309
+ return loadOrRenewSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
273
310
  }
274
311
 
275
312
  /**
@@ -301,35 +338,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
301
338
  ])
302
339
  }
303
340
 
304
- /** One-line TLS description for status output. */
341
+ /**
342
+ * One-line TLS description for status output.
343
+ *
344
+ * Never generates: this is the read path behind `GET /lan-gateway/config` and
345
+ * `lan_gateway status`, and a status query that mints an RSA key and writes a
346
+ * certificate to disk is not a read. The material is created when the listener
347
+ * starts, or by `lan_gateway tls-regenerate`.
348
+ */
305
349
  function tlsStatusLine(cfg: Config): string {
306
350
  if (!cfg.tlsEnabled) return 'off'
307
351
  if (cfg.tlsMode === 'custom') {
308
352
  return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
309
353
  }
310
354
  try {
311
- const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
312
- const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
313
- const info = describeCert(material.cert)
355
+ const status = readSelfSignedStatus()
356
+ if (status === undefined) return 'self-signed (not generated yet — created when the listener starts)'
357
+ const info = describeCert(status.cert)
314
358
  return `self-signed [${info.subject}] exp ${info.validTo}`
315
359
  } catch (error) {
316
360
  return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
317
361
  }
318
362
  }
319
363
 
320
- /** Whether `hostname` is loopback (127/8, localhost, ::1). */
321
- function isLoopbackHost(hostname: string): boolean {
322
- if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
323
- const parts = hostname.split('.')
324
- return (
325
- parts.length === 4
326
- && parts[0] === '127'
327
- && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
328
- )
329
- }
330
-
331
- const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
332
-
333
364
  /**
334
365
  * Same-origin loopback fence for the native `/lan-gateway/config` route. The
335
366
  * gateway refuses to relay this prefix, so the only way in is the native
@@ -356,23 +387,106 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
356
387
  return true
357
388
  }
358
389
 
390
+ /**
391
+ * Turn a submitted config patch into the next user section: only keys the card
392
+ * can edit, only real values, and `null` (or an emptied optional) removes the
393
+ * key rather than storing it.
394
+ *
395
+ * The patch is built from the *submitted* object, never from a schema call's
396
+ * output. Schemastery fills defaults into whatever it validates and passes
397
+ * unknown keys through, so deriving the section from `Config(submitted)` wrote
398
+ * `authRequired: true` (a capability that exists only to be refused) and any
399
+ * stray key into the user's settings on every save — and, because it also
400
+ * materialized `cookieName`, reset an operator's custom cookie name to the
401
+ * schema default.
402
+ *
403
+ * A `null` value is the card's clear: the key is dropped from the patch, which
404
+ * leaves it absent from the section, so it re-inherits the composition layer.
405
+ */
406
+ export function buildConfigPatch(submitted: Record<string, unknown>): {
407
+ patch: Record<string, unknown>
408
+ clear: string[]
409
+ unknown: string[]
410
+ } {
411
+ const patch: Record<string, unknown> = {}
412
+ const clear: string[] = []
413
+ const unknown: string[] = []
414
+ for (const [key, value] of Object.entries(submitted)) {
415
+ if (!CONFIG_FIELD_KEYS.has(key)) {
416
+ unknown.push(key)
417
+ continue
418
+ }
419
+ if (value === null || value === undefined) {
420
+ clear.push(key)
421
+ continue
422
+ }
423
+ if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) {
424
+ clear.push(key)
425
+ continue
426
+ }
427
+ // An empty string on a non-optional text field is a real value the card
428
+ // refuses to submit, but a hand-written POST could send one; let the schema
429
+ // reject it rather than inventing a meaning here.
430
+ patch[key] = value
431
+ }
432
+ return { patch, clear, unknown }
433
+ }
434
+
359
435
  export function apply(ctx: Context, config: Config): void {
360
436
  let state = loadState()
361
437
  let gateway: LanGateway | undefined
362
438
  let startedWith: string | undefined
363
439
  let lastError: string | undefined
440
+ /**
441
+ * The operator's run intent, used only while no settings service is attached.
442
+ * With settings present, `enabled` in the settings section *is* the intent —
443
+ * the card and the tool write the same field, so there is one truth rather
444
+ * than two that disagree.
445
+ */
364
446
  let manualOverride: boolean | undefined
365
447
  /** Whether the base enforces browser-session auth; set once `connection` is seen. */
366
448
  let upstreamSessionAvailable = false
449
+ /**
450
+ * Identifies the current `connection` handler. A provider that detaches and a
451
+ * new one that attaches run their disposers in an order the plugin does not
452
+ * control, and a stale disposer clearing `makeRelay` would strand the live
453
+ * provider — so a disposer only acts if it is still the latest generation.
454
+ */
455
+ let connectionGeneration = 0
367
456
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
368
457
  let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
369
458
  /** The authoritative config: settings section when attached, else composition. */
370
459
  let configSource: () => Config = () => config
371
- /** Serializes listener start/stop/restart so settings changes cannot race. */
372
- let syncing: Promise<void> = Promise.resolve()
460
+ /** Whether writes go to the settings section rather than staying in memory. */
461
+ let settingsAttached = false
462
+ /** The settings scope for the `lan-gateway` namespace, while one is attached. */
463
+ let settingsScope: SettingsScope<Config> | undefined
464
+ /**
465
+ * The settings provider, for the one write a scope cannot express: a section
466
+ * key must be *removed* to re-inherit the composition layer, and only the
467
+ * provider's path-addressed `mutate` can unset one.
468
+ */
469
+ let settingsProvider: SettingsProvider | undefined
470
+ /**
471
+ * One queue for every lifecycle side effect. Settings changes, tool commands,
472
+ * credential changes, TLS regeneration and plugin disposal all land here, so
473
+ * two of them can never interleave a stop with a start.
474
+ */
475
+ let lifecycle: Promise<void> = Promise.resolve()
476
+ /** Set by the dispose hook; a start that completes after it must undo itself. */
477
+ let disposed = false
373
478
 
374
479
  const effective = (): Config => configSource()
375
480
 
481
+ /** Queue one lifecycle action behind every action already running. */
482
+ const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
483
+ lifecycle = lifecycle.then(action).catch((error: unknown) => {
484
+ lastError = error instanceof Error ? error.message : String(error)
485
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
486
+ })
487
+ return lifecycle
488
+ }
489
+
376
490
  const startGateway = async (cfg: Config): Promise<void> => {
377
491
  if (gateway !== undefined) return
378
492
  const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
@@ -383,7 +497,8 @@ export function apply(ctx: Context, config: Config): void {
383
497
  throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(' ')}`)
384
498
  }
385
499
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
386
- const tls = resolveTls(cfg)
500
+ const resolved = resolveTls(cfg)
501
+ const tls = resolved?.material
387
502
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
388
503
  const secureCookies = resolveSecureCookies(cfg)
389
504
  const next = new LanGateway({
@@ -396,15 +511,35 @@ export function apply(ctx: Context, config: Config): void {
396
511
  secureCookies,
397
512
  ...(tls !== undefined ? { tls } : {}),
398
513
  ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
514
+ onStateChange: (updated) => {
515
+ // The gateway retired a session itself (sign-out). Keep the plugin's
516
+ // copy and the state file in step, or a restart would resurrect a
517
+ // session the user signed out of.
518
+ state = updated
519
+ saveState(state)
520
+ },
399
521
  }, state)
400
522
  await next.listen()
523
+ // The listen above is asynchronous and the tree can be disposed while it is
524
+ // in flight. Publishing the listener after that would leave a socket owned
525
+ // by nobody — the dispose hook already ran and saw `gateway` undefined.
526
+ if (disposed) {
527
+ await next.close()
528
+ return
529
+ }
401
530
  gateway = next
402
531
  startedWith = listenerKey(cfg, makeRelay !== undefined)
403
532
  ctx.logger.info(
404
- `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
533
+ `dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== undefined ? ' (TLS)' : ''}`
405
534
  + ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
406
535
  + `${makeRelay !== undefined ? ' [shared upstream session relay]' : ' [no upstream session relay: base has no browser-session auth]'}`,
407
536
  )
537
+ if (resolved?.renewed === true) {
538
+ ctx.logger.warn(
539
+ 'dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one '
540
+ + '— clients that had trusted the old certificate must trust the new one.',
541
+ )
542
+ }
408
543
  }
409
544
 
410
545
  const stopGateway = async (): Promise<void> => {
@@ -417,46 +552,70 @@ export function apply(ctx: Context, config: Config): void {
417
552
  }
418
553
  }
419
554
 
555
+ /** The config the listener should be running under, intent included. */
556
+ const desiredConfig = (): Config => {
557
+ const cfg = effective()
558
+ // Without a settings service there is nowhere to record the tool's intent,
559
+ // so it lives in memory as an override on the composition entry. With one
560
+ // attached, `enabled` already carries it and an override would shadow the
561
+ // card — the defect this replaces.
562
+ if (settingsAttached) return cfg
563
+ return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
564
+ }
565
+
420
566
  /** Reconcile the listener with the effective config (start/stop/restart). */
421
567
  const syncGateway = (reason: string): Promise<void> => {
422
- syncing = syncing.then(async () => {
568
+ return enqueue(reason, async () => {
423
569
  lastError = undefined
424
- const cfg = effective()
425
- const shouldRun = manualOverride ?? cfg.enabled
426
- try {
427
- if (gateway === undefined) {
428
- if (shouldRun) await startGateway(cfg)
429
- } else if (!shouldRun) {
430
- await stopGateway()
431
- } else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
432
- await stopGateway()
433
- await startGateway(cfg)
434
- }
435
- } catch (error) {
436
- lastError = error instanceof Error ? error.message : String(error)
437
- ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
570
+ if (disposed) return
571
+ const cfg = desiredConfig()
572
+ if (gateway === undefined) {
573
+ if (cfg.enabled) await startGateway(cfg)
574
+ } else if (!cfg.enabled) {
575
+ await stopGateway()
576
+ } else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
577
+ await stopGateway()
578
+ await startGateway(cfg)
438
579
  }
439
580
  })
440
- return syncing
581
+ }
582
+
583
+ /** Record the run intent where it will survive: the settings section, or memory. */
584
+ const setRunIntent = async (enabled: boolean): Promise<void> => {
585
+ if (settingsAttached && settingsScope !== undefined) {
586
+ // The same field the Settings card writes. A merge patch, so nothing else
587
+ // in the user's section is disturbed.
588
+ await settingsScope.update({ enabled })
589
+ // The section's watcher queues the reconcile; it observes committed
590
+ // changes, so waiting on it here would deadlock behind this same write.
591
+ return
592
+ }
593
+ manualOverride = enabled
441
594
  }
442
595
 
443
596
  // The tunables also live in the `lan-gateway` settings section: while the
444
597
  // settings service exists, the section (composition base + user overrides)
445
598
  // is the authoritative config, and every committed change re-syncs the
446
599
  // listener — so the Settings → Plugins page adjusts the gateway live.
447
- // Registered directly (not via installSettingsSection) so the scope handle
448
- // is available to the /lan-gateway/config route for writes.
449
- let settingsScope: SettingsScope<Config> | undefined
600
+ // Registered directly (not via installSection) so the scope handle is
601
+ // available to the /lan-gateway/config route for writes.
450
602
  ctx.inject(['settings'], (sctx) => {
451
603
  const scope = sctx.settings.register(NS, Config, { base: config })
452
604
  settingsScope = scope
605
+ settingsProvider = sctx.settings
606
+ settingsAttached = true
453
607
  configSource = () => scope.get()
454
608
  sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
455
609
  sctx.effect(() => () => {
456
- // The settings provider went away (disposal / provider reload): fall
457
- // back to the composition entry so the plugin keeps working as composed.
610
+ // The settings provider went away (disposal / provider reload): fall back
611
+ // to the composition entry so the plugin keeps working as composed, and
612
+ // reconcile so the listener follows the config that is now authoritative
613
+ // instead of staying on the section's last value.
458
614
  configSource = () => config
459
615
  settingsScope = undefined
616
+ settingsProvider = undefined
617
+ settingsAttached = false
618
+ void syncGateway('settings detach')
460
619
  })
461
620
  void syncGateway('settings attach')
462
621
  })
@@ -467,6 +626,7 @@ export function apply(ctx: Context, config: Config): void {
467
626
  // relay exchanges. Optional: on an older base the callback never runs, the
468
627
  // gateway forwards without a relay, and lanPasswordless stays refused.
469
628
  ctx.inject(['connection'], (ccx) => {
629
+ const generation = ++connectionGeneration
470
630
  upstreamSessionAvailable = true
471
631
  ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
472
632
  makeRelay = (dshPort) => new UpstreamSessionRelay({
@@ -476,6 +636,15 @@ export function apply(ctx: Context, config: Config): void {
476
636
  // and looks exactly like a base with no browser sessions.
477
637
  log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
478
638
  })
639
+ ccx.effect(() => () => {
640
+ // The provider went away. Drop the relay factory rather than hold one
641
+ // bound to a disposed context, and let the listenerKey see the change so
642
+ // the running listener does not keep serving through a dead provider.
643
+ if (generation !== connectionGeneration) return
644
+ makeRelay = undefined
645
+ upstreamSessionAvailable = false
646
+ void syncGateway('connection detach')
647
+ })
479
648
  // A listener that started before the connection service appeared must
480
649
  // restart so it picks up the relay (and the now-correct fail-closed facts).
481
650
  void syncGateway('connection attach')
@@ -488,6 +657,17 @@ export function apply(ctx: Context, config: Config): void {
488
657
  // reach it — a genuine local user, or a local process that could already read
489
658
  // ~/.dsh.
490
659
  const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
660
+ const snapshot = (): Record<string, unknown> => {
661
+ const cfg = effective()
662
+ return {
663
+ config: cfg,
664
+ running: gateway !== undefined,
665
+ port: cfg.gatewayPort,
666
+ tls: tlsStatusLine(cfg),
667
+ upstreamSessionAvailable,
668
+ lastError: lastError ?? null,
669
+ }
670
+ }
491
671
  const send = (status: number, body: unknown): void => {
492
672
  res.writeHead(status, { 'content-type': 'application/json' })
493
673
  res.end(JSON.stringify(body))
@@ -497,15 +677,7 @@ export function apply(ctx: Context, config: Config): void {
497
677
  return
498
678
  }
499
679
  if (req.method === 'GET') {
500
- const cfg = effective()
501
- send(200, {
502
- config: cfg,
503
- running: gateway !== undefined,
504
- port: cfg.gatewayPort,
505
- tls: tlsStatusLine(cfg),
506
- upstreamSessionAvailable,
507
- lastError: lastError ?? null,
508
- })
680
+ send(200, snapshot())
509
681
  return
510
682
  }
511
683
  if (req.method !== 'POST') {
@@ -525,20 +697,18 @@ export function apply(ctx: Context, config: Config): void {
525
697
  send(400, { error: 'body must be a config object' })
526
698
  return
527
699
  }
528
- // The schema callable validates and fills defaults; it throws with a
529
- // descriptive message on any invalid value.
530
- let candidate: Config
531
- try {
532
- candidate = Config(submitted as Config)
533
- } catch (error) {
534
- send(400, { error: error instanceof Error ? error.message : String(error) })
535
- return
536
- }
537
- if (settingsScope === undefined) {
700
+ if (settingsProvider === undefined) {
538
701
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
539
702
  return
540
703
  }
541
- // Fail the save early (before persisting) when the candidate is unusable.
704
+ // The patch names only the keys the card can edit; a clear is expressed by
705
+ // omitting the key from the patch, which is what `unset` does to the section
706
+ // as it stands. Unknown keys are reported rather than silently stored.
707
+ const { patch, clear, unknown } = buildConfigPatch(submitted as Record<string, unknown>)
708
+ // Validate the candidate the patch would produce — schema defaults included,
709
+ // exactly as the listener will resolve it — so the save fails closed on an
710
+ // unusable combination instead of persisting it.
711
+ const candidate = Config({ ...effective(), ...patch })
542
712
  // A structural problem (legacy authRequired:false, lanPasswordless without
543
713
  // a session-capable base) is invalid however it is reached; a start
544
714
  // condition (plaintext without TLS/terminator/opt-in) only blocks a save
@@ -551,28 +721,26 @@ export function apply(ctx: Context, config: Config): void {
551
721
  send(409, { error: `config cannot start: ${problems.join(' ')}` })
552
722
  return
553
723
  }
554
- // Build the next user section: drop null/undefined and empty optionals
555
- // (an empty path field re-inherits the composition layer).
556
- const section: Record<string, unknown> = {}
557
- for (const [key, value] of Object.entries(candidate)) {
558
- if (value === null || value === undefined) continue
559
- if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) continue
560
- section[key] = value
561
- }
562
724
  try {
563
- await settingsScope.replace(section)
564
- // Let the listener restart settle before reporting, so `running` is
565
- // accurate instead of a mid-restart snapshot.
725
+ const ops = [
726
+ ...Object.entries(patch).map(([key, value]) => ({ op: 'set' as const, path: [key], value })),
727
+ ...clear.map(key => ({ op: 'unset' as const, path: [key] })),
728
+ ]
729
+ // Path-addressed edits, not a merge patch: a clear has to *remove* the
730
+ // key so it re-inherits the composition layer. Storing null instead would
731
+ // leave a null where the config expects a string, and `!== undefined`
732
+ // tests elsewhere would then read that null as a declared value.
733
+ if (ops.length > 0) await settingsProvider.mutate(NS, ops)
734
+ // The write commits through the section's watcher; reconcile explicitly so
735
+ // the response reports a settled listener rather than a mid-restart one.
566
736
  await syncGateway('config route save')
567
- const cfg = effective()
568
- send(200, {
569
- config: cfg,
570
- running: gateway !== undefined,
571
- port: cfg.gatewayPort,
572
- tls: tlsStatusLine(cfg),
573
- upstreamSessionAvailable,
574
- lastError: lastError ?? null,
575
- })
737
+ const next = { ...snapshot() }
738
+ if (unknown.length > 0) {
739
+ // Not an error: an older client may post fields this build dropped. Say
740
+ // so rather than dropping them silently.
741
+ next['ignored'] = unknown
742
+ }
743
+ send(200, next)
576
744
  } catch (error) {
577
745
  send(409, { error: error instanceof Error ? error.message : String(error) })
578
746
  }
@@ -584,35 +752,36 @@ export function apply(ctx: Context, config: Config): void {
584
752
 
585
753
  const controller: GatewayController = {
586
754
  status(): ToolResult {
587
- const cfg = effective()
755
+ const cfg = desiredConfig()
588
756
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
589
757
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
590
758
  return {
591
759
  ok: true,
592
760
  message:
593
- `LAN gateway: ${gateway !== undefined ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : 'stopped'}`
761
+ `LAN gateway: ${gateway !== undefined ? `LISTENING on ${gateway.boundAddress()}` : 'stopped'}`
594
762
  + `\n- dsh target: 127.0.0.1:${dshPort}`
595
763
  + `\n- password: ${state.password !== undefined ? 'set' : 'NOT SET'}`
596
764
  + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
597
765
  + `\n- session epoch: ${state.sessionEpoch}`
766
+ + `\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)`
598
767
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
599
768
  + `\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== undefined ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? 'TLS' : 'plaintext'} browser ingress)` : encrypted ? 'encrypted' : cfg.allowInsecurePlaintext ? 'PLAINTEXT (explicit allowInsecurePlaintext)' : 'plaintext — will not start'}`
600
769
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
601
- + (manualOverride !== undefined
770
+ + (manualOverride !== undefined && !settingsAttached
602
771
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
603
772
  : '')
604
773
  + (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
605
774
  }
606
775
  },
607
776
  async enable(): Promise<ToolResult> {
608
- manualOverride = true
777
+ await setRunIntent(true)
609
778
  await syncGateway('tool enable')
610
779
  return gateway !== undefined
611
- ? { ok: true, message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}` }
780
+ ? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
612
781
  : { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
613
782
  },
614
783
  async disable(): Promise<ToolResult> {
615
- manualOverride = false
784
+ await setRunIntent(false)
616
785
  await syncGateway('tool disable')
617
786
  return { ok: true, message: 'Gateway disabled.' }
618
787
  },
@@ -621,26 +790,29 @@ export function apply(ctx: Context, config: Config): void {
621
790
  return { ok: false, message: 'Password must be at least 8 characters.' }
622
791
  }
623
792
  const setting = password !== undefined && password.length > 0
624
- const previous = state
625
- state = setPassword(state, setting ? password : undefined)
793
+ const hadPassword = state.password !== undefined
794
+ state = await setPassword(state, setting ? password : undefined)
626
795
  saveState(state)
627
796
  gateway?.setState(state)
628
797
  if (!setting) {
629
798
  // Clearing the credential must not leave an open gateway serving
630
799
  // sessions the old password authorized: stop the listener. A password
631
800
  // is required to run, so a later enable fails closed.
632
- manualOverride = false
633
- if (gateway !== undefined) {
634
- await stopGateway()
801
+ await setRunIntent(false)
802
+ return enqueue('password cleared', async () => {
803
+ if (gateway !== undefined) await stopGateway()
635
804
  lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
636
- void syncGateway('password cleared')
637
- }
638
- return {
805
+ }).then(() => ({
639
806
  ok: true,
640
807
  message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
641
- }
808
+ }))
642
809
  }
643
- void (previous === undefined ? syncGateway('password set') : Promise.resolve())
810
+ // The first password turns a dormant "enabled but unpassworded" intent
811
+ // into a startable one, so reconcile: the listener was refused a moment
812
+ // ago for a reason that no longer holds. A later password change needs no
813
+ // reconcile (the listener is already running or already refused for some
814
+ // other reason), and reconciling anyway would be harmless but noisy.
815
+ if (!hadPassword) await syncGateway('password set')
644
816
  return {
645
817
  ok: true,
646
818
  message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
@@ -668,29 +840,40 @@ export function apply(ctx: Context, config: Config): void {
668
840
  if (hosts.length === 0) {
669
841
  return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
670
842
  }
671
- try {
672
- regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
673
- if (gateway !== undefined) {
674
- await stopGateway()
675
- await startGateway(effective())
843
+ let failure: string | undefined
844
+ // Through the queue like every other lifecycle action: minting the
845
+ // certificate and restarting must not interleave with a settings-driven
846
+ // restart, which is how two listeners ended up racing for one port.
847
+ await enqueue('tls regenerate', async () => {
848
+ try {
849
+ regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
850
+ if (gateway !== undefined) {
851
+ await stopGateway()
852
+ await startGateway(effective())
853
+ }
676
854
  lastError = undefined
855
+ } catch (error) {
856
+ failure = error instanceof Error ? error.message : String(error)
677
857
  }
678
- return { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
679
- } catch (error) {
680
- return {
681
- ok: false,
682
- message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
683
- }
684
- }
858
+ })
859
+ return failure === undefined
860
+ ? { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
861
+ : { ok: false, message: `Failed to regenerate TLS certificate: ${failure}` }
685
862
  },
686
863
  }
687
864
 
688
865
  // Register the management tool once.
689
866
  ctx.tools.register(lanGatewayTool(controller))
690
867
 
691
- // Own the gateway lifecycle with the cordis tree.
868
+ // Own the gateway lifecycle with the cordis tree. The dispose hook only marks
869
+ // the tree gone and queues the stop: everything that could be mid-flight is
870
+ // already holding the queue, and `startGateway` undoes its own listener when
871
+ // it notices the flag.
692
872
  ctx.effect(() => {
693
873
  void syncGateway('boot')
694
- return stopGateway
874
+ return async () => {
875
+ disposed = true
876
+ await enqueue('dispose', stopGateway)
877
+ }
695
878
  }, 'dsh-lan-gateway: listener lifecycle')
696
879
  }