@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/README.md +153 -46
- package/cordis.patch.yml +5 -2
- package/lib/client.js +22 -5
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +477 -111
- package/package.json +24 -21
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/lan-gateway-card.tsx +18 -6
- package/src/gateway.ts +230 -86
- package/src/index.ts +204 -51
- 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`.
|
|
@@ -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 {
|
|
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
|
|
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,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
|
-
/**
|
|
121
|
-
|
|
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.
|
|
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
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
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
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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)' : ''}
|
|
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 (!
|
|
333
|
-
send(403, { error: 'request refused: this route answers
|
|
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-
|
|
418
|
-
+ `\n-
|
|
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:
|
|
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 = {
|
|
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(
|
|
495
|
-
|
|
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
|
-
/**
|
|
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',
|