@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/README.md +145 -55
- package/cordis.patch.yml +5 -2
- package/lib/client.js +78 -44
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +470 -108
- package/package.json +2 -2
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/index.ts +16 -2
- package/src/client/lan-gateway-card.tsx +56 -21
- package/src/gateway.ts +230 -86
- package/src/index.ts +196 -48
- package/src/login.ts +3 -0
- package/src/state.ts +27 -6
- package/src/tool.ts +12 -8
- package/src/upstream-session.ts +187 -0
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
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
120
|
+
/** LAN CIDRs that may be treated as trusted when `lanPasswordless` is on. */
|
|
99
121
|
lanCidrs: string[]
|
|
100
|
-
/**
|
|
101
|
-
|
|
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.
|
|
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
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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)' : ''}
|
|
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 (!
|
|
333
|
-
send(403, { error: 'request refused: this route answers
|
|
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-
|
|
418
|
-
+ `\n-
|
|
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:
|
|
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 = {
|
|
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(
|
|
495
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
31
|
-
+ 'listening on 0.0.0.0
|
|
32
|
-
+ '
|
|
33
|
-
+ '
|
|
34
|
-
+ '
|
|
35
|
-
+ '
|
|
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
|
|
43
|
-
+ '
|
|
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',
|