@riceawa/dsh-lan-gateway 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -43,12 +43,24 @@
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
+ // Type-only: the `settings` service (0.1.7 addresses a write by profile entry
49
+ // id) and the Loader's `fiber.entry`, which is where this plugin reads its own
50
+ // entry id from.
51
+ import type SettingsService from '@deepseek-ai/dsh-settings'
52
+ import type {} from '@deepseek-ai/cordis-plugin-loader'
49
53
  import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
54
+ import {
55
+ CONFIG_FIELD_KEYS,
56
+ OPTIONAL_CONFIG_KEYS,
57
+ } from './config-fields.ts'
50
58
  import { LanGateway } from './gateway.ts'
51
59
  import { readBody } from './login.ts'
60
+ import {
61
+ isLoopbackHost,
62
+ READ_ONLY_METHODS,
63
+ } from './request-policy.ts'
52
64
  import {
53
65
  loadState,
54
66
  saveState,
@@ -57,10 +69,10 @@ import {
57
69
  } from './state.ts'
58
70
  import {
59
71
  describeCert,
60
- loadCustomCert,
61
- loadOrCreateSelfSigned,
62
72
  loadOrRenewSelfSigned,
73
+ loadCustomCert,
63
74
  parseSelfSignedHosts,
75
+ readSelfSignedStatus,
64
76
  regenerateSelfSigned,
65
77
  type TlsMaterial,
66
78
  } from './tls.ts'
@@ -132,12 +144,17 @@ export interface Config {
132
144
  /**
133
145
  * Removed capability: authentication is always required. Retained only so an
134
146
  * explicit legacy `authRequired: false` is rejected loudly instead of
135
- * silently ignored.
147
+ * silently ignored. Not card-editable and never written back to the user
148
+ * section — the config route builds its patch from the editable key set.
136
149
  */
137
150
  authRequired?: boolean
138
151
  /** Session cookie lifetime in days. */
139
152
  cookieMaxAgeDays: number
140
- /** Cookie name. */
153
+ /**
154
+ * Cookie name. Deliberately not card-editable — see the editable key set in
155
+ * `config-fields.ts`; the config route applies a patch, so an operator's
156
+ * custom name survives every save from the Settings card.
157
+ */
141
158
  cookieName: string
142
159
  /** Whether the gateway listener speaks TLS. */
143
160
  tlsEnabled: boolean
@@ -147,7 +164,13 @@ export interface Config {
147
164
  tlsCertPath?: string
148
165
  /** Custom mode: path to the PEM private key. */
149
166
  tlsKeyPath?: string
150
- /** Self-signed mode: comma/space separated DNS names and IPs for the SANs. */
167
+ /**
168
+ * Self-signed mode: comma/space separated DNS names and IPs for the SANs.
169
+ *
170
+ * Read when a certificate is generated, not when it is served: changing it
171
+ * does not replace a certificate that already exists and is still valid. Use
172
+ * `lan_gateway tls-regenerate` for that.
173
+ */
151
174
  tlsSelfSignedHosts?: string
152
175
  /**
153
176
  * Self-signed certificate validity in days (default 825 ≈ 27 months).
@@ -160,6 +183,8 @@ export interface Config {
160
183
  * self-signed certificate is either clicked through or trusted by hand,
161
184
  * there is nothing to gain from the shorter window and a re-trust to lose
162
185
  * every time it lapses.
186
+ *
187
+ * Like `tlsSelfSignedHosts`, this applies to the next generation only.
163
188
  */
164
189
  tlsCertMaxAgeDays: number
165
190
  /**
@@ -171,6 +196,11 @@ export interface Config {
171
196
  * An identifier for a trusted TLS-terminating proxy in front of the gateway.
172
197
  * Declaring one marks the ingress encrypted (Secure cookies, passes the
173
198
  * encrypted-ingress gate) without this listener sending HSTS.
199
+ *
200
+ * Note the coupling with login rate limiting: the limiter is keyed by
201
+ * `socket.remoteAddress`, and `X-Forwarded-For` is deliberately untrusted, so
202
+ * behind such a proxy every browser shares one bucket — the login budget
203
+ * becomes per-deployment, not per-client.
174
204
  */
175
205
  trustedTerminator?: string
176
206
  /**
@@ -183,38 +213,112 @@ export interface Config {
183
213
  secureCookies?: boolean
184
214
  }
185
215
 
216
+ /** A `.volatile()` config field as the Loader hands it to `apply`. */
217
+ export interface ConfigRef<T> {
218
+ /** The current value; updated in place when the profile entry is written. */
219
+ get(): T
220
+ }
221
+
222
+ /**
223
+ * The config object `apply` receives: dsh 0.1.7 hands every `.volatile()` field
224
+ * over as a reference rather than a value, so the plugin reads its live config
225
+ * through `readConfig`. A field declared optional and left unset resolves to
226
+ * `undefined` instead of a reference, hence the union inside `ConfigRef`.
227
+ */
228
+ export type ConfigRefs = { [K in keyof Required<Config>]: ConfigRef<Config[K]> }
229
+
186
230
  /**
187
- * The `lan-gateway` user-settings namespace, mirroring the composition schema.
188
- * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
189
- * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
190
- * shape works against both that release line and the older branded one.
231
+ * Schemastery configuration validated by the Loader.
232
+ *
233
+ * Every field is `.volatile()`, which is what lets the Settings service write
234
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
235
+ * other path (`not volatile`). The mark also changes the runtime shape — a
236
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
237
+ * value the rest of this file expects — so read it through `readConfig`.
191
238
  */
192
- const NS = 'lan-gateway'
193
-
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
- /** Schemastery configuration validated by the Loader. */
198
- export const Config: z<Config> = z.object({
199
- enabled: z.boolean().default(false),
200
- gatewayPort: z.natural().min(1).max(65535).default(3081),
201
- dshTargetPort: z.natural().min(1).max(65535),
202
- lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
203
- lanPasswordless: z.boolean().default(false),
204
- authRequired: z.boolean().default(true),
205
- cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
206
- cookieName: z.string().default('dsh_gw_auth'),
207
- tlsEnabled: z.boolean().default(false),
208
- tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed'),
209
- tlsCertPath: z.string(),
210
- tlsKeyPath: z.string(),
211
- tlsSelfSignedHosts: z.string().default('localhost'),
212
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
213
- allowInsecurePlaintext: z.boolean().default(false),
214
- trustedTerminator: z.string(),
215
- secureCookies: z.boolean(),
239
+ export const Config = z.object({
240
+ enabled: z.boolean().default(false).volatile(),
241
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
242
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
243
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
244
+ lanPasswordless: z.boolean().default(false).volatile(),
245
+ authRequired: z.boolean().default(true).volatile(),
246
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
247
+ cookieName: z.string().default('dsh_gw_auth').volatile(),
248
+ tlsEnabled: z.boolean().default(false).volatile(),
249
+ tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed').volatile(),
250
+ tlsCertPath: z.string().volatile(),
251
+ tlsKeyPath: z.string().volatile(),
252
+ tlsSelfSignedHosts: z.string().default('localhost').volatile(),
253
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
254
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
255
+ trustedTerminator: z.string().volatile(),
256
+ secureCookies: z.boolean().volatile(),
216
257
  })
217
258
 
259
+ /**
260
+ * Unwrap the config references into the plain values every other function in
261
+ * this file reads. Called on each access rather than once, because a settings
262
+ * write updates the references in place.
263
+ * @param refs - the config object handed to `apply`.
264
+ * @returns one detached plain snapshot.
265
+ */
266
+ export function readConfig(refs: ConfigRefs): Config {
267
+ const dshTargetPort = refs.dshTargetPort?.get()
268
+ const authRequired = refs.authRequired?.get()
269
+ const tlsCertPath = refs.tlsCertPath?.get()
270
+ const tlsKeyPath = refs.tlsKeyPath?.get()
271
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get()
272
+ const trustedTerminator = refs.trustedTerminator?.get()
273
+ const secureCookies = refs.secureCookies?.get()
274
+ return {
275
+ enabled: refs.enabled.get(),
276
+ gatewayPort: refs.gatewayPort.get(),
277
+ lanCidrs: [...refs.lanCidrs.get()],
278
+ lanPasswordless: refs.lanPasswordless.get(),
279
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
280
+ cookieName: refs.cookieName.get(),
281
+ tlsEnabled: refs.tlsEnabled.get(),
282
+ tlsMode: refs.tlsMode.get(),
283
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
284
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
285
+ // `exactOptionalPropertyTypes` is on, so an absent optional key must stay
286
+ // absent rather than be assigned `undefined`. Every field here has a schema
287
+ // default or is genuinely optional, so an absent reference means unset.
288
+ ...(dshTargetPort !== undefined ? { dshTargetPort } : {}),
289
+ ...(authRequired !== undefined ? { authRequired } : {}),
290
+ ...(tlsCertPath !== undefined ? { tlsCertPath } : {}),
291
+ ...(tlsKeyPath !== undefined ? { tlsKeyPath } : {}),
292
+ ...(tlsSelfSignedHosts !== undefined ? { tlsSelfSignedHosts } : {}),
293
+ ...(trustedTerminator !== undefined ? { trustedTerminator } : {}),
294
+ ...(secureCookies !== undefined ? { secureCookies } : {}),
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
300
+ * builds it. Exported for tests that drive `apply` directly.
301
+ * @param raw - a config object; missing fields take their schema defaults.
302
+ * @returns one reference per volatile field.
303
+ */
304
+ export function configRefs(raw: object): ConfigRefs {
305
+ return Config(raw) as unknown as ConfigRefs
306
+ }
307
+
308
+ /**
309
+ * Validate a raw config object the way the Loader does, and unwrap it.
310
+ *
311
+ * `Config` marks every field volatile, so a validation hands the values back as
312
+ * references (typed deeply-readonly by schemastery); this returns the plain
313
+ * shape the rest of the file reads. Used to judge a config the Settings card is
314
+ * about to save, before it is persisted.
315
+ * @param raw - a config object; missing fields take their schema defaults.
316
+ * @returns the validated plain config.
317
+ */
318
+ export function validateConfig(raw: object): Config {
319
+ return readConfig(configRefs(raw))
320
+ }
321
+
218
322
  /** Facts the fail-closed start guard needs to judge a config. */
219
323
  export interface StartFacts {
220
324
  /** Whether the dsh base enforces browser-session auth (auto-detected). */
@@ -315,35 +419,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
315
419
  ])
316
420
  }
317
421
 
318
- /** One-line TLS description for status output. */
422
+ /**
423
+ * One-line TLS description for status output.
424
+ *
425
+ * Never generates: this is the read path behind `GET /lan-gateway/config` and
426
+ * `lan_gateway status`, and a status query that mints an RSA key and writes a
427
+ * certificate to disk is not a read. The material is created when the listener
428
+ * starts, or by `lan_gateway tls-regenerate`.
429
+ */
319
430
  function tlsStatusLine(cfg: Config): string {
320
431
  if (!cfg.tlsEnabled) return 'off'
321
432
  if (cfg.tlsMode === 'custom') {
322
433
  return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
323
434
  }
324
435
  try {
325
- const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
326
- const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
327
- const info = describeCert(material.cert)
436
+ const status = readSelfSignedStatus()
437
+ if (status === undefined) return 'self-signed (not generated yet — created when the listener starts)'
438
+ const info = describeCert(status.cert)
328
439
  return `self-signed [${info.subject}] exp ${info.validTo}`
329
440
  } catch (error) {
330
441
  return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
331
442
  }
332
443
  }
333
444
 
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
445
  /**
348
446
  * Same-origin loopback fence for the native `/lan-gateway/config` route. The
349
447
  * gateway refuses to relay this prefix, so the only way in is the native
@@ -370,22 +468,107 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
370
468
  return true
371
469
  }
372
470
 
373
- export function apply(ctx: Context, config: Config): void {
471
+ /**
472
+ * Turn a submitted config patch into the next user section: only keys the card
473
+ * can edit, only real values, and `null` (or an emptied optional) removes the
474
+ * key rather than storing it.
475
+ *
476
+ * The patch is built from the *submitted* object, never from a schema call's
477
+ * output. Schemastery fills defaults into whatever it validates and passes
478
+ * unknown keys through, so deriving the section from `Config(submitted)` wrote
479
+ * `authRequired: true` (a capability that exists only to be refused) and any
480
+ * stray key into the user's settings on every save — and, because it also
481
+ * materialized `cookieName`, reset an operator's custom cookie name to the
482
+ * schema default.
483
+ *
484
+ * A `null` value is the card's clear: the key is dropped from the patch, which
485
+ * leaves it absent from the section, so it re-inherits the composition layer.
486
+ */
487
+ export function buildConfigPatch(submitted: Record<string, unknown>): {
488
+ patch: Record<string, unknown>
489
+ clear: string[]
490
+ unknown: string[]
491
+ } {
492
+ const patch: Record<string, unknown> = {}
493
+ const clear: string[] = []
494
+ const unknown: string[] = []
495
+ for (const [key, value] of Object.entries(submitted)) {
496
+ if (!CONFIG_FIELD_KEYS.has(key)) {
497
+ unknown.push(key)
498
+ continue
499
+ }
500
+ if (value === null || value === undefined) {
501
+ clear.push(key)
502
+ continue
503
+ }
504
+ if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) {
505
+ clear.push(key)
506
+ continue
507
+ }
508
+ // An empty string on a non-optional text field is a real value the card
509
+ // refuses to submit, but a hand-written POST could send one; let the schema
510
+ // reject it rather than inventing a meaning here.
511
+ patch[key] = value
512
+ }
513
+ return { patch, clear, unknown }
514
+ }
515
+
516
+ export function apply(ctx: Context, config: ConfigRefs): void {
374
517
  let state = loadState()
375
518
  let gateway: LanGateway | undefined
376
519
  let startedWith: string | undefined
377
520
  let lastError: string | undefined
521
+ /**
522
+ * The operator's run intent, used only while no settings service is attached.
523
+ * With settings present, `enabled` in the settings section *is* the intent —
524
+ * the card and the tool write the same field, so there is one truth rather
525
+ * than two that disagree.
526
+ */
378
527
  let manualOverride: boolean | undefined
379
528
  /** Whether the base enforces browser-session auth; set once `connection` is seen. */
380
529
  let upstreamSessionAvailable = false
530
+ /**
531
+ * Identifies the current `connection` handler. A provider that detaches and a
532
+ * new one that attaches run their disposers in an order the plugin does not
533
+ * control, and a stale disposer clearing `makeRelay` would strand the live
534
+ * provider — so a disposer only acts if it is still the latest generation.
535
+ */
536
+ let connectionGeneration = 0
381
537
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
382
538
  let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
383
- /** The authoritative config: settings section when attached, else composition. */
384
- let configSource: () => Config = () => config
385
- /** Serializes listener start/stop/restart so settings changes cannot race. */
386
- let syncing: Promise<void> = Promise.resolve()
539
+ /** Whether the settings service is attached, so writes reach the profile entry. */
540
+ let settingsAttached = false
541
+ /**
542
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
543
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
544
+ * with is gone along with `settingsScope`.
545
+ */
546
+ let settingsEntryId: string | undefined
547
+ /**
548
+ * The settings service, for the one write a merge patch cannot express: a key
549
+ * must be *removed* to re-inherit the composition layer, and only its
550
+ * path-addressed `mutate` can unset one.
551
+ */
552
+ let settingsProvider: SettingsService | undefined
553
+ /**
554
+ * One queue for every lifecycle side effect. Settings changes, tool commands,
555
+ * credential changes, TLS regeneration and plugin disposal all land here, so
556
+ * two of them can never interleave a stop with a start.
557
+ */
558
+ let lifecycle: Promise<void> = Promise.resolve()
559
+ /** Set by the dispose hook; a start that completes after it must undo itself. */
560
+ let disposed = false
387
561
 
388
- const effective = (): Config => configSource()
562
+ const effective = (): Config => readConfig(config)
563
+
564
+ /** Queue one lifecycle action behind every action already running. */
565
+ const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
566
+ lifecycle = lifecycle.then(action).catch((error: unknown) => {
567
+ lastError = error instanceof Error ? error.message : String(error)
568
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
569
+ })
570
+ return lifecycle
571
+ }
389
572
 
390
573
  const startGateway = async (cfg: Config): Promise<void> => {
391
574
  if (gateway !== undefined) return
@@ -420,6 +603,13 @@ export function apply(ctx: Context, config: Config): void {
420
603
  },
421
604
  }, state)
422
605
  await next.listen()
606
+ // The listen above is asynchronous and the tree can be disposed while it is
607
+ // in flight. Publishing the listener after that would leave a socket owned
608
+ // by nobody — the dispose hook already ran and saw `gateway` undefined.
609
+ if (disposed) {
610
+ await next.close()
611
+ return
612
+ }
423
613
  gateway = next
424
614
  startedWith = listenerKey(cfg, makeRelay !== undefined)
425
615
  ctx.logger.info(
@@ -445,46 +635,72 @@ export function apply(ctx: Context, config: Config): void {
445
635
  }
446
636
  }
447
637
 
638
+ /** The config the listener should be running under, intent included. */
639
+ const desiredConfig = (): Config => {
640
+ const cfg = effective()
641
+ // Without a settings service there is nowhere to record the tool's intent,
642
+ // so it lives in memory as an override on the composition entry. With one
643
+ // attached, the profile entry's `enabled` already carries it and an override
644
+ // would shadow the card — the defect this replaces.
645
+ if (settingsAttached) return cfg
646
+ return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
647
+ }
648
+
448
649
  /** Reconcile the listener with the effective config (start/stop/restart). */
449
650
  const syncGateway = (reason: string): Promise<void> => {
450
- syncing = syncing.then(async () => {
651
+ return enqueue(reason, async () => {
451
652
  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}`)
653
+ if (disposed) return
654
+ const cfg = desiredConfig()
655
+ if (gateway === undefined) {
656
+ if (cfg.enabled) await startGateway(cfg)
657
+ } else if (!cfg.enabled) {
658
+ await stopGateway()
659
+ } else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
660
+ await stopGateway()
661
+ await startGateway(cfg)
466
662
  }
467
663
  })
468
- return syncing
469
664
  }
470
665
 
471
- // The tunables also live in the `lan-gateway` settings section: while the
472
- // settings service exists, the section (composition base + user overrides)
473
- // is the authoritative config, and every committed change re-syncs the
474
- // 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
666
+ /** Record the run intent where it will survive: the profile entry, or memory. */
667
+ const setRunIntent = async (enabled: boolean): Promise<void> => {
668
+ if (settingsAttached && settingsProvider !== undefined && settingsEntryId !== undefined) {
669
+ // The same field the Settings card writes, in this plugin's own profile
670
+ // entry. A merge patch, so nothing else in the entry is disturbed.
671
+ await settingsProvider.update(settingsEntryId, { enabled })
672
+ // The write updates the config references in place and the loader then
673
+ // emits `loader/volatile-update`, which queues the reconcile; waiting on
674
+ // that here would deadlock behind this same write.
675
+ return
676
+ }
677
+ manualOverride = enabled
678
+ }
679
+
680
+ // The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
681
+ // it by *entry id*: the `lan-gateway` settings namespace this plugin used to
682
+ // register (and the scope handle it wrote through) no longer exist. The entry
683
+ // id is the Loader's, so it comes from the fiber; without a Loader there is no
684
+ // entry to write and the composition value stands alone.
478
685
  ctx.inject(['settings'], (sctx) => {
479
- const scope = sctx.settings.register(NS, Config, { base: config })
480
- settingsScope = scope
481
- configSource = () => scope.get()
482
- sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
686
+ const entryId = ctx.fiber.entry?.options.id
687
+ if (entryId === undefined) return
688
+ settingsEntryId = entryId
689
+ settingsProvider = sctx.settings
690
+ settingsAttached = true
691
+ // This plugin ships its own card, so it owns its page policy.
692
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
693
+ // A committed write updates the volatile references in place (no remount)
694
+ // and emits this, so the listener follows the new config.
695
+ sctx.effect(() => ctx.on('loader/volatile-update', () => { void syncGateway('settings change') }))
483
696
  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.
486
- configSource = () => config
487
- settingsScope = undefined
697
+ // The settings service went away (disposal / provider reload): the
698
+ // composition entry is authoritative again, and the tool's intent falls
699
+ // back to memory. Reconcile so the listener follows it.
700
+ settingsEntryId = undefined
701
+ settingsProvider = undefined
702
+ settingsAttached = false
703
+ void syncGateway('settings detach')
488
704
  })
489
705
  void syncGateway('settings attach')
490
706
  })
@@ -495,6 +711,7 @@ export function apply(ctx: Context, config: Config): void {
495
711
  // relay exchanges. Optional: on an older base the callback never runs, the
496
712
  // gateway forwards without a relay, and lanPasswordless stays refused.
497
713
  ctx.inject(['connection'], (ccx) => {
714
+ const generation = ++connectionGeneration
498
715
  upstreamSessionAvailable = true
499
716
  ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
500
717
  makeRelay = (dshPort) => new UpstreamSessionRelay({
@@ -504,6 +721,15 @@ export function apply(ctx: Context, config: Config): void {
504
721
  // and looks exactly like a base with no browser sessions.
505
722
  log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
506
723
  })
724
+ ccx.effect(() => () => {
725
+ // The provider went away. Drop the relay factory rather than hold one
726
+ // bound to a disposed context, and let the listenerKey see the change so
727
+ // the running listener does not keep serving through a dead provider.
728
+ if (generation !== connectionGeneration) return
729
+ makeRelay = undefined
730
+ upstreamSessionAvailable = false
731
+ void syncGateway('connection detach')
732
+ })
507
733
  // A listener that started before the connection service appeared must
508
734
  // restart so it picks up the relay (and the now-correct fail-closed facts).
509
735
  void syncGateway('connection attach')
@@ -516,6 +742,17 @@ export function apply(ctx: Context, config: Config): void {
516
742
  // reach it — a genuine local user, or a local process that could already read
517
743
  // ~/.dsh.
518
744
  const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
745
+ const snapshot = (): Record<string, unknown> => {
746
+ const cfg = effective()
747
+ return {
748
+ config: cfg,
749
+ running: gateway !== undefined,
750
+ port: cfg.gatewayPort,
751
+ tls: tlsStatusLine(cfg),
752
+ upstreamSessionAvailable,
753
+ lastError: lastError ?? null,
754
+ }
755
+ }
519
756
  const send = (status: number, body: unknown): void => {
520
757
  res.writeHead(status, { 'content-type': 'application/json' })
521
758
  res.end(JSON.stringify(body))
@@ -525,15 +762,7 @@ export function apply(ctx: Context, config: Config): void {
525
762
  return
526
763
  }
527
764
  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
- })
765
+ send(200, snapshot())
537
766
  return
538
767
  }
539
768
  if (req.method !== 'POST') {
@@ -553,20 +782,23 @@ export function apply(ctx: Context, config: Config): void {
553
782
  send(400, { error: 'body must be a config object' })
554
783
  return
555
784
  }
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) {
785
+ // A settings write is addressed by this plugin's profile entry id; without
786
+ // one (no Loader, or no settings service) the composition value is all there
787
+ // is, and the operator edits the profile patch instead.
788
+ const settings = settingsProvider
789
+ const entryId = settingsEntryId
790
+ if (settings === undefined || entryId === undefined) {
566
791
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
567
792
  return
568
793
  }
569
- // Fail the save early (before persisting) when the candidate is unusable.
794
+ // The patch names only the keys the card can edit; a clear is expressed by
795
+ // omitting the key from the patch, which is what `unset` does to the section
796
+ // as it stands. Unknown keys are reported rather than silently stored.
797
+ const { patch, clear, unknown } = buildConfigPatch(submitted as Record<string, unknown>)
798
+ // Validate the candidate the patch would produce — schema defaults included,
799
+ // exactly as the listener will resolve it — so the save fails closed on an
800
+ // unusable combination instead of persisting it.
801
+ const candidate = validateConfig({ ...effective(), ...patch })
570
802
  // A structural problem (legacy authRequired:false, lanPasswordless without
571
803
  // a session-capable base) is invalid however it is reached; a start
572
804
  // condition (plaintext without TLS/terminator/opt-in) only blocks a save
@@ -579,28 +811,26 @@ export function apply(ctx: Context, config: Config): void {
579
811
  send(409, { error: `config cannot start: ${problems.join(' ')}` })
580
812
  return
581
813
  }
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
814
  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.
815
+ const ops = [
816
+ ...Object.entries(patch).map(([key, value]) => ({ op: 'set' as const, path: [key], value })),
817
+ ...clear.map(key => ({ op: 'unset' as const, path: [key] })),
818
+ ]
819
+ // Path-addressed edits, not a merge patch: a clear has to *remove* the
820
+ // key so it re-inherits the composition layer. Storing null instead would
821
+ // leave a null where the config expects a string, and `!== undefined`
822
+ // tests elsewhere would then read that null as a declared value.
823
+ if (ops.length > 0) await settings.mutate(entryId, ops)
824
+ // The write commits through the section's watcher; reconcile explicitly so
825
+ // the response reports a settled listener rather than a mid-restart one.
594
826
  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
- })
827
+ const next = { ...snapshot() }
828
+ if (unknown.length > 0) {
829
+ // Not an error: an older client may post fields this build dropped. Say
830
+ // so rather than dropping them silently.
831
+ next['ignored'] = unknown
832
+ }
833
+ send(200, next)
604
834
  } catch (error) {
605
835
  send(409, { error: error instanceof Error ? error.message : String(error) })
606
836
  }
@@ -612,7 +842,7 @@ export function apply(ctx: Context, config: Config): void {
612
842
 
613
843
  const controller: GatewayController = {
614
844
  status(): ToolResult {
615
- const cfg = effective()
845
+ const cfg = desiredConfig()
616
846
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
617
847
  const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
618
848
  return {
@@ -627,21 +857,21 @@ export function apply(ctx: Context, config: Config): void {
627
857
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
628
858
  + `\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
859
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
630
- + (manualOverride !== undefined
860
+ + (manualOverride !== undefined && !settingsAttached
631
861
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
632
862
  : '')
633
863
  + (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
634
864
  }
635
865
  },
636
866
  async enable(): Promise<ToolResult> {
637
- manualOverride = true
867
+ await setRunIntent(true)
638
868
  await syncGateway('tool enable')
639
869
  return gateway !== undefined
640
870
  ? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
641
871
  : { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
642
872
  },
643
873
  async disable(): Promise<ToolResult> {
644
- manualOverride = false
874
+ await setRunIntent(false)
645
875
  await syncGateway('tool disable')
646
876
  return { ok: true, message: 'Gateway disabled.' }
647
877
  },
@@ -650,26 +880,29 @@ export function apply(ctx: Context, config: Config): void {
650
880
  return { ok: false, message: 'Password must be at least 8 characters.' }
651
881
  }
652
882
  const setting = password !== undefined && password.length > 0
653
- const previous = state
654
- state = setPassword(state, setting ? password : undefined)
883
+ const hadPassword = state.password !== undefined
884
+ state = await setPassword(state, setting ? password : undefined)
655
885
  saveState(state)
656
886
  gateway?.setState(state)
657
887
  if (!setting) {
658
888
  // Clearing the credential must not leave an open gateway serving
659
889
  // sessions the old password authorized: stop the listener. A password
660
890
  // is required to run, so a later enable fails closed.
661
- manualOverride = false
662
- if (gateway !== undefined) {
663
- await stopGateway()
891
+ await setRunIntent(false)
892
+ return enqueue('password cleared', async () => {
893
+ if (gateway !== undefined) await stopGateway()
664
894
  lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
665
- void syncGateway('password cleared')
666
- }
667
- return {
895
+ }).then(() => ({
668
896
  ok: true,
669
897
  message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
670
- }
898
+ }))
671
899
  }
672
- void (previous === undefined ? syncGateway('password set') : Promise.resolve())
900
+ // The first password turns a dormant "enabled but unpassworded" intent
901
+ // into a startable one, so reconcile: the listener was refused a moment
902
+ // ago for a reason that no longer holds. A later password change needs no
903
+ // reconcile (the listener is already running or already refused for some
904
+ // other reason), and reconciling anyway would be harmless but noisy.
905
+ if (!hadPassword) await syncGateway('password set')
673
906
  return {
674
907
  ok: true,
675
908
  message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
@@ -697,29 +930,40 @@ export function apply(ctx: Context, config: Config): void {
697
930
  if (hosts.length === 0) {
698
931
  return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
699
932
  }
700
- try {
701
- regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
702
- if (gateway !== undefined) {
703
- await stopGateway()
704
- await startGateway(effective())
933
+ let failure: string | undefined
934
+ // Through the queue like every other lifecycle action: minting the
935
+ // certificate and restarting must not interleave with a settings-driven
936
+ // restart, which is how two listeners ended up racing for one port.
937
+ await enqueue('tls regenerate', async () => {
938
+ try {
939
+ regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
940
+ if (gateway !== undefined) {
941
+ await stopGateway()
942
+ await startGateway(effective())
943
+ }
705
944
  lastError = undefined
945
+ } catch (error) {
946
+ failure = error instanceof Error ? error.message : String(error)
706
947
  }
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
- }
948
+ })
949
+ return failure === undefined
950
+ ? { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
951
+ : { ok: false, message: `Failed to regenerate TLS certificate: ${failure}` }
714
952
  },
715
953
  }
716
954
 
717
955
  // Register the management tool once.
718
956
  ctx.tools.register(lanGatewayTool(controller))
719
957
 
720
- // Own the gateway lifecycle with the cordis tree.
958
+ // Own the gateway lifecycle with the cordis tree. The dispose hook only marks
959
+ // the tree gone and queues the stop: everything that could be mid-flight is
960
+ // already holding the queue, and `startGateway` undoes its own listener when
961
+ // it notices the flag.
721
962
  ctx.effect(() => {
722
963
  void syncGateway('boot')
723
- return stopGateway
964
+ return async () => {
965
+ disposed = true
966
+ await enqueue('dispose', stopGateway)
967
+ }
724
968
  }, 'dsh-lan-gateway: listener lifecycle')
725
969
  }