@riceawa/dsh-lan-gateway 0.3.0 → 0.5.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
@@ -5,18 +5,32 @@
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
7
  * own reverse-proxy gateway on 0.0.0.0 that forwards to the loopback dsh port,
8
- * rewriting Host/Origin so the `/api` trust fence passes. LAN and loopback
9
- * sources are proxied password-free; anything else must complete the login
10
- * page and present the HMAC cookie.
8
+ * rewriting Host/Origin so the request reaches the dsh web server as if it came
9
+ * from the loopback authority it names.
11
10
  *
12
- * The gateway listener can speak TLS: either a persisted auto-generated
13
- * self-signed certificate (`tlsMode: 'self-signed'`, hosts from
14
- * `tlsSelfSignedHosts`) or a user-supplied PEM pair (`tlsMode: 'custom'`,
15
- * `tlsCertPath` + `tlsKeyPath`).
11
+ * Security model (default-deny, post-QVD-2026-57410):
12
+ * - Every source — loopback, LAN, internet — must present a gateway session
13
+ * before anything is forwarded. Classification by source IP grants nothing.
14
+ * `lanPasswordless` is an explicit opt-in (false by default) that lets
15
+ * LAN/loopback sources skip the gateway login; it is refused unless the dsh
16
+ * base itself enforces browser-session auth (auto-detected in-process), so a
17
+ * "trust my LAN" choice can never reinstall the original Host-trust hole.
18
+ * - Against such a base the gateway relays one shared upstream session (see
19
+ * `upstream-session.ts`), so dsh's own authorization still gates every
20
+ * request: the gateway only decides who may ride its shared session.
21
+ * - The listener refuses to run over plaintext unless TLS, a declared trusted
22
+ * TLS-terminating proxy, or an explicit `allowInsecurePlaintext` opt-in is
23
+ * present.
24
+ * - 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.
16
27
  *
17
28
  * Every tunable is also exposed as the `lan-gateway` user-settings namespace
18
29
  * (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
19
30
  * port, CIDRs, auth, and TLS live; the running listener restarts on change.
31
+ * The card reads/writes through the loopback-only `/lan-gateway/config` route;
32
+ * remote browsers get a 403 from the gateway for that prefix and manage the
33
+ * gateway through the `lan_gateway` tool instead.
20
34
  *
21
35
  * Disabled by default in the bundle patch (safe): the listener opens only
22
36
  * after `lan_gateway enable` or `enabled: true`.
@@ -29,7 +43,7 @@ import { randomBytes } from 'node:crypto'
29
43
  import type { IncomingMessage, ServerResponse } from 'node:http'
30
44
  import z from '@deepseek-ai/schemastery'
31
45
  import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
32
- import { DEFAULT_LAN_CIDR_STRINGS } from './auth.ts'
46
+ import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
33
47
  import { LanGateway } from './gateway.ts'
34
48
  import { readBody } from './login.ts'
35
49
  import {
@@ -47,6 +61,7 @@ import {
47
61
  type TlsMaterial,
48
62
  } from './tls.ts'
49
63
  import { lanGatewayTool } from './tool.ts'
64
+ import { UpstreamSessionRelay, type UpstreamSession } from './upstream-session.ts'
50
65
 
51
66
  /** Stable Cordis plugin name. */
52
67
  export const name = 'dsh-lan-gateway'
@@ -65,9 +80,16 @@ export interface WebServerSurface {
65
80
  }): () => void
66
81
  }
67
82
 
83
+ /** Minimal surface of the dsh client-connection service (session-capable bases). */
84
+ export interface UpstreamConnectionSurface {
85
+ /** A root URL for the upstream origin carrying the process launch token. */
86
+ authenticatedUrl(baseUrl: string): string
87
+ }
88
+
68
89
  declare module '@deepseek-ai/cordis' {
69
90
  interface Context {
70
91
  webServer: WebServerSurface
92
+ connection: UpstreamConnectionSurface
71
93
  }
72
94
  }
73
95
 
@@ -82,7 +104,7 @@ export interface GatewayController {
82
104
  status(): ToolResult
83
105
  enable(): Promise<ToolResult>
84
106
  disable(): Promise<ToolResult>
85
- setPassword(password: string | undefined): ToolResult
107
+ setPassword(password: string | undefined): Promise<ToolResult>
86
108
  rotateSecret(): ToolResult
87
109
  regenerateTls(): Promise<ToolResult>
88
110
  }
@@ -95,10 +117,20 @@ export interface Config {
95
117
  gatewayPort: number
96
118
  /** Explicit dsh target port; defaults to the live `ctx.webServer.port`. */
97
119
  dshTargetPort?: number
98
- /** LAN CIDRs treated as password-free. */
120
+ /** LAN CIDRs that may be treated as trusted when `lanPasswordless` is on. */
99
121
  lanCidrs: string[]
100
- /** Whether non-LAN sources must authenticate. */
101
- authRequired: boolean
122
+ /**
123
+ * Opt-in (default false): let LAN/loopback sources skip the gateway login.
124
+ * Only allowed against a session-capable dsh base, where upstream auth still
125
+ * gates every request via the relayed shared session.
126
+ */
127
+ lanPasswordless: boolean
128
+ /**
129
+ * Removed capability: authentication is always required. Retained only so an
130
+ * explicit legacy `authRequired: false` is rejected loudly instead of
131
+ * silently ignored.
132
+ */
133
+ authRequired?: boolean
102
134
  /** Session cookie lifetime in days. */
103
135
  cookieMaxAgeDays: number
104
136
  /** Cookie name. */
@@ -115,13 +147,24 @@ export interface Config {
115
147
  tlsSelfSignedHosts?: string
116
148
  /** Self-signed certificate validity in days (default 825 ≈ 27 months). */
117
149
  tlsCertMaxAgeDays: number
150
+ /**
151
+ * Escape hatch (default false): permit plaintext HTTP. Never derived from
152
+ * `X-Forwarded-Proto` — the operator declares it.
153
+ */
154
+ allowInsecurePlaintext: boolean
155
+ /**
156
+ * An identifier for a trusted TLS-terminating proxy in front of the gateway.
157
+ * Declaring one marks the ingress encrypted (Secure cookies, passes the
158
+ * encrypted-ingress gate) without this listener sending HSTS.
159
+ */
160
+ trustedTerminator?: string
118
161
  }
119
162
 
120
163
  /** The `lan-gateway` user-settings namespace, mirroring the composition schema. */
121
164
  const NS = settingsNamespace('lan-gateway')
122
165
 
123
166
  /** Optional config keys: an empty submitted value clears them back to the composition layer. */
124
- const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath'])
167
+ const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
125
168
 
126
169
  /** Schemastery configuration validated by the Loader. */
127
170
  export const Config: z<Config> = z.object({
@@ -129,6 +172,7 @@ export const Config: z<Config> = z.object({
129
172
  gatewayPort: z.natural().min(1).max(65535).default(3081),
130
173
  dshTargetPort: z.natural().min(1).max(65535),
131
174
  lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
175
+ lanPasswordless: z.boolean().default(false),
132
176
  authRequired: z.boolean().default(true),
133
177
  cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
134
178
  cookieName: z.string().default('dsh_gw_auth'),
@@ -138,8 +182,46 @@ export const Config: z<Config> = z.object({
138
182
  tlsKeyPath: z.string(),
139
183
  tlsSelfSignedHosts: z.string().default('localhost'),
140
184
  tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
185
+ allowInsecurePlaintext: z.boolean().default(false),
186
+ trustedTerminator: z.string(),
141
187
  })
142
188
 
189
+ /** Facts the fail-closed start guard needs to judge a config. */
190
+ export interface StartFacts {
191
+ /** Whether the dsh base enforces browser-session auth (auto-detected). */
192
+ upstreamSessionAvailable: boolean
193
+ }
194
+
195
+ /**
196
+ * The fail-closed problems that prevent a config from enabling the listener.
197
+ * Returns every problem (not just the first) so the operator sees the full
198
+ * migration at once. Exported for tests.
199
+ */
200
+ export function gatewayStartProblems(cfg: Config, facts: StartFacts): string[] {
201
+ const problems: string[] = []
202
+ if (cfg.authRequired === false) {
203
+ problems.push(
204
+ 'authRequired=false is no longer supported — authentication is always required. '
205
+ + 'Remove `authRequired` (or set it true); for password-free LAN access set `lanPasswordless: true`.',
206
+ )
207
+ }
208
+ if (cfg.lanPasswordless && !facts.upstreamSessionAvailable) {
209
+ problems.push(
210
+ 'lanPasswordless requires a dsh base with browser-session auth (>= 0.1.2-rc.1): the gateway '
211
+ + 'relaxes only its own login, never dsh authorization. Upgrade dsh, or set lanPasswordless: false.',
212
+ )
213
+ }
214
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
215
+ if (!encryptedIngress && !cfg.allowInsecurePlaintext) {
216
+ problems.push(
217
+ 'Refusing to serve over plaintext HTTP: enable TLS (tlsEnabled: true), declare a trusted '
218
+ + 'TLS-terminating proxy (trustedTerminator), or set allowInsecurePlaintext: true to accept '
219
+ + 'the plaintext exposure (passwords and sessions would travel in clear).',
220
+ )
221
+ }
222
+ return problems
223
+ }
224
+
143
225
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
144
226
  function resolveTls(cfg: Config): TlsMaterial | undefined {
145
227
  if (!cfg.tlsEnabled) return undefined
@@ -160,7 +242,7 @@ function listenerKey(cfg: Config): string {
160
242
  cfg.gatewayPort,
161
243
  cfg.dshTargetPort,
162
244
  cfg.lanCidrs,
163
- cfg.authRequired,
245
+ cfg.lanPasswordless,
164
246
  cfg.cookieMaxAgeDays,
165
247
  cfg.cookieName,
166
248
  cfg.tlsEnabled,
@@ -169,6 +251,8 @@ function listenerKey(cfg: Config): string {
169
251
  cfg.tlsKeyPath,
170
252
  cfg.tlsSelfSignedHosts,
171
253
  cfg.tlsCertMaxAgeDays,
254
+ cfg.allowInsecurePlaintext,
255
+ cfg.trustedTerminator,
172
256
  ])
173
257
  }
174
258
 
@@ -199,13 +283,17 @@ function isLoopbackHost(hostname: string): boolean {
199
283
  )
200
284
  }
201
285
 
286
+ const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
287
+
202
288
  /**
203
- * Same-origin loopback fence for the config route (mirrors the fence the dsh
204
- * host uses for its own /api, and what dsh-lan-gateway's sibling plugins do):
205
- * the Host must be loopback (the gateway rewrites it), cross-site fetches are
206
- * refused, and any Origin must match the Host the browser actually used.
289
+ * Same-origin loopback fence for the native `/lan-gateway/config` route. The
290
+ * gateway refuses to relay this prefix, so the only way in is the native
291
+ * loopback listener itself (a genuine local user, or a local process that could
292
+ * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
293
+ * cross-site fetches are refused, an Origin must match the Host the browser
294
+ * used, and a state-changing method must carry that Origin. Exported for tests.
207
295
  */
208
- function isTrustedRequest(req: IncomingMessage): boolean {
296
+ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
209
297
  const host = req.headers?.host
210
298
  if (typeof host !== 'string' || host === '') return false
211
299
  let hostUrl: URL
@@ -217,12 +305,10 @@ function isTrustedRequest(req: IncomingMessage): boolean {
217
305
  if (!isLoopbackHost(hostUrl.hostname)) return false
218
306
  if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
219
307
  const origin = req.headers?.origin
220
- if (origin === undefined) return true
221
- try {
222
- return new URL(origin).host === hostUrl.host
223
- } catch {
224
- return false
225
- }
308
+ if (origin !== undefined && !originMatchesHost(origin, host)) return false
309
+ const method = req.method ?? 'GET'
310
+ if (!READ_ONLY_METHODS.has(method) && origin === undefined) return false
311
+ return true
226
312
  }
227
313
 
228
314
  export function apply(ctx: Context, config: Config): void {
@@ -231,6 +317,10 @@ export function apply(ctx: Context, config: Config): void {
231
317
  let startedWith: string | undefined
232
318
  let lastError: string | undefined
233
319
  let manualOverride: boolean | undefined
320
+ /** Whether the base enforces browser-session auth; set once `connection` is seen. */
321
+ let upstreamSessionAvailable = false
322
+ /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
323
+ let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
234
324
  /** The authoritative config: settings section when attached, else composition. */
235
325
  let configSource: () => Config = () => config
236
326
  /** Serializes listener start/stop/restart so settings changes cannot race. */
@@ -240,30 +330,34 @@ export function apply(ctx: Context, config: Config): void {
240
330
 
241
331
  const startGateway = async (cfg: Config): Promise<void> => {
242
332
  if (gateway !== undefined) return
243
- if (cfg.authRequired && state.password === undefined) {
244
- // A passwordless gateway exposed to non-LAN sources would be an open
245
- // remote-code-execution door. Refuse to listen until a password is set.
246
- throw new Error(
247
- 'dsh-lan-gateway: no password set — run `lan_gateway set-password` (or set '
248
- + 'authRequired=false in the plugin config) before enabling.',
249
- )
333
+ const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
334
+ if (state.password === undefined) {
335
+ problems.unshift('no password set — run `lan_gateway set-password` before enabling the listener')
336
+ }
337
+ if (problems.length > 0) {
338
+ throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(' ')}`)
250
339
  }
251
340
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
252
341
  const tls = resolveTls(cfg)
342
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
253
343
  const next = new LanGateway({
254
344
  gatewayPort: cfg.gatewayPort,
255
345
  dshPort,
256
346
  lanCidrs: cfg.lanCidrs,
257
- authRequired: cfg.authRequired,
347
+ lanPasswordless: cfg.lanPasswordless,
258
348
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
259
349
  cookieName: cfg.cookieName,
350
+ secureCookies: encryptedIngress,
260
351
  ...(tls !== undefined ? { tls } : {}),
352
+ ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
261
353
  }, state)
262
354
  await next.listen()
263
355
  gateway = next
264
356
  startedWith = listenerKey(cfg)
265
357
  ctx.logger.info(
266
- `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''} -> 127.0.0.1:${dshPort}`,
358
+ `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
359
+ + ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
360
+ + `${makeRelay !== undefined ? ' [shared upstream session relay]' : ' [no upstream session relay: base has no browser-session auth]'}`,
267
361
  )
268
362
  }
269
363
 
@@ -321,16 +415,35 @@ export function apply(ctx: Context, config: Config): void {
321
415
  void syncGateway('settings attach')
322
416
  })
323
417
 
418
+ // A session-capable dsh base exposes the `connection` service (0.1.2+). The
419
+ // presence of that service both (a) tells the fail-closed guard that the base
420
+ // itself authenticates and (b) supplies the launch-token URL the shared-session
421
+ // relay exchanges. Optional: on an older base the callback never runs, the
422
+ // gateway forwards without a relay, and lanPasswordless stays refused.
423
+ ctx.inject(['connection'], (ccx) => {
424
+ upstreamSessionAvailable = true
425
+ makeRelay = (dshPort) => new UpstreamSessionRelay({
426
+ port: dshPort,
427
+ authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
428
+ })
429
+ // A listener that started before the connection service appeared must
430
+ // restart so it picks up the relay (and the now-correct fail-closed facts).
431
+ void syncGateway('connection attach')
432
+ })
433
+
324
434
  // The Settings → Plugins card reads and writes through this loopback-only
325
435
  // JSON route (ModLens-style: the browser never touches the settings seam
326
- // directly, so the card has no service dependencies to resolve).
436
+ // directly, so the card has no service dependencies to resolve). The gateway
437
+ // refuses to relay this prefix, so only the native loopback listener can
438
+ // reach it — a genuine local user, or a local process that could already read
439
+ // ~/.dsh.
327
440
  const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
328
441
  const send = (status: number, body: unknown): void => {
329
442
  res.writeHead(status, { 'content-type': 'application/json' })
330
443
  res.end(JSON.stringify(body))
331
444
  }
332
- if (!isTrustedRequest(req)) {
333
- send(403, { error: 'request refused: this route answers loopback-origin requests only' })
445
+ if (!isTrustedConfigRequest(req)) {
446
+ send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
334
447
  return
335
448
  }
336
449
  if (req.method === 'GET') {
@@ -340,6 +453,7 @@ export function apply(ctx: Context, config: Config): void {
340
453
  running: gateway !== undefined,
341
454
  port: cfg.gatewayPort,
342
455
  tls: tlsStatusLine(cfg),
456
+ upstreamSessionAvailable,
343
457
  lastError: lastError ?? null,
344
458
  })
345
459
  return
@@ -374,6 +488,19 @@ export function apply(ctx: Context, config: Config): void {
374
488
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
375
489
  return
376
490
  }
491
+ // Fail the save early (before persisting) when the candidate is unusable.
492
+ // A structural problem (legacy authRequired:false, lanPasswordless without
493
+ // a session-capable base) is invalid however it is reached; a start
494
+ // condition (plaintext without TLS/terminator/opt-in) only blocks a save
495
+ // that would actually enable the listener. This lets a disabled, dormant
496
+ // config be tuned without tripping the plaintext guard.
497
+ const structural = candidate.authRequired === false
498
+ || (candidate.lanPasswordless && !upstreamSessionAvailable)
499
+ const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable })
500
+ if (structural || (candidate.enabled && problems.length > 0)) {
501
+ send(409, { error: `config cannot start: ${problems.join(' ')}` })
502
+ return
503
+ }
377
504
  // Build the next user section: drop null/undefined and empty optionals
378
505
  // (an empty path field re-inherits the composition layer).
379
506
  const section: Record<string, unknown> = {}
@@ -393,6 +520,7 @@ export function apply(ctx: Context, config: Config): void {
393
520
  running: gateway !== undefined,
394
521
  port: cfg.gatewayPort,
395
522
  tls: tlsStatusLine(cfg),
523
+ upstreamSessionAvailable,
396
524
  lastError: lastError ?? null,
397
525
  })
398
526
  } catch (error) {
@@ -408,16 +536,18 @@ export function apply(ctx: Context, config: Config): void {
408
536
  status(): ToolResult {
409
537
  const cfg = effective()
410
538
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
539
+ const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
411
540
  return {
412
541
  ok: true,
413
542
  message:
414
543
  `LAN gateway: ${gateway !== undefined ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : 'stopped'}`
415
544
  + `\n- dsh target: 127.0.0.1:${dshPort}`
416
545
  + `\n- password: ${state.password !== undefined ? 'set' : 'NOT SET'}`
417
- + `\n- auth required for non-LAN: ${cfg.authRequired}`
418
- + `\n- trusted LAN CIDRs: ${cfg.lanCidrs.join(', ') || '(none)'}`
546
+ + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
547
+ + `\n- session epoch: ${state.sessionEpoch}`
548
+ + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
549
+ + `\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== undefined ? `TLS terminated by trusted proxy (${cfg.trustedTerminator})` : encrypted ? 'encrypted' : cfg.allowInsecurePlaintext ? 'PLAINTEXT (explicit allowInsecurePlaintext)' : 'plaintext — will not start'}`
419
550
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d`
420
- + `\n- TLS: ${tlsStatusLine(cfg)}`
421
551
  + (manualOverride !== undefined
422
552
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
423
553
  : '')
@@ -436,30 +566,48 @@ export function apply(ctx: Context, config: Config): void {
436
566
  await syncGateway('tool disable')
437
567
  return { ok: true, message: 'Gateway disabled.' }
438
568
  },
439
- setPassword(password: string | undefined): ToolResult {
569
+ async setPassword(password: string | undefined): Promise<ToolResult> {
440
570
  if (password !== undefined && password.length > 0 && password.length < 8) {
441
571
  return { ok: false, message: 'Password must be at least 8 characters.' }
442
572
  }
443
573
  const setting = password !== undefined && password.length > 0
574
+ const previous = state
444
575
  state = setPassword(state, setting ? password : undefined)
445
576
  saveState(state)
446
577
  gateway?.setState(state)
578
+ if (!setting) {
579
+ // Clearing the credential must not leave an open gateway serving
580
+ // sessions the old password authorized: stop the listener. A password
581
+ // is required to run, so a later enable fails closed.
582
+ manualOverride = false
583
+ if (gateway !== undefined) {
584
+ await stopGateway()
585
+ lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
586
+ void syncGateway('password cleared')
587
+ }
588
+ return {
589
+ ok: true,
590
+ message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
591
+ }
592
+ }
593
+ void (previous === undefined ? syncGateway('password set') : Promise.resolve())
447
594
  return {
448
595
  ok: true,
449
- message: setting
450
- ? 'Password set. Non-LAN access now requires it.'
451
- : 'Password cleared. Non-LAN access is now password-free (only safe if authRequired is false or no non-LAN sources exist).',
596
+ message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
452
597
  }
453
598
  },
454
599
  rotateSecret(): ToolResult {
455
- const next: GatewayState = { cookieSecret: randomBytes(32).toString('base64') }
600
+ const next: GatewayState = {
601
+ cookieSecret: randomBytes(32).toString('base64'),
602
+ sessionEpoch: state.sessionEpoch + 1,
603
+ }
456
604
  if (state.password !== undefined) {
457
605
  next.password = state.password
458
606
  }
459
607
  state = next
460
608
  saveState(state)
461
609
  gateway?.setState(state)
462
- return { ok: true, message: 'Session secret rotated. All existing login cookies are now invalid.' }
610
+ return { ok: true, message: 'Session secret rotated and epoch advanced. All existing login cookies and live WebSockets are now invalid.' }
463
611
  },
464
612
  async regenerateTls(): Promise<ToolResult> {
465
613
  const cfg = effective()
@@ -491,8 +639,8 @@ export function apply(ctx: Context, config: Config): void {
491
639
  ctx.tools.register(lanGatewayTool(controller))
492
640
 
493
641
  // Own the gateway lifecycle with the cordis tree.
494
- ctx.effect(async () => {
495
- await syncGateway('boot')
642
+ ctx.effect(() => {
643
+ void syncGateway('boot')
496
644
  return stopGateway
497
645
  }, 'dsh-lan-gateway: listener lifecycle')
498
646
  }
package/src/login.ts CHANGED
@@ -11,6 +11,9 @@ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:
11
11
  /** Path the gateway owns and never forwards. */
12
12
  export const LOGIN_PATH = '/__login' as const
13
13
 
14
+ /** Path the gateway owns and never forwards: signs the session out. */
15
+ export const LOGOUT_PATH = '/__logout' as const
16
+
14
17
  /** The cookie name used for the signed session. */
15
18
  export const COOKIE_NAME = 'dsh_gw_auth' as const
16
19
 
package/src/state.ts CHANGED
@@ -29,6 +29,14 @@ export interface GatewayState {
29
29
  cookieSecret: string
30
30
  /** scrypt password record, absent when no password is set. */
31
31
  password?: PasswordRecord
32
+ /**
33
+ * Session revocation epoch. Every issued login cookie carries the epoch it
34
+ * was signed under; a cookie whose epoch differs from the current one is
35
+ * rejected. Setting or clearing the password (and rotating the signing
36
+ * secret) increments the epoch so every previously issued session dies
37
+ * immediately. Old state files without the field load as epoch 0.
38
+ */
39
+ sessionEpoch: number
32
40
  }
33
41
 
34
42
  const STATE_FILENAME = 'state.json'
@@ -46,21 +54,26 @@ export function verifyPassword(state: GatewayState, password: string): boolean {
46
54
  }
47
55
  }
48
56
 
49
- /** Set (or clear) the password, re-salted on every write. */
57
+ /**
58
+ * Set (or clear) the password, re-salted on every write. Both operations bump
59
+ * the session epoch so every cookie issued under the previous epoch dies — a
60
+ * password change must invalidate sessions the old password authorized.
61
+ */
50
62
  export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
63
+ const base = { ...state, sessionEpoch: state.sessionEpoch + 1 }
51
64
  if (password === undefined) {
52
- return { cookieSecret: state.cookieSecret }
65
+ return { cookieSecret: base.cookieSecret, sessionEpoch: base.sessionEpoch }
53
66
  }
54
67
  const salt = randomBytes(16)
55
68
  const hash = scryptSync(password, salt, 64)
56
69
  return {
57
- ...state,
70
+ ...base,
58
71
  password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
59
72
  }
60
73
  }
61
74
 
62
75
  function defaultState(): GatewayState {
63
- return { cookieSecret: randomBytes(32).toString('base64') }
76
+ return { cookieSecret: randomBytes(32).toString('base64'), sessionEpoch: 0 }
64
77
  }
65
78
 
66
79
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
@@ -68,9 +81,17 @@ export function loadState(home: string = homedir()): GatewayState {
68
81
  const dir = stateDir(home)
69
82
  try {
70
83
  const raw = readFileSync(join(dir, STATE_FILENAME), 'utf8')
71
- const parsed = JSON.parse(raw) as GatewayState
84
+ const parsed = JSON.parse(raw) as Partial<GatewayState>
72
85
  if (typeof parsed?.cookieSecret === 'string' && parsed.cookieSecret.length >= 16) {
73
- return parsed
86
+ // Pre-0.5.0 files carry no sessionEpoch: treat them as epoch 0 so any
87
+ // cookie they issued (also epoch-less) still validates until the next
88
+ // password change or secret rotation bumps the epoch.
89
+ const sessionEpoch = typeof parsed.sessionEpoch === 'number' && Number.isSafeInteger(parsed.sessionEpoch)
90
+ ? parsed.sessionEpoch
91
+ : 0
92
+ const base: GatewayState = { cookieSecret: parsed.cookieSecret, sessionEpoch }
93
+ if (parsed.password !== undefined) base.password = parsed.password
94
+ return base
74
95
  }
75
96
  return defaultState()
76
97
  } catch {
package/src/tool.ts CHANGED
@@ -27,20 +27,24 @@ export function lanGatewayTool(control: GatewayController): ToolDefinition {
27
27
  description:
28
28
  'Manage the LAN/internet gateway for this DeepSeek Harness web GUI. '
29
29
  + '`status` shows whether the gateway is listening, on which port, toward which dsh port, '
30
- + 'whether a password is set, the trusted LAN CIDRs, and the TLS state. `enable` starts '
31
- + 'listening on 0.0.0.0 (loopback and LAN sources need no password; anything else must sign '
32
- + 'in). `disable` stops listening. `set-password` sets (or, with an empty password, clears) '
33
- + 'the gateway password for non-LAN access. `rotate-secret` invalidates every issued login '
34
- + 'cookie. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be '
35
- + 'self-signed) and restarts the listener.',
30
+ + 'whether a password is set, the ingress/TLS state, and the upstream-session-relay state. '
31
+ + '`enable` starts listening on 0.0.0.0 — a password is required, and by default every source '
32
+ + '(loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The '
33
+ + 'listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an '
34
+ + 'explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` '
35
+ + 'sets (or, with an empty password, clears) the gateway password; changing it revokes every '
36
+ + 'existing session, and clearing it stops the listener. `rotate-secret` invalidates every '
37
+ + 'issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed '
38
+ + 'certificate (tlsMode must be self-signed) and restarts the listener.',
36
39
  parameters: {
37
40
  command: {
38
41
  type: 'string',
39
42
  enum: ['status', 'enable', 'disable', 'set-password', 'rotate-secret', 'tls-regenerate'],
40
43
  description:
41
44
  '`status` (default) — report gateway state. `enable` / `disable` — start or stop the '
42
- + 'listener. `set-password` — set or clear the login password. `rotate-secret` — '
43
- + 'invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate.',
45
+ + 'listener. `set-password` — set or clear the login password (setting revokes all '
46
+ + 'sessions; clearing stops the listener). `rotate-secret` — invalidate all existing '
47
+ + 'sessions. `tls-regenerate` — mint a new self-signed certificate.',
44
48
  },
45
49
  password: {
46
50
  type: 'string',