@riceawa/dsh-lan-gateway 0.4.0 → 0.5.1

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`.
@@ -28,8 +42,8 @@ import type { Context } from '@deepseek-ai/cordis'
28
42
  import { randomBytes } from 'node:crypto'
29
43
  import type { IncomingMessage, ServerResponse } from 'node:http'
30
44
  import z from '@deepseek-ai/schemastery'
31
- import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
32
- import { DEFAULT_LAN_CIDR_STRINGS } from './auth.ts'
45
+ import type { SettingsScope } from '@deepseek-ai/dsh-settings'
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,29 @@ 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
- /** The `lan-gateway` user-settings namespace, mirroring the composition schema. */
121
- const NS = settingsNamespace('lan-gateway')
163
+ /**
164
+ * The `lan-gateway` user-settings namespace, mirroring the composition schema.
165
+ * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
166
+ * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
167
+ * shape works against both that release line and the older branded one.
168
+ */
169
+ const NS = 'lan-gateway'
122
170
 
123
171
  /** Optional config keys: an empty submitted value clears them back to the composition layer. */
124
- const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath'])
172
+ const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
125
173
 
126
174
  /** Schemastery configuration validated by the Loader. */
127
175
  export const Config: z<Config> = z.object({
@@ -129,6 +177,7 @@ export const Config: z<Config> = z.object({
129
177
  gatewayPort: z.natural().min(1).max(65535).default(3081),
130
178
  dshTargetPort: z.natural().min(1).max(65535),
131
179
  lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
180
+ lanPasswordless: z.boolean().default(false),
132
181
  authRequired: z.boolean().default(true),
133
182
  cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
134
183
  cookieName: z.string().default('dsh_gw_auth'),
@@ -138,8 +187,46 @@ export const Config: z<Config> = z.object({
138
187
  tlsKeyPath: z.string(),
139
188
  tlsSelfSignedHosts: z.string().default('localhost'),
140
189
  tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
190
+ allowInsecurePlaintext: z.boolean().default(false),
191
+ trustedTerminator: z.string(),
141
192
  })
142
193
 
194
+ /** Facts the fail-closed start guard needs to judge a config. */
195
+ export interface StartFacts {
196
+ /** Whether the dsh base enforces browser-session auth (auto-detected). */
197
+ upstreamSessionAvailable: boolean
198
+ }
199
+
200
+ /**
201
+ * The fail-closed problems that prevent a config from enabling the listener.
202
+ * Returns every problem (not just the first) so the operator sees the full
203
+ * migration at once. Exported for tests.
204
+ */
205
+ export function gatewayStartProblems(cfg: Config, facts: StartFacts): string[] {
206
+ const problems: string[] = []
207
+ if (cfg.authRequired === false) {
208
+ problems.push(
209
+ 'authRequired=false is no longer supported — authentication is always required. '
210
+ + 'Remove `authRequired` (or set it true); for password-free LAN access set `lanPasswordless: true`.',
211
+ )
212
+ }
213
+ if (cfg.lanPasswordless && !facts.upstreamSessionAvailable) {
214
+ problems.push(
215
+ 'lanPasswordless requires a dsh base with browser-session auth (>= 0.1.2-rc.1): the gateway '
216
+ + 'relaxes only its own login, never dsh authorization. Upgrade dsh, or set lanPasswordless: false.',
217
+ )
218
+ }
219
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
220
+ if (!encryptedIngress && !cfg.allowInsecurePlaintext) {
221
+ problems.push(
222
+ 'Refusing to serve over plaintext HTTP: enable TLS (tlsEnabled: true), declare a trusted '
223
+ + 'TLS-terminating proxy (trustedTerminator), or set allowInsecurePlaintext: true to accept '
224
+ + 'the plaintext exposure (passwords and sessions would travel in clear).',
225
+ )
226
+ }
227
+ return problems
228
+ }
229
+
143
230
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
144
231
  function resolveTls(cfg: Config): TlsMaterial | undefined {
145
232
  if (!cfg.tlsEnabled) return undefined
@@ -160,7 +247,7 @@ function listenerKey(cfg: Config): string {
160
247
  cfg.gatewayPort,
161
248
  cfg.dshTargetPort,
162
249
  cfg.lanCidrs,
163
- cfg.authRequired,
250
+ cfg.lanPasswordless,
164
251
  cfg.cookieMaxAgeDays,
165
252
  cfg.cookieName,
166
253
  cfg.tlsEnabled,
@@ -169,6 +256,8 @@ function listenerKey(cfg: Config): string {
169
256
  cfg.tlsKeyPath,
170
257
  cfg.tlsSelfSignedHosts,
171
258
  cfg.tlsCertMaxAgeDays,
259
+ cfg.allowInsecurePlaintext,
260
+ cfg.trustedTerminator,
172
261
  ])
173
262
  }
174
263
 
@@ -199,13 +288,17 @@ function isLoopbackHost(hostname: string): boolean {
199
288
  )
200
289
  }
201
290
 
291
+ const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
292
+
202
293
  /**
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.
294
+ * Same-origin loopback fence for the native `/lan-gateway/config` route. The
295
+ * gateway refuses to relay this prefix, so the only way in is the native
296
+ * loopback listener itself (a genuine local user, or a local process that could
297
+ * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
298
+ * cross-site fetches are refused, an Origin must match the Host the browser
299
+ * used, and a state-changing method must carry that Origin. Exported for tests.
207
300
  */
208
- function isTrustedRequest(req: IncomingMessage): boolean {
301
+ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
209
302
  const host = req.headers?.host
210
303
  if (typeof host !== 'string' || host === '') return false
211
304
  let hostUrl: URL
@@ -217,12 +310,10 @@ function isTrustedRequest(req: IncomingMessage): boolean {
217
310
  if (!isLoopbackHost(hostUrl.hostname)) return false
218
311
  if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
219
312
  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
- }
313
+ if (origin !== undefined && !originMatchesHost(origin, host)) return false
314
+ const method = req.method ?? 'GET'
315
+ if (!READ_ONLY_METHODS.has(method) && origin === undefined) return false
316
+ return true
226
317
  }
227
318
 
228
319
  export function apply(ctx: Context, config: Config): void {
@@ -231,6 +322,10 @@ export function apply(ctx: Context, config: Config): void {
231
322
  let startedWith: string | undefined
232
323
  let lastError: string | undefined
233
324
  let manualOverride: boolean | undefined
325
+ /** Whether the base enforces browser-session auth; set once `connection` is seen. */
326
+ let upstreamSessionAvailable = false
327
+ /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
328
+ let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
234
329
  /** The authoritative config: settings section when attached, else composition. */
235
330
  let configSource: () => Config = () => config
236
331
  /** Serializes listener start/stop/restart so settings changes cannot race. */
@@ -240,30 +335,34 @@ export function apply(ctx: Context, config: Config): void {
240
335
 
241
336
  const startGateway = async (cfg: Config): Promise<void> => {
242
337
  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
- )
338
+ const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
339
+ if (state.password === undefined) {
340
+ problems.unshift('no password set — run `lan_gateway set-password` before enabling the listener')
341
+ }
342
+ if (problems.length > 0) {
343
+ throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(' ')}`)
250
344
  }
251
345
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
252
346
  const tls = resolveTls(cfg)
347
+ const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
253
348
  const next = new LanGateway({
254
349
  gatewayPort: cfg.gatewayPort,
255
350
  dshPort,
256
351
  lanCidrs: cfg.lanCidrs,
257
- authRequired: cfg.authRequired,
352
+ lanPasswordless: cfg.lanPasswordless,
258
353
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
259
354
  cookieName: cfg.cookieName,
355
+ secureCookies: encryptedIngress,
260
356
  ...(tls !== undefined ? { tls } : {}),
357
+ ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
261
358
  }, state)
262
359
  await next.listen()
263
360
  gateway = next
264
361
  startedWith = listenerKey(cfg)
265
362
  ctx.logger.info(
266
- `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''} -> 127.0.0.1:${dshPort}`,
363
+ `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
364
+ + ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
365
+ + `${makeRelay !== undefined ? ' [shared upstream session relay]' : ' [no upstream session relay: base has no browser-session auth]'}`,
267
366
  )
268
367
  }
269
368
 
@@ -321,16 +420,35 @@ export function apply(ctx: Context, config: Config): void {
321
420
  void syncGateway('settings attach')
322
421
  })
323
422
 
423
+ // A session-capable dsh base exposes the `connection` service (0.1.2+). The
424
+ // presence of that service both (a) tells the fail-closed guard that the base
425
+ // itself authenticates and (b) supplies the launch-token URL the shared-session
426
+ // relay exchanges. Optional: on an older base the callback never runs, the
427
+ // gateway forwards without a relay, and lanPasswordless stays refused.
428
+ ctx.inject(['connection'], (ccx) => {
429
+ upstreamSessionAvailable = true
430
+ makeRelay = (dshPort) => new UpstreamSessionRelay({
431
+ port: dshPort,
432
+ authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
433
+ })
434
+ // A listener that started before the connection service appeared must
435
+ // restart so it picks up the relay (and the now-correct fail-closed facts).
436
+ void syncGateway('connection attach')
437
+ })
438
+
324
439
  // The Settings → Plugins card reads and writes through this loopback-only
325
440
  // JSON route (ModLens-style: the browser never touches the settings seam
326
- // directly, so the card has no service dependencies to resolve).
441
+ // directly, so the card has no service dependencies to resolve). The gateway
442
+ // refuses to relay this prefix, so only the native loopback listener can
443
+ // reach it — a genuine local user, or a local process that could already read
444
+ // ~/.dsh.
327
445
  const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
328
446
  const send = (status: number, body: unknown): void => {
329
447
  res.writeHead(status, { 'content-type': 'application/json' })
330
448
  res.end(JSON.stringify(body))
331
449
  }
332
- if (!isTrustedRequest(req)) {
333
- send(403, { error: 'request refused: this route answers loopback-origin requests only' })
450
+ if (!isTrustedConfigRequest(req)) {
451
+ send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
334
452
  return
335
453
  }
336
454
  if (req.method === 'GET') {
@@ -340,6 +458,7 @@ export function apply(ctx: Context, config: Config): void {
340
458
  running: gateway !== undefined,
341
459
  port: cfg.gatewayPort,
342
460
  tls: tlsStatusLine(cfg),
461
+ upstreamSessionAvailable,
343
462
  lastError: lastError ?? null,
344
463
  })
345
464
  return
@@ -374,6 +493,19 @@ export function apply(ctx: Context, config: Config): void {
374
493
  send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
375
494
  return
376
495
  }
496
+ // Fail the save early (before persisting) when the candidate is unusable.
497
+ // A structural problem (legacy authRequired:false, lanPasswordless without
498
+ // a session-capable base) is invalid however it is reached; a start
499
+ // condition (plaintext without TLS/terminator/opt-in) only blocks a save
500
+ // that would actually enable the listener. This lets a disabled, dormant
501
+ // config be tuned without tripping the plaintext guard.
502
+ const structural = candidate.authRequired === false
503
+ || (candidate.lanPasswordless && !upstreamSessionAvailable)
504
+ const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable })
505
+ if (structural || (candidate.enabled && problems.length > 0)) {
506
+ send(409, { error: `config cannot start: ${problems.join(' ')}` })
507
+ return
508
+ }
377
509
  // Build the next user section: drop null/undefined and empty optionals
378
510
  // (an empty path field re-inherits the composition layer).
379
511
  const section: Record<string, unknown> = {}
@@ -393,6 +525,7 @@ export function apply(ctx: Context, config: Config): void {
393
525
  running: gateway !== undefined,
394
526
  port: cfg.gatewayPort,
395
527
  tls: tlsStatusLine(cfg),
528
+ upstreamSessionAvailable,
396
529
  lastError: lastError ?? null,
397
530
  })
398
531
  } catch (error) {
@@ -408,16 +541,18 @@ export function apply(ctx: Context, config: Config): void {
408
541
  status(): ToolResult {
409
542
  const cfg = effective()
410
543
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
544
+ const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
411
545
  return {
412
546
  ok: true,
413
547
  message:
414
548
  `LAN gateway: ${gateway !== undefined ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : 'stopped'}`
415
549
  + `\n- dsh target: 127.0.0.1:${dshPort}`
416
550
  + `\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)'}`
551
+ + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
552
+ + `\n- session epoch: ${state.sessionEpoch}`
553
+ + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
554
+ + `\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
555
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d`
420
- + `\n- TLS: ${tlsStatusLine(cfg)}`
421
556
  + (manualOverride !== undefined
422
557
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
423
558
  : '')
@@ -436,30 +571,48 @@ export function apply(ctx: Context, config: Config): void {
436
571
  await syncGateway('tool disable')
437
572
  return { ok: true, message: 'Gateway disabled.' }
438
573
  },
439
- setPassword(password: string | undefined): ToolResult {
574
+ async setPassword(password: string | undefined): Promise<ToolResult> {
440
575
  if (password !== undefined && password.length > 0 && password.length < 8) {
441
576
  return { ok: false, message: 'Password must be at least 8 characters.' }
442
577
  }
443
578
  const setting = password !== undefined && password.length > 0
579
+ const previous = state
444
580
  state = setPassword(state, setting ? password : undefined)
445
581
  saveState(state)
446
582
  gateway?.setState(state)
583
+ if (!setting) {
584
+ // Clearing the credential must not leave an open gateway serving
585
+ // sessions the old password authorized: stop the listener. A password
586
+ // is required to run, so a later enable fails closed.
587
+ manualOverride = false
588
+ if (gateway !== undefined) {
589
+ await stopGateway()
590
+ lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
591
+ void syncGateway('password cleared')
592
+ }
593
+ return {
594
+ ok: true,
595
+ message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
596
+ }
597
+ }
598
+ void (previous === undefined ? syncGateway('password set') : Promise.resolve())
447
599
  return {
448
600
  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).',
601
+ message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
452
602
  }
453
603
  },
454
604
  rotateSecret(): ToolResult {
455
- const next: GatewayState = { cookieSecret: randomBytes(32).toString('base64') }
605
+ const next: GatewayState = {
606
+ cookieSecret: randomBytes(32).toString('base64'),
607
+ sessionEpoch: state.sessionEpoch + 1,
608
+ }
456
609
  if (state.password !== undefined) {
457
610
  next.password = state.password
458
611
  }
459
612
  state = next
460
613
  saveState(state)
461
614
  gateway?.setState(state)
462
- return { ok: true, message: 'Session secret rotated. All existing login cookies are now invalid.' }
615
+ return { ok: true, message: 'Session secret rotated and epoch advanced. All existing login cookies and live WebSockets are now invalid.' }
463
616
  },
464
617
  async regenerateTls(): Promise<ToolResult> {
465
618
  const cfg = effective()
@@ -491,8 +644,8 @@ export function apply(ctx: Context, config: Config): void {
491
644
  ctx.tools.register(lanGatewayTool(controller))
492
645
 
493
646
  // Own the gateway lifecycle with the cordis tree.
494
- ctx.effect(async () => {
495
- await syncGateway('boot')
647
+ ctx.effect(() => {
648
+ void syncGateway('boot')
496
649
  return stopGateway
497
650
  }, 'dsh-lan-gateway: listener lifecycle')
498
651
  }
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',