@riceawa/dsh-lan-gateway 0.5.4 → 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
@@ -43,12 +43,20 @@
43
43
 
44
44
  import type { Context } from '@deepseek-ai/cordis'
45
45
  import { randomBytes } from 'node:crypto'
46
- import type { IncomingMessage, ServerResponse } from 'node:http'
46
+ import type { IncomingMessage, ServerResponse } from 'http'
47
47
  import z from '@deepseek-ai/schemastery'
48
- import type { SettingsScope } from '@deepseek-ai/dsh-settings'
48
+ import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
49
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'
50
54
  import { LanGateway } from './gateway.ts'
51
55
  import { readBody } from './login.ts'
56
+ import {
57
+ isLoopbackHost,
58
+ READ_ONLY_METHODS,
59
+ } from './request-policy.ts'
52
60
  import {
53
61
  loadState,
54
62
  saveState,
@@ -57,10 +65,10 @@ import {
57
65
  } from './state.ts'
58
66
  import {
59
67
  describeCert,
60
- loadCustomCert,
61
- loadOrCreateSelfSigned,
62
68
  loadOrRenewSelfSigned,
69
+ loadCustomCert,
63
70
  parseSelfSignedHosts,
71
+ readSelfSignedStatus,
64
72
  regenerateSelfSigned,
65
73
  type TlsMaterial,
66
74
  } from './tls.ts'
@@ -132,12 +140,17 @@ export interface Config {
132
140
  /**
133
141
  * Removed capability: authentication is always required. Retained only so an
134
142
  * explicit legacy `authRequired: false` is rejected loudly instead of
135
- * 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.
136
145
  */
137
146
  authRequired?: boolean
138
147
  /** Session cookie lifetime in days. */
139
148
  cookieMaxAgeDays: number
140
- /** 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
+ */
141
154
  cookieName: string
142
155
  /** Whether the gateway listener speaks TLS. */
143
156
  tlsEnabled: boolean
@@ -147,7 +160,13 @@ export interface Config {
147
160
  tlsCertPath?: string
148
161
  /** Custom mode: path to the PEM private key. */
149
162
  tlsKeyPath?: string
150
- /** 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
+ */
151
170
  tlsSelfSignedHosts?: string
152
171
  /**
153
172
  * Self-signed certificate validity in days (default 825 ≈ 27 months).
@@ -160,6 +179,8 @@ export interface Config {
160
179
  * self-signed certificate is either clicked through or trusted by hand,
161
180
  * there is nothing to gain from the shorter window and a re-trust to lose
162
181
  * every time it lapses.
182
+ *
183
+ * Like `tlsSelfSignedHosts`, this applies to the next generation only.
163
184
  */
164
185
  tlsCertMaxAgeDays: number
165
186
  /**
@@ -171,6 +192,11 @@ export interface Config {
171
192
  * An identifier for a trusted TLS-terminating proxy in front of the gateway.
172
193
  * Declaring one marks the ingress encrypted (Secure cookies, passes the
173
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.
174
200
  */
175
201
  trustedTerminator?: string
176
202
  /**
@@ -191,9 +217,6 @@ export interface Config {
191
217
  */
192
218
  const NS = 'lan-gateway'
193
219
 
194
- /** Optional config keys: an empty submitted value clears them back to the composition layer. */
195
- const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
196
-
197
220
  /** Schemastery configuration validated by the Loader. */
198
221
  export const Config: z<Config> = z.object({
199
222
  enabled: z.boolean().default(false),
@@ -315,35 +338,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
315
338
  ])
316
339
  }
317
340
 
318
- /** 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
+ */
319
349
  function tlsStatusLine(cfg: Config): string {
320
350
  if (!cfg.tlsEnabled) return 'off'
321
351
  if (cfg.tlsMode === 'custom') {
322
352
  return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
323
353
  }
324
354
  try {
325
- const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
326
- const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
327
- 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)
328
358
  return `self-signed [${info.subject}] exp ${info.validTo}`
329
359
  } catch (error) {
330
360
  return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
331
361
  }
332
362
  }
333
363
 
334
- /** Whether `hostname` is loopback (127/8, localhost, ::1). */
335
- function isLoopbackHost(hostname: string): boolean {
336
- if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
337
- const parts = hostname.split('.')
338
- return (
339
- parts.length === 4
340
- && parts[0] === '127'
341
- && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
342
- )
343
- }
344
-
345
- const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
346
-
347
364
  /**
348
365
  * Same-origin loopback fence for the native `/lan-gateway/config` route. The
349
366
  * gateway refuses to relay this prefix, so the only way in is the native
@@ -370,23 +387,106 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
370
387
  return true
371
388
  }
372
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
+
373
435
  export function apply(ctx: Context, config: Config): void {
374
436
  let state = loadState()
375
437
  let gateway: LanGateway | undefined
376
438
  let startedWith: string | undefined
377
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
+ */
378
446
  let manualOverride: boolean | undefined
379
447
  /** Whether the base enforces browser-session auth; set once `connection` is seen. */
380
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
381
456
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
382
457
  let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
383
458
  /** The authoritative config: settings section when attached, else composition. */
384
459
  let configSource: () => Config = () => config
385
- /** Serializes listener start/stop/restart so settings changes cannot race. */
386
- 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
387
478
 
388
479
  const effective = (): Config => configSource()
389
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
+
390
490
  const startGateway = async (cfg: Config): Promise<void> => {
391
491
  if (gateway !== undefined) return
392
492
  const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
@@ -420,6 +520,13 @@ export function apply(ctx: Context, config: Config): void {
420
520
  },
421
521
  }, state)
422
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
+ }
423
530
  gateway = next
424
531
  startedWith = listenerKey(cfg, makeRelay !== undefined)
425
532
  ctx.logger.info(
@@ -445,46 +552,70 @@ export function apply(ctx: Context, config: Config): void {
445
552
  }
446
553
  }
447
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
+
448
566
  /** Reconcile the listener with the effective config (start/stop/restart). */
449
567
  const syncGateway = (reason: string): Promise<void> => {
450
- syncing = syncing.then(async () => {
568
+ return enqueue(reason, async () => {
451
569
  lastError = undefined
452
- const cfg = effective()
453
- const shouldRun = manualOverride ?? cfg.enabled
454
- try {
455
- if (gateway === undefined) {
456
- if (shouldRun) await startGateway(cfg)
457
- } else if (!shouldRun) {
458
- await stopGateway()
459
- } else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
460
- await stopGateway()
461
- await startGateway(cfg)
462
- }
463
- } catch (error) {
464
- lastError = error instanceof Error ? error.message : String(error)
465
- 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)
466
579
  }
467
580
  })
468
- 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
469
594
  }
470
595
 
471
596
  // The tunables also live in the `lan-gateway` settings section: while the
472
597
  // settings service exists, the section (composition base + user overrides)
473
598
  // is the authoritative config, and every committed change re-syncs the
474
599
  // listener — so the Settings → Plugins page adjusts the gateway live.
475
- // Registered directly (not via installSettingsSection) so the scope handle
476
- // is available to the /lan-gateway/config route for writes.
477
- 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.
478
602
  ctx.inject(['settings'], (sctx) => {
479
603
  const scope = sctx.settings.register(NS, Config, { base: config })
480
604
  settingsScope = scope
605
+ settingsProvider = sctx.settings
606
+ settingsAttached = true
481
607
  configSource = () => scope.get()
482
608
  sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
483
609
  sctx.effect(() => () => {
484
- // The settings provider went away (disposal / provider reload): fall
485
- // 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.
486
614
  configSource = () => config
487
615
  settingsScope = undefined
616
+ settingsProvider = undefined
617
+ settingsAttached = false
618
+ void syncGateway('settings detach')
488
619
  })
489
620
  void syncGateway('settings attach')
490
621
  })
@@ -495,6 +626,7 @@ export function apply(ctx: Context, config: Config): void {
495
626
  // relay exchanges. Optional: on an older base the callback never runs, the
496
627
  // gateway forwards without a relay, and lanPasswordless stays refused.
497
628
  ctx.inject(['connection'], (ccx) => {
629
+ const generation = ++connectionGeneration
498
630
  upstreamSessionAvailable = true
499
631
  ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
500
632
  makeRelay = (dshPort) => new UpstreamSessionRelay({
@@ -504,6 +636,15 @@ export function apply(ctx: Context, config: Config): void {
504
636
  // and looks exactly like a base with no browser sessions.
505
637
  log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
506
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
+ })
507
648
  // A listener that started before the connection service appeared must
508
649
  // restart so it picks up the relay (and the now-correct fail-closed facts).
509
650
  void syncGateway('connection attach')
@@ -516,6 +657,17 @@ export function apply(ctx: Context, config: Config): void {
516
657
  // reach it — a genuine local user, or a local process that could already read
517
658
  // ~/.dsh.
518
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
+ }
519
671
  const send = (status: number, body: unknown): void => {
520
672
  res.writeHead(status, { 'content-type': 'application/json' })
521
673
  res.end(JSON.stringify(body))
@@ -525,15 +677,7 @@ export function apply(ctx: Context, config: Config): void {
525
677
  return
526
678
  }
527
679
  if (req.method === 'GET') {
528
- const cfg = effective()
529
- send(200, {
530
- config: cfg,
531
- running: gateway !== undefined,
532
- port: cfg.gatewayPort,
533
- tls: tlsStatusLine(cfg),
534
- upstreamSessionAvailable,
535
- lastError: lastError ?? null,
536
- })
680
+ send(200, snapshot())
537
681
  return
538
682
  }
539
683
  if (req.method !== 'POST') {
@@ -553,20 +697,18 @@ export function apply(ctx: Context, config: Config): void {
553
697
  send(400, { error: 'body must be a config object' })
554
698
  return
555
699
  }
556
- // The schema callable validates and fills defaults; it throws with a
557
- // descriptive message on any invalid value.
558
- let candidate: Config
559
- try {
560
- candidate = Config(submitted as Config)
561
- } catch (error) {
562
- send(400, { error: error instanceof Error ? error.message : String(error) })
563
- return
564
- }
565
- if (settingsScope === undefined) {
700
+ if (settingsProvider === undefined) {
566
701
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
567
702
  return
568
703
  }
569
- // 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 })
570
712
  // A structural problem (legacy authRequired:false, lanPasswordless without
571
713
  // a session-capable base) is invalid however it is reached; a start
572
714
  // condition (plaintext without TLS/terminator/opt-in) only blocks a save
@@ -579,28 +721,26 @@ export function apply(ctx: Context, config: Config): void {
579
721
  send(409, { error: `config cannot start: ${problems.join(' ')}` })
580
722
  return
581
723
  }
582
- // Build the next user section: drop null/undefined and empty optionals
583
- // (an empty path field re-inherits the composition layer).
584
- const section: Record<string, unknown> = {}
585
- for (const [key, value] of Object.entries(candidate)) {
586
- if (value === null || value === undefined) continue
587
- if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) continue
588
- section[key] = value
589
- }
590
724
  try {
591
- await settingsScope.replace(section)
592
- // Let the listener restart settle before reporting, so `running` is
593
- // 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.
594
736
  await syncGateway('config route save')
595
- const cfg = effective()
596
- send(200, {
597
- config: cfg,
598
- running: gateway !== undefined,
599
- port: cfg.gatewayPort,
600
- tls: tlsStatusLine(cfg),
601
- upstreamSessionAvailable,
602
- lastError: lastError ?? null,
603
- })
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)
604
744
  } catch (error) {
605
745
  send(409, { error: error instanceof Error ? error.message : String(error) })
606
746
  }
@@ -612,7 +752,7 @@ export function apply(ctx: Context, config: Config): void {
612
752
 
613
753
  const controller: GatewayController = {
614
754
  status(): ToolResult {
615
- const cfg = effective()
755
+ const cfg = desiredConfig()
616
756
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
617
757
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
618
758
  return {
@@ -627,21 +767,21 @@ export function apply(ctx: Context, config: Config): void {
627
767
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
628
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'}`
629
769
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
630
- + (manualOverride !== undefined
770
+ + (manualOverride !== undefined && !settingsAttached
631
771
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
632
772
  : '')
633
773
  + (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
634
774
  }
635
775
  },
636
776
  async enable(): Promise<ToolResult> {
637
- manualOverride = true
777
+ await setRunIntent(true)
638
778
  await syncGateway('tool enable')
639
779
  return gateway !== undefined
640
780
  ? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
641
781
  : { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
642
782
  },
643
783
  async disable(): Promise<ToolResult> {
644
- manualOverride = false
784
+ await setRunIntent(false)
645
785
  await syncGateway('tool disable')
646
786
  return { ok: true, message: 'Gateway disabled.' }
647
787
  },
@@ -650,26 +790,29 @@ export function apply(ctx: Context, config: Config): void {
650
790
  return { ok: false, message: 'Password must be at least 8 characters.' }
651
791
  }
652
792
  const setting = password !== undefined && password.length > 0
653
- const previous = state
654
- state = setPassword(state, setting ? password : undefined)
793
+ const hadPassword = state.password !== undefined
794
+ state = await setPassword(state, setting ? password : undefined)
655
795
  saveState(state)
656
796
  gateway?.setState(state)
657
797
  if (!setting) {
658
798
  // Clearing the credential must not leave an open gateway serving
659
799
  // sessions the old password authorized: stop the listener. A password
660
800
  // is required to run, so a later enable fails closed.
661
- manualOverride = false
662
- if (gateway !== undefined) {
663
- await stopGateway()
801
+ await setRunIntent(false)
802
+ return enqueue('password cleared', async () => {
803
+ if (gateway !== undefined) await stopGateway()
664
804
  lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
665
- void syncGateway('password cleared')
666
- }
667
- return {
805
+ }).then(() => ({
668
806
  ok: true,
669
807
  message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
670
- }
808
+ }))
671
809
  }
672
- 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')
673
816
  return {
674
817
  ok: true,
675
818
  message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
@@ -697,29 +840,40 @@ export function apply(ctx: Context, config: Config): void {
697
840
  if (hosts.length === 0) {
698
841
  return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
699
842
  }
700
- try {
701
- regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
702
- if (gateway !== undefined) {
703
- await stopGateway()
704
- 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
+ }
705
854
  lastError = undefined
855
+ } catch (error) {
856
+ failure = error instanceof Error ? error.message : String(error)
706
857
  }
707
- return { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
708
- } catch (error) {
709
- return {
710
- ok: false,
711
- message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
712
- }
713
- }
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}` }
714
862
  },
715
863
  }
716
864
 
717
865
  // Register the management tool once.
718
866
  ctx.tools.register(lanGatewayTool(controller))
719
867
 
720
- // 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.
721
872
  ctx.effect(() => {
722
873
  void syncGateway('boot')
723
- return stopGateway
874
+ return async () => {
875
+ disposed = true
876
+ await enqueue('dispose', stopGateway)
877
+ }
724
878
  }, 'dsh-lan-gateway: listener lifecycle')
725
879
  }
package/src/login.ts CHANGED
@@ -14,12 +14,9 @@ export const LOGIN_PATH = '/__login' as const
14
14
  /** Path the gateway owns and never forwards: signs the session out. */
15
15
  export const LOGOUT_PATH = '/__logout' as const
16
16
 
17
- /** The cookie name used for the signed session. */
18
- export const COOKIE_NAME = 'dsh_gw_auth' as const
19
-
20
17
  export interface LoginPageOptions {
21
18
  error?: string
22
- /** Optional login attempt counter to show when rate-limited. */
19
+ /** The attempt was refused by the rate limiter, not by a wrong password. */
23
20
  limited?: boolean
24
21
  }
25
22
 
@@ -95,19 +92,6 @@ export function serveLoginGet(res: ServerResponse, extraHeaders: OutgoingHttpHea
95
92
  res.end(renderLoginPage())
96
93
  }
97
94
 
98
- /** Parse an application/x-www-form-urlencoded body into its fields. */
99
- export function parseFormBody(body: string): Record<string, string> {
100
- const out: Record<string, string> = {}
101
- for (const pair of body.split('&')) {
102
- if (pair === '') continue
103
- const eq = pair.indexOf('=')
104
- const key = eq === -1 ? pair : pair.slice(0, eq)
105
- const value = eq === -1 ? '' : pair.slice(eq + 1)
106
- out[decodeURIComponent(key.replaceAll('+', ' '))] = decodeURIComponent(value.replaceAll('+', ' '))
107
- }
108
- return out
109
- }
110
-
111
95
  /** Read a request body up to a byte ceiling, rejecting anything larger. */
112
96
  export function readBody(req: IncomingMessage, maxBytes: number, res: ServerResponse): Promise<string | undefined> {
113
97
  return new Promise((resolve) => {