@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5
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 +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/src/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
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
|
-
* own reverse-proxy gateway on
|
|
7
|
+
* own reverse-proxy gateway on the unspecified address — both families, so
|
|
8
|
+
* IPv6 clients reach it too — that forwards to the loopback dsh port,
|
|
8
9
|
* rewriting Host/Origin so the request reaches the dsh web server as if it came
|
|
9
10
|
* from the loopback authority it names.
|
|
10
11
|
*
|
|
@@ -22,8 +23,10 @@
|
|
|
22
23
|
* TLS-terminating proxy, or an explicit `allowInsecurePlaintext` opt-in is
|
|
23
24
|
* present.
|
|
24
25
|
* - The gateway never relays its own surface (`/lan-gateway/*`, the login and
|
|
25
|
-
* logout pages)
|
|
26
|
-
*
|
|
26
|
+
* logout pages). Sessions are revocable: each carries a random id, so signing
|
|
27
|
+
* out retires that one session and the WebSockets it opened, and each carries
|
|
28
|
+
* a revocation epoch, so a password change or secret rotation kills every
|
|
29
|
+
* session at once.
|
|
27
30
|
*
|
|
28
31
|
* Every tunable is also exposed as the `lan-gateway` user-settings namespace
|
|
29
32
|
* (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
|
|
@@ -40,12 +43,20 @@
|
|
|
40
43
|
|
|
41
44
|
import type { Context } from '@deepseek-ai/cordis'
|
|
42
45
|
import { randomBytes } from 'node:crypto'
|
|
43
|
-
import type { IncomingMessage, ServerResponse } from '
|
|
46
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
44
47
|
import z from '@deepseek-ai/schemastery'
|
|
45
|
-
import type { SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
48
|
+
import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
46
49
|
import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
|
|
50
|
+
import {
|
|
51
|
+
CONFIG_FIELD_KEYS,
|
|
52
|
+
OPTIONAL_CONFIG_KEYS,
|
|
53
|
+
} from './config-fields.ts'
|
|
47
54
|
import { LanGateway } from './gateway.ts'
|
|
48
55
|
import { readBody } from './login.ts'
|
|
56
|
+
import {
|
|
57
|
+
isLoopbackHost,
|
|
58
|
+
READ_ONLY_METHODS,
|
|
59
|
+
} from './request-policy.ts'
|
|
49
60
|
import {
|
|
50
61
|
loadState,
|
|
51
62
|
saveState,
|
|
@@ -54,9 +65,10 @@ import {
|
|
|
54
65
|
} from './state.ts'
|
|
55
66
|
import {
|
|
56
67
|
describeCert,
|
|
68
|
+
loadOrRenewSelfSigned,
|
|
57
69
|
loadCustomCert,
|
|
58
|
-
loadOrCreateSelfSigned,
|
|
59
70
|
parseSelfSignedHosts,
|
|
71
|
+
readSelfSignedStatus,
|
|
60
72
|
regenerateSelfSigned,
|
|
61
73
|
type TlsMaterial,
|
|
62
74
|
} from './tls.ts'
|
|
@@ -113,7 +125,7 @@ export interface GatewayController {
|
|
|
113
125
|
export interface Config {
|
|
114
126
|
/** Whether the gateway listener is started at boot. Default false (safe). */
|
|
115
127
|
enabled: boolean
|
|
116
|
-
/** Port to bind on
|
|
128
|
+
/** Port to bind on the unspecified address, both address families. */
|
|
117
129
|
gatewayPort: number
|
|
118
130
|
/** Explicit dsh target port; defaults to the live `ctx.webServer.port`. */
|
|
119
131
|
dshTargetPort?: number
|
|
@@ -128,12 +140,17 @@ export interface Config {
|
|
|
128
140
|
/**
|
|
129
141
|
* Removed capability: authentication is always required. Retained only so an
|
|
130
142
|
* explicit legacy `authRequired: false` is rejected loudly instead of
|
|
131
|
-
* silently ignored.
|
|
143
|
+
* silently ignored. Not card-editable and never written back to the user
|
|
144
|
+
* section — the config route builds its patch from the editable key set.
|
|
132
145
|
*/
|
|
133
146
|
authRequired?: boolean
|
|
134
147
|
/** Session cookie lifetime in days. */
|
|
135
148
|
cookieMaxAgeDays: number
|
|
136
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Cookie name. Deliberately not card-editable — see the editable key set in
|
|
151
|
+
* `config-fields.ts`; the config route applies a patch, so an operator's
|
|
152
|
+
* custom name survives every save from the Settings card.
|
|
153
|
+
*/
|
|
137
154
|
cookieName: string
|
|
138
155
|
/** Whether the gateway listener speaks TLS. */
|
|
139
156
|
tlsEnabled: boolean
|
|
@@ -143,9 +160,28 @@ export interface Config {
|
|
|
143
160
|
tlsCertPath?: string
|
|
144
161
|
/** Custom mode: path to the PEM private key. */
|
|
145
162
|
tlsKeyPath?: string
|
|
146
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Self-signed mode: comma/space separated DNS names and IPs for the SANs.
|
|
165
|
+
*
|
|
166
|
+
* Read when a certificate is generated, not when it is served: changing it
|
|
167
|
+
* does not replace a certificate that already exists and is still valid. Use
|
|
168
|
+
* `lan_gateway tls-regenerate` for that.
|
|
169
|
+
*/
|
|
147
170
|
tlsSelfSignedHosts?: string
|
|
148
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Self-signed certificate validity in days (default 825 ≈ 27 months).
|
|
173
|
+
*
|
|
174
|
+
* 825 is the ceiling Apple states for TLS server certificates, and the
|
|
175
|
+
* well-known 398-day limit — which this default is sometimes mistaken for
|
|
176
|
+
* exceeding — applies only to certificates chaining to a root preinstalled
|
|
177
|
+
* by the platform: Apple exempts user- and administrator-added roots
|
|
178
|
+
* outright, and a self-signed certificate is always one of those. Since a
|
|
179
|
+
* self-signed certificate is either clicked through or trusted by hand,
|
|
180
|
+
* there is nothing to gain from the shorter window and a re-trust to lose
|
|
181
|
+
* every time it lapses.
|
|
182
|
+
*
|
|
183
|
+
* Like `tlsSelfSignedHosts`, this applies to the next generation only.
|
|
184
|
+
*/
|
|
149
185
|
tlsCertMaxAgeDays: number
|
|
150
186
|
/**
|
|
151
187
|
* Escape hatch (default false): permit plaintext HTTP. Never derived from
|
|
@@ -156,6 +192,11 @@ export interface Config {
|
|
|
156
192
|
* An identifier for a trusted TLS-terminating proxy in front of the gateway.
|
|
157
193
|
* Declaring one marks the ingress encrypted (Secure cookies, passes the
|
|
158
194
|
* encrypted-ingress gate) without this listener sending HSTS.
|
|
195
|
+
*
|
|
196
|
+
* Note the coupling with login rate limiting: the limiter is keyed by
|
|
197
|
+
* `socket.remoteAddress`, and `X-Forwarded-For` is deliberately untrusted, so
|
|
198
|
+
* behind such a proxy every browser shares one bucket — the login budget
|
|
199
|
+
* becomes per-deployment, not per-client.
|
|
159
200
|
*/
|
|
160
201
|
trustedTerminator?: string
|
|
161
202
|
/**
|
|
@@ -176,9 +217,6 @@ export interface Config {
|
|
|
176
217
|
*/
|
|
177
218
|
const NS = 'lan-gateway'
|
|
178
219
|
|
|
179
|
-
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
180
|
-
const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
|
|
181
|
-
|
|
182
220
|
/** Schemastery configuration validated by the Loader. */
|
|
183
221
|
export const Config: z<Config> = z.object({
|
|
184
222
|
enabled: z.boolean().default(false),
|
|
@@ -259,17 +297,16 @@ export function resolveSecureCookies(
|
|
|
259
297
|
}
|
|
260
298
|
|
|
261
299
|
/** Resolve the TLS material for a config, or undefined when TLS is off. */
|
|
262
|
-
function resolveTls(cfg: Config): TlsMaterial | undefined {
|
|
300
|
+
function resolveTls(cfg: Config): { material: TlsMaterial; renewed: boolean } | undefined {
|
|
263
301
|
if (!cfg.tlsEnabled) return undefined
|
|
264
302
|
if (cfg.tlsMode === 'custom') {
|
|
265
|
-
return loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? '')
|
|
303
|
+
return { material: loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? ''), renewed: false }
|
|
266
304
|
}
|
|
267
305
|
const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
|
|
268
306
|
if (hosts.length === 0) {
|
|
269
307
|
throw new Error('tlsSelfSignedHosts must name at least one host (DNS name or IP)')
|
|
270
308
|
}
|
|
271
|
-
|
|
272
|
-
return material
|
|
309
|
+
return loadOrRenewSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
|
|
273
310
|
}
|
|
274
311
|
|
|
275
312
|
/**
|
|
@@ -301,35 +338,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
|
|
|
301
338
|
])
|
|
302
339
|
}
|
|
303
340
|
|
|
304
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* One-line TLS description for status output.
|
|
343
|
+
*
|
|
344
|
+
* Never generates: this is the read path behind `GET /lan-gateway/config` and
|
|
345
|
+
* `lan_gateway status`, and a status query that mints an RSA key and writes a
|
|
346
|
+
* certificate to disk is not a read. The material is created when the listener
|
|
347
|
+
* starts, or by `lan_gateway tls-regenerate`.
|
|
348
|
+
*/
|
|
305
349
|
function tlsStatusLine(cfg: Config): string {
|
|
306
350
|
if (!cfg.tlsEnabled) return 'off'
|
|
307
351
|
if (cfg.tlsMode === 'custom') {
|
|
308
352
|
return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
|
|
309
353
|
}
|
|
310
354
|
try {
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
const info = describeCert(
|
|
355
|
+
const status = readSelfSignedStatus()
|
|
356
|
+
if (status === undefined) return 'self-signed (not generated yet — created when the listener starts)'
|
|
357
|
+
const info = describeCert(status.cert)
|
|
314
358
|
return `self-signed [${info.subject}] exp ${info.validTo}`
|
|
315
359
|
} catch (error) {
|
|
316
360
|
return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
|
|
317
361
|
}
|
|
318
362
|
}
|
|
319
363
|
|
|
320
|
-
/** Whether `hostname` is loopback (127/8, localhost, ::1). */
|
|
321
|
-
function isLoopbackHost(hostname: string): boolean {
|
|
322
|
-
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
323
|
-
const parts = hostname.split('.')
|
|
324
|
-
return (
|
|
325
|
-
parts.length === 4
|
|
326
|
-
&& parts[0] === '127'
|
|
327
|
-
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
328
|
-
)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
332
|
-
|
|
333
364
|
/**
|
|
334
365
|
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
335
366
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
@@ -356,23 +387,106 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
|
|
|
356
387
|
return true
|
|
357
388
|
}
|
|
358
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Turn a submitted config patch into the next user section: only keys the card
|
|
392
|
+
* can edit, only real values, and `null` (or an emptied optional) removes the
|
|
393
|
+
* key rather than storing it.
|
|
394
|
+
*
|
|
395
|
+
* The patch is built from the *submitted* object, never from a schema call's
|
|
396
|
+
* output. Schemastery fills defaults into whatever it validates and passes
|
|
397
|
+
* unknown keys through, so deriving the section from `Config(submitted)` wrote
|
|
398
|
+
* `authRequired: true` (a capability that exists only to be refused) and any
|
|
399
|
+
* stray key into the user's settings on every save — and, because it also
|
|
400
|
+
* materialized `cookieName`, reset an operator's custom cookie name to the
|
|
401
|
+
* schema default.
|
|
402
|
+
*
|
|
403
|
+
* A `null` value is the card's clear: the key is dropped from the patch, which
|
|
404
|
+
* leaves it absent from the section, so it re-inherits the composition layer.
|
|
405
|
+
*/
|
|
406
|
+
export function buildConfigPatch(submitted: Record<string, unknown>): {
|
|
407
|
+
patch: Record<string, unknown>
|
|
408
|
+
clear: string[]
|
|
409
|
+
unknown: string[]
|
|
410
|
+
} {
|
|
411
|
+
const patch: Record<string, unknown> = {}
|
|
412
|
+
const clear: string[] = []
|
|
413
|
+
const unknown: string[] = []
|
|
414
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
415
|
+
if (!CONFIG_FIELD_KEYS.has(key)) {
|
|
416
|
+
unknown.push(key)
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if (value === null || value === undefined) {
|
|
420
|
+
clear.push(key)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) {
|
|
424
|
+
clear.push(key)
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
// An empty string on a non-optional text field is a real value the card
|
|
428
|
+
// refuses to submit, but a hand-written POST could send one; let the schema
|
|
429
|
+
// reject it rather than inventing a meaning here.
|
|
430
|
+
patch[key] = value
|
|
431
|
+
}
|
|
432
|
+
return { patch, clear, unknown }
|
|
433
|
+
}
|
|
434
|
+
|
|
359
435
|
export function apply(ctx: Context, config: Config): void {
|
|
360
436
|
let state = loadState()
|
|
361
437
|
let gateway: LanGateway | undefined
|
|
362
438
|
let startedWith: string | undefined
|
|
363
439
|
let lastError: string | undefined
|
|
440
|
+
/**
|
|
441
|
+
* The operator's run intent, used only while no settings service is attached.
|
|
442
|
+
* With settings present, `enabled` in the settings section *is* the intent —
|
|
443
|
+
* the card and the tool write the same field, so there is one truth rather
|
|
444
|
+
* than two that disagree.
|
|
445
|
+
*/
|
|
364
446
|
let manualOverride: boolean | undefined
|
|
365
447
|
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
366
448
|
let upstreamSessionAvailable = false
|
|
449
|
+
/**
|
|
450
|
+
* Identifies the current `connection` handler. A provider that detaches and a
|
|
451
|
+
* new one that attaches run their disposers in an order the plugin does not
|
|
452
|
+
* control, and a stale disposer clearing `makeRelay` would strand the live
|
|
453
|
+
* provider — so a disposer only acts if it is still the latest generation.
|
|
454
|
+
*/
|
|
455
|
+
let connectionGeneration = 0
|
|
367
456
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
368
457
|
let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
|
|
369
458
|
/** The authoritative config: settings section when attached, else composition. */
|
|
370
459
|
let configSource: () => Config = () => config
|
|
371
|
-
/**
|
|
372
|
-
let
|
|
460
|
+
/** Whether writes go to the settings section rather than staying in memory. */
|
|
461
|
+
let settingsAttached = false
|
|
462
|
+
/** The settings scope for the `lan-gateway` namespace, while one is attached. */
|
|
463
|
+
let settingsScope: SettingsScope<Config> | undefined
|
|
464
|
+
/**
|
|
465
|
+
* The settings provider, for the one write a scope cannot express: a section
|
|
466
|
+
* key must be *removed* to re-inherit the composition layer, and only the
|
|
467
|
+
* provider's path-addressed `mutate` can unset one.
|
|
468
|
+
*/
|
|
469
|
+
let settingsProvider: SettingsProvider | undefined
|
|
470
|
+
/**
|
|
471
|
+
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
472
|
+
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
473
|
+
* two of them can never interleave a stop with a start.
|
|
474
|
+
*/
|
|
475
|
+
let lifecycle: Promise<void> = Promise.resolve()
|
|
476
|
+
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
477
|
+
let disposed = false
|
|
373
478
|
|
|
374
479
|
const effective = (): Config => configSource()
|
|
375
480
|
|
|
481
|
+
/** Queue one lifecycle action behind every action already running. */
|
|
482
|
+
const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
|
|
483
|
+
lifecycle = lifecycle.then(action).catch((error: unknown) => {
|
|
484
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
485
|
+
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
|
|
486
|
+
})
|
|
487
|
+
return lifecycle
|
|
488
|
+
}
|
|
489
|
+
|
|
376
490
|
const startGateway = async (cfg: Config): Promise<void> => {
|
|
377
491
|
if (gateway !== undefined) return
|
|
378
492
|
const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
|
|
@@ -383,7 +497,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
383
497
|
throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(' ')}`)
|
|
384
498
|
}
|
|
385
499
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
|
|
386
|
-
const
|
|
500
|
+
const resolved = resolveTls(cfg)
|
|
501
|
+
const tls = resolved?.material
|
|
387
502
|
const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
|
|
388
503
|
const secureCookies = resolveSecureCookies(cfg)
|
|
389
504
|
const next = new LanGateway({
|
|
@@ -396,15 +511,35 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
396
511
|
secureCookies,
|
|
397
512
|
...(tls !== undefined ? { tls } : {}),
|
|
398
513
|
...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
|
|
514
|
+
onStateChange: (updated) => {
|
|
515
|
+
// The gateway retired a session itself (sign-out). Keep the plugin's
|
|
516
|
+
// copy and the state file in step, or a restart would resurrect a
|
|
517
|
+
// session the user signed out of.
|
|
518
|
+
state = updated
|
|
519
|
+
saveState(state)
|
|
520
|
+
},
|
|
399
521
|
}, state)
|
|
400
522
|
await next.listen()
|
|
523
|
+
// The listen above is asynchronous and the tree can be disposed while it is
|
|
524
|
+
// in flight. Publishing the listener after that would leave a socket owned
|
|
525
|
+
// by nobody — the dispose hook already ran and saw `gateway` undefined.
|
|
526
|
+
if (disposed) {
|
|
527
|
+
await next.close()
|
|
528
|
+
return
|
|
529
|
+
}
|
|
401
530
|
gateway = next
|
|
402
531
|
startedWith = listenerKey(cfg, makeRelay !== undefined)
|
|
403
532
|
ctx.logger.info(
|
|
404
|
-
`dsh-lan-gateway: listening on
|
|
533
|
+
`dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== undefined ? ' (TLS)' : ''}`
|
|
405
534
|
+ ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
|
|
406
535
|
+ `${makeRelay !== undefined ? ' [shared upstream session relay]' : ' [no upstream session relay: base has no browser-session auth]'}`,
|
|
407
536
|
)
|
|
537
|
+
if (resolved?.renewed === true) {
|
|
538
|
+
ctx.logger.warn(
|
|
539
|
+
'dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one '
|
|
540
|
+
+ '— clients that had trusted the old certificate must trust the new one.',
|
|
541
|
+
)
|
|
542
|
+
}
|
|
408
543
|
}
|
|
409
544
|
|
|
410
545
|
const stopGateway = async (): Promise<void> => {
|
|
@@ -417,46 +552,70 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
417
552
|
}
|
|
418
553
|
}
|
|
419
554
|
|
|
555
|
+
/** The config the listener should be running under, intent included. */
|
|
556
|
+
const desiredConfig = (): Config => {
|
|
557
|
+
const cfg = effective()
|
|
558
|
+
// Without a settings service there is nowhere to record the tool's intent,
|
|
559
|
+
// so it lives in memory as an override on the composition entry. With one
|
|
560
|
+
// attached, `enabled` already carries it and an override would shadow the
|
|
561
|
+
// card — the defect this replaces.
|
|
562
|
+
if (settingsAttached) return cfg
|
|
563
|
+
return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
|
|
564
|
+
}
|
|
565
|
+
|
|
420
566
|
/** Reconcile the listener with the effective config (start/stop/restart). */
|
|
421
567
|
const syncGateway = (reason: string): Promise<void> => {
|
|
422
|
-
|
|
568
|
+
return enqueue(reason, async () => {
|
|
423
569
|
lastError = undefined
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
await startGateway(cfg)
|
|
434
|
-
}
|
|
435
|
-
} catch (error) {
|
|
436
|
-
lastError = error instanceof Error ? error.message : String(error)
|
|
437
|
-
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
|
|
570
|
+
if (disposed) return
|
|
571
|
+
const cfg = desiredConfig()
|
|
572
|
+
if (gateway === undefined) {
|
|
573
|
+
if (cfg.enabled) await startGateway(cfg)
|
|
574
|
+
} else if (!cfg.enabled) {
|
|
575
|
+
await stopGateway()
|
|
576
|
+
} else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
|
|
577
|
+
await stopGateway()
|
|
578
|
+
await startGateway(cfg)
|
|
438
579
|
}
|
|
439
580
|
})
|
|
440
|
-
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Record the run intent where it will survive: the settings section, or memory. */
|
|
584
|
+
const setRunIntent = async (enabled: boolean): Promise<void> => {
|
|
585
|
+
if (settingsAttached && settingsScope !== undefined) {
|
|
586
|
+
// The same field the Settings card writes. A merge patch, so nothing else
|
|
587
|
+
// in the user's section is disturbed.
|
|
588
|
+
await settingsScope.update({ enabled })
|
|
589
|
+
// The section's watcher queues the reconcile; it observes committed
|
|
590
|
+
// changes, so waiting on it here would deadlock behind this same write.
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
manualOverride = enabled
|
|
441
594
|
}
|
|
442
595
|
|
|
443
596
|
// The tunables also live in the `lan-gateway` settings section: while the
|
|
444
597
|
// settings service exists, the section (composition base + user overrides)
|
|
445
598
|
// is the authoritative config, and every committed change re-syncs the
|
|
446
599
|
// listener — so the Settings → Plugins page adjusts the gateway live.
|
|
447
|
-
// Registered directly (not via
|
|
448
|
-
//
|
|
449
|
-
let settingsScope: SettingsScope<Config> | undefined
|
|
600
|
+
// Registered directly (not via installSection) so the scope handle is
|
|
601
|
+
// available to the /lan-gateway/config route for writes.
|
|
450
602
|
ctx.inject(['settings'], (sctx) => {
|
|
451
603
|
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
452
604
|
settingsScope = scope
|
|
605
|
+
settingsProvider = sctx.settings
|
|
606
|
+
settingsAttached = true
|
|
453
607
|
configSource = () => scope.get()
|
|
454
608
|
sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
|
|
455
609
|
sctx.effect(() => () => {
|
|
456
|
-
// The settings provider went away (disposal / provider reload): fall
|
|
457
|
-
//
|
|
610
|
+
// The settings provider went away (disposal / provider reload): fall back
|
|
611
|
+
// to the composition entry so the plugin keeps working as composed, and
|
|
612
|
+
// reconcile so the listener follows the config that is now authoritative
|
|
613
|
+
// instead of staying on the section's last value.
|
|
458
614
|
configSource = () => config
|
|
459
615
|
settingsScope = undefined
|
|
616
|
+
settingsProvider = undefined
|
|
617
|
+
settingsAttached = false
|
|
618
|
+
void syncGateway('settings detach')
|
|
460
619
|
})
|
|
461
620
|
void syncGateway('settings attach')
|
|
462
621
|
})
|
|
@@ -467,6 +626,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
467
626
|
// relay exchanges. Optional: on an older base the callback never runs, the
|
|
468
627
|
// gateway forwards without a relay, and lanPasswordless stays refused.
|
|
469
628
|
ctx.inject(['connection'], (ccx) => {
|
|
629
|
+
const generation = ++connectionGeneration
|
|
470
630
|
upstreamSessionAvailable = true
|
|
471
631
|
ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
|
|
472
632
|
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
@@ -476,6 +636,15 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
476
636
|
// and looks exactly like a base with no browser sessions.
|
|
477
637
|
log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
|
|
478
638
|
})
|
|
639
|
+
ccx.effect(() => () => {
|
|
640
|
+
// The provider went away. Drop the relay factory rather than hold one
|
|
641
|
+
// bound to a disposed context, and let the listenerKey see the change so
|
|
642
|
+
// the running listener does not keep serving through a dead provider.
|
|
643
|
+
if (generation !== connectionGeneration) return
|
|
644
|
+
makeRelay = undefined
|
|
645
|
+
upstreamSessionAvailable = false
|
|
646
|
+
void syncGateway('connection detach')
|
|
647
|
+
})
|
|
479
648
|
// A listener that started before the connection service appeared must
|
|
480
649
|
// restart so it picks up the relay (and the now-correct fail-closed facts).
|
|
481
650
|
void syncGateway('connection attach')
|
|
@@ -488,6 +657,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
488
657
|
// reach it — a genuine local user, or a local process that could already read
|
|
489
658
|
// ~/.dsh.
|
|
490
659
|
const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
660
|
+
const snapshot = (): Record<string, unknown> => {
|
|
661
|
+
const cfg = effective()
|
|
662
|
+
return {
|
|
663
|
+
config: cfg,
|
|
664
|
+
running: gateway !== undefined,
|
|
665
|
+
port: cfg.gatewayPort,
|
|
666
|
+
tls: tlsStatusLine(cfg),
|
|
667
|
+
upstreamSessionAvailable,
|
|
668
|
+
lastError: lastError ?? null,
|
|
669
|
+
}
|
|
670
|
+
}
|
|
491
671
|
const send = (status: number, body: unknown): void => {
|
|
492
672
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
493
673
|
res.end(JSON.stringify(body))
|
|
@@ -497,15 +677,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
497
677
|
return
|
|
498
678
|
}
|
|
499
679
|
if (req.method === 'GET') {
|
|
500
|
-
|
|
501
|
-
send(200, {
|
|
502
|
-
config: cfg,
|
|
503
|
-
running: gateway !== undefined,
|
|
504
|
-
port: cfg.gatewayPort,
|
|
505
|
-
tls: tlsStatusLine(cfg),
|
|
506
|
-
upstreamSessionAvailable,
|
|
507
|
-
lastError: lastError ?? null,
|
|
508
|
-
})
|
|
680
|
+
send(200, snapshot())
|
|
509
681
|
return
|
|
510
682
|
}
|
|
511
683
|
if (req.method !== 'POST') {
|
|
@@ -525,20 +697,18 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
525
697
|
send(400, { error: 'body must be a config object' })
|
|
526
698
|
return
|
|
527
699
|
}
|
|
528
|
-
|
|
529
|
-
// descriptive message on any invalid value.
|
|
530
|
-
let candidate: Config
|
|
531
|
-
try {
|
|
532
|
-
candidate = Config(submitted as Config)
|
|
533
|
-
} catch (error) {
|
|
534
|
-
send(400, { error: error instanceof Error ? error.message : String(error) })
|
|
535
|
-
return
|
|
536
|
-
}
|
|
537
|
-
if (settingsScope === undefined) {
|
|
700
|
+
if (settingsProvider === undefined) {
|
|
538
701
|
send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
|
|
539
702
|
return
|
|
540
703
|
}
|
|
541
|
-
//
|
|
704
|
+
// The patch names only the keys the card can edit; a clear is expressed by
|
|
705
|
+
// omitting the key from the patch, which is what `unset` does to the section
|
|
706
|
+
// as it stands. Unknown keys are reported rather than silently stored.
|
|
707
|
+
const { patch, clear, unknown } = buildConfigPatch(submitted as Record<string, unknown>)
|
|
708
|
+
// Validate the candidate the patch would produce — schema defaults included,
|
|
709
|
+
// exactly as the listener will resolve it — so the save fails closed on an
|
|
710
|
+
// unusable combination instead of persisting it.
|
|
711
|
+
const candidate = Config({ ...effective(), ...patch })
|
|
542
712
|
// A structural problem (legacy authRequired:false, lanPasswordless without
|
|
543
713
|
// a session-capable base) is invalid however it is reached; a start
|
|
544
714
|
// condition (plaintext without TLS/terminator/opt-in) only blocks a save
|
|
@@ -551,28 +721,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
551
721
|
send(409, { error: `config cannot start: ${problems.join(' ')}` })
|
|
552
722
|
return
|
|
553
723
|
}
|
|
554
|
-
// Build the next user section: drop null/undefined and empty optionals
|
|
555
|
-
// (an empty path field re-inherits the composition layer).
|
|
556
|
-
const section: Record<string, unknown> = {}
|
|
557
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
558
|
-
if (value === null || value === undefined) continue
|
|
559
|
-
if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) continue
|
|
560
|
-
section[key] = value
|
|
561
|
-
}
|
|
562
724
|
try {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
725
|
+
const ops = [
|
|
726
|
+
...Object.entries(patch).map(([key, value]) => ({ op: 'set' as const, path: [key], value })),
|
|
727
|
+
...clear.map(key => ({ op: 'unset' as const, path: [key] })),
|
|
728
|
+
]
|
|
729
|
+
// Path-addressed edits, not a merge patch: a clear has to *remove* the
|
|
730
|
+
// key so it re-inherits the composition layer. Storing null instead would
|
|
731
|
+
// leave a null where the config expects a string, and `!== undefined`
|
|
732
|
+
// tests elsewhere would then read that null as a declared value.
|
|
733
|
+
if (ops.length > 0) await settingsProvider.mutate(NS, ops)
|
|
734
|
+
// The write commits through the section's watcher; reconcile explicitly so
|
|
735
|
+
// the response reports a settled listener rather than a mid-restart one.
|
|
566
736
|
await syncGateway('config route save')
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
lastError: lastError ?? null,
|
|
575
|
-
})
|
|
737
|
+
const next = { ...snapshot() }
|
|
738
|
+
if (unknown.length > 0) {
|
|
739
|
+
// Not an error: an older client may post fields this build dropped. Say
|
|
740
|
+
// so rather than dropping them silently.
|
|
741
|
+
next['ignored'] = unknown
|
|
742
|
+
}
|
|
743
|
+
send(200, next)
|
|
576
744
|
} catch (error) {
|
|
577
745
|
send(409, { error: error instanceof Error ? error.message : String(error) })
|
|
578
746
|
}
|
|
@@ -584,35 +752,36 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
584
752
|
|
|
585
753
|
const controller: GatewayController = {
|
|
586
754
|
status(): ToolResult {
|
|
587
|
-
const cfg =
|
|
755
|
+
const cfg = desiredConfig()
|
|
588
756
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
|
|
589
757
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
|
|
590
758
|
return {
|
|
591
759
|
ok: true,
|
|
592
760
|
message:
|
|
593
|
-
`LAN gateway: ${gateway !== undefined ? `LISTENING on
|
|
761
|
+
`LAN gateway: ${gateway !== undefined ? `LISTENING on ${gateway.boundAddress()}` : 'stopped'}`
|
|
594
762
|
+ `\n- dsh target: 127.0.0.1:${dshPort}`
|
|
595
763
|
+ `\n- password: ${state.password !== undefined ? 'set' : 'NOT SET'}`
|
|
596
764
|
+ `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
|
|
597
765
|
+ `\n- session epoch: ${state.sessionEpoch}`
|
|
766
|
+
+ `\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)`
|
|
598
767
|
+ `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
|
|
599
768
|
+ `\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== undefined ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? 'TLS' : 'plaintext'} browser ingress)` : encrypted ? 'encrypted' : cfg.allowInsecurePlaintext ? 'PLAINTEXT (explicit allowInsecurePlaintext)' : 'plaintext — will not start'}`
|
|
600
769
|
+ `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
|
|
601
|
-
+ (manualOverride !== undefined
|
|
770
|
+
+ (manualOverride !== undefined && !settingsAttached
|
|
602
771
|
? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
|
|
603
772
|
: '')
|
|
604
773
|
+ (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
|
|
605
774
|
}
|
|
606
775
|
},
|
|
607
776
|
async enable(): Promise<ToolResult> {
|
|
608
|
-
|
|
777
|
+
await setRunIntent(true)
|
|
609
778
|
await syncGateway('tool enable')
|
|
610
779
|
return gateway !== undefined
|
|
611
|
-
? { ok: true, message: `Gateway enabled: listening on
|
|
780
|
+
? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
|
|
612
781
|
: { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
|
|
613
782
|
},
|
|
614
783
|
async disable(): Promise<ToolResult> {
|
|
615
|
-
|
|
784
|
+
await setRunIntent(false)
|
|
616
785
|
await syncGateway('tool disable')
|
|
617
786
|
return { ok: true, message: 'Gateway disabled.' }
|
|
618
787
|
},
|
|
@@ -621,26 +790,29 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
621
790
|
return { ok: false, message: 'Password must be at least 8 characters.' }
|
|
622
791
|
}
|
|
623
792
|
const setting = password !== undefined && password.length > 0
|
|
624
|
-
const
|
|
625
|
-
state = setPassword(state, setting ? password : undefined)
|
|
793
|
+
const hadPassword = state.password !== undefined
|
|
794
|
+
state = await setPassword(state, setting ? password : undefined)
|
|
626
795
|
saveState(state)
|
|
627
796
|
gateway?.setState(state)
|
|
628
797
|
if (!setting) {
|
|
629
798
|
// Clearing the credential must not leave an open gateway serving
|
|
630
799
|
// sessions the old password authorized: stop the listener. A password
|
|
631
800
|
// is required to run, so a later enable fails closed.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
await stopGateway()
|
|
801
|
+
await setRunIntent(false)
|
|
802
|
+
return enqueue('password cleared', async () => {
|
|
803
|
+
if (gateway !== undefined) await stopGateway()
|
|
635
804
|
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
636
|
-
|
|
637
|
-
}
|
|
638
|
-
return {
|
|
805
|
+
}).then(() => ({
|
|
639
806
|
ok: true,
|
|
640
807
|
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
641
|
-
}
|
|
808
|
+
}))
|
|
642
809
|
}
|
|
643
|
-
|
|
810
|
+
// The first password turns a dormant "enabled but unpassworded" intent
|
|
811
|
+
// into a startable one, so reconcile: the listener was refused a moment
|
|
812
|
+
// ago for a reason that no longer holds. A later password change needs no
|
|
813
|
+
// reconcile (the listener is already running or already refused for some
|
|
814
|
+
// other reason), and reconciling anyway would be harmless but noisy.
|
|
815
|
+
if (!hadPassword) await syncGateway('password set')
|
|
644
816
|
return {
|
|
645
817
|
ok: true,
|
|
646
818
|
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
@@ -668,29 +840,40 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
668
840
|
if (hosts.length === 0) {
|
|
669
841
|
return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
|
|
670
842
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
843
|
+
let failure: string | undefined
|
|
844
|
+
// Through the queue like every other lifecycle action: minting the
|
|
845
|
+
// certificate and restarting must not interleave with a settings-driven
|
|
846
|
+
// restart, which is how two listeners ended up racing for one port.
|
|
847
|
+
await enqueue('tls regenerate', async () => {
|
|
848
|
+
try {
|
|
849
|
+
regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
|
|
850
|
+
if (gateway !== undefined) {
|
|
851
|
+
await stopGateway()
|
|
852
|
+
await startGateway(effective())
|
|
853
|
+
}
|
|
676
854
|
lastError = undefined
|
|
855
|
+
} catch (error) {
|
|
856
|
+
failure = error instanceof Error ? error.message : String(error)
|
|
677
857
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
|
683
|
-
}
|
|
684
|
-
}
|
|
858
|
+
})
|
|
859
|
+
return failure === undefined
|
|
860
|
+
? { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
|
|
861
|
+
: { ok: false, message: `Failed to regenerate TLS certificate: ${failure}` }
|
|
685
862
|
},
|
|
686
863
|
}
|
|
687
864
|
|
|
688
865
|
// Register the management tool once.
|
|
689
866
|
ctx.tools.register(lanGatewayTool(controller))
|
|
690
867
|
|
|
691
|
-
// Own the gateway lifecycle with the cordis tree.
|
|
868
|
+
// Own the gateway lifecycle with the cordis tree. The dispose hook only marks
|
|
869
|
+
// the tree gone and queues the stop: everything that could be mid-flight is
|
|
870
|
+
// already holding the queue, and `startGateway` undoes its own listener when
|
|
871
|
+
// it notices the flag.
|
|
692
872
|
ctx.effect(() => {
|
|
693
873
|
void syncGateway('boot')
|
|
694
|
-
return
|
|
874
|
+
return async () => {
|
|
875
|
+
disposed = true
|
|
876
|
+
await enqueue('dispose', stopGateway)
|
|
877
|
+
}
|
|
695
878
|
}, 'dsh-lan-gateway: listener lifecycle')
|
|
696
879
|
}
|