@riceawa/dsh-lan-gateway 0.5.4 → 0.6.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 +37 -21
- package/lib/client.js +190 -146
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +129 -8
- package/lib/index.js +1424 -968
- package/package.json +26 -14
- package/src/auth.ts +7 -14
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +93 -145
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +401 -157
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +16 -5
- package/src/tls.ts +23 -0
- package/src/upstream-session.ts +24 -8
package/src/index.ts
CHANGED
|
@@ -43,12 +43,24 @@
|
|
|
43
43
|
|
|
44
44
|
import type { Context } from '@deepseek-ai/cordis'
|
|
45
45
|
import { randomBytes } from 'node:crypto'
|
|
46
|
-
import type { IncomingMessage, ServerResponse } from '
|
|
46
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
47
47
|
import z from '@deepseek-ai/schemastery'
|
|
48
|
-
|
|
48
|
+
// Type-only: the `settings` service (0.1.7 addresses a write by profile entry
|
|
49
|
+
// id) and the Loader's `fiber.entry`, which is where this plugin reads its own
|
|
50
|
+
// entry id from.
|
|
51
|
+
import type SettingsService from '@deepseek-ai/dsh-settings'
|
|
52
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
49
53
|
import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
|
|
54
|
+
import {
|
|
55
|
+
CONFIG_FIELD_KEYS,
|
|
56
|
+
OPTIONAL_CONFIG_KEYS,
|
|
57
|
+
} from './config-fields.ts'
|
|
50
58
|
import { LanGateway } from './gateway.ts'
|
|
51
59
|
import { readBody } from './login.ts'
|
|
60
|
+
import {
|
|
61
|
+
isLoopbackHost,
|
|
62
|
+
READ_ONLY_METHODS,
|
|
63
|
+
} from './request-policy.ts'
|
|
52
64
|
import {
|
|
53
65
|
loadState,
|
|
54
66
|
saveState,
|
|
@@ -57,10 +69,10 @@ import {
|
|
|
57
69
|
} from './state.ts'
|
|
58
70
|
import {
|
|
59
71
|
describeCert,
|
|
60
|
-
loadCustomCert,
|
|
61
|
-
loadOrCreateSelfSigned,
|
|
62
72
|
loadOrRenewSelfSigned,
|
|
73
|
+
loadCustomCert,
|
|
63
74
|
parseSelfSignedHosts,
|
|
75
|
+
readSelfSignedStatus,
|
|
64
76
|
regenerateSelfSigned,
|
|
65
77
|
type TlsMaterial,
|
|
66
78
|
} from './tls.ts'
|
|
@@ -132,12 +144,17 @@ export interface Config {
|
|
|
132
144
|
/**
|
|
133
145
|
* Removed capability: authentication is always required. Retained only so an
|
|
134
146
|
* explicit legacy `authRequired: false` is rejected loudly instead of
|
|
135
|
-
* silently ignored.
|
|
147
|
+
* silently ignored. Not card-editable and never written back to the user
|
|
148
|
+
* section — the config route builds its patch from the editable key set.
|
|
136
149
|
*/
|
|
137
150
|
authRequired?: boolean
|
|
138
151
|
/** Session cookie lifetime in days. */
|
|
139
152
|
cookieMaxAgeDays: number
|
|
140
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Cookie name. Deliberately not card-editable — see the editable key set in
|
|
155
|
+
* `config-fields.ts`; the config route applies a patch, so an operator's
|
|
156
|
+
* custom name survives every save from the Settings card.
|
|
157
|
+
*/
|
|
141
158
|
cookieName: string
|
|
142
159
|
/** Whether the gateway listener speaks TLS. */
|
|
143
160
|
tlsEnabled: boolean
|
|
@@ -147,7 +164,13 @@ export interface Config {
|
|
|
147
164
|
tlsCertPath?: string
|
|
148
165
|
/** Custom mode: path to the PEM private key. */
|
|
149
166
|
tlsKeyPath?: string
|
|
150
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* Self-signed mode: comma/space separated DNS names and IPs for the SANs.
|
|
169
|
+
*
|
|
170
|
+
* Read when a certificate is generated, not when it is served: changing it
|
|
171
|
+
* does not replace a certificate that already exists and is still valid. Use
|
|
172
|
+
* `lan_gateway tls-regenerate` for that.
|
|
173
|
+
*/
|
|
151
174
|
tlsSelfSignedHosts?: string
|
|
152
175
|
/**
|
|
153
176
|
* Self-signed certificate validity in days (default 825 ≈ 27 months).
|
|
@@ -160,6 +183,8 @@ export interface Config {
|
|
|
160
183
|
* self-signed certificate is either clicked through or trusted by hand,
|
|
161
184
|
* there is nothing to gain from the shorter window and a re-trust to lose
|
|
162
185
|
* every time it lapses.
|
|
186
|
+
*
|
|
187
|
+
* Like `tlsSelfSignedHosts`, this applies to the next generation only.
|
|
163
188
|
*/
|
|
164
189
|
tlsCertMaxAgeDays: number
|
|
165
190
|
/**
|
|
@@ -171,6 +196,11 @@ export interface Config {
|
|
|
171
196
|
* An identifier for a trusted TLS-terminating proxy in front of the gateway.
|
|
172
197
|
* Declaring one marks the ingress encrypted (Secure cookies, passes the
|
|
173
198
|
* encrypted-ingress gate) without this listener sending HSTS.
|
|
199
|
+
*
|
|
200
|
+
* Note the coupling with login rate limiting: the limiter is keyed by
|
|
201
|
+
* `socket.remoteAddress`, and `X-Forwarded-For` is deliberately untrusted, so
|
|
202
|
+
* behind such a proxy every browser shares one bucket — the login budget
|
|
203
|
+
* becomes per-deployment, not per-client.
|
|
174
204
|
*/
|
|
175
205
|
trustedTerminator?: string
|
|
176
206
|
/**
|
|
@@ -183,38 +213,112 @@ export interface Config {
|
|
|
183
213
|
secureCookies?: boolean
|
|
184
214
|
}
|
|
185
215
|
|
|
216
|
+
/** A `.volatile()` config field as the Loader hands it to `apply`. */
|
|
217
|
+
export interface ConfigRef<T> {
|
|
218
|
+
/** The current value; updated in place when the profile entry is written. */
|
|
219
|
+
get(): T
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The config object `apply` receives: dsh 0.1.7 hands every `.volatile()` field
|
|
224
|
+
* over as a reference rather than a value, so the plugin reads its live config
|
|
225
|
+
* through `readConfig`. A field declared optional and left unset resolves to
|
|
226
|
+
* `undefined` instead of a reference, hence the union inside `ConfigRef`.
|
|
227
|
+
*/
|
|
228
|
+
export type ConfigRefs = { [K in keyof Required<Config>]: ConfigRef<Config[K]> }
|
|
229
|
+
|
|
186
230
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
231
|
+
* Schemastery configuration validated by the Loader.
|
|
232
|
+
*
|
|
233
|
+
* Every field is `.volatile()`, which is what lets the Settings service write
|
|
234
|
+
* it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
|
|
235
|
+
* other path (`not volatile`). The mark also changes the runtime shape — a
|
|
236
|
+
* volatile field arrives as a reference (see `ConfigRefs`), never as the plain
|
|
237
|
+
* value the rest of this file expects — so read it through `readConfig`.
|
|
191
238
|
*/
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
tlsKeyPath: z.string(),
|
|
211
|
-
tlsSelfSignedHosts: z.string().default('localhost'),
|
|
212
|
-
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
|
|
213
|
-
allowInsecurePlaintext: z.boolean().default(false),
|
|
214
|
-
trustedTerminator: z.string(),
|
|
215
|
-
secureCookies: z.boolean(),
|
|
239
|
+
export const Config = z.object({
|
|
240
|
+
enabled: z.boolean().default(false).volatile(),
|
|
241
|
+
gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
|
|
242
|
+
dshTargetPort: z.natural().min(1).max(65535).volatile(),
|
|
243
|
+
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
|
|
244
|
+
lanPasswordless: z.boolean().default(false).volatile(),
|
|
245
|
+
authRequired: z.boolean().default(true).volatile(),
|
|
246
|
+
cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
|
|
247
|
+
cookieName: z.string().default('dsh_gw_auth').volatile(),
|
|
248
|
+
tlsEnabled: z.boolean().default(false).volatile(),
|
|
249
|
+
tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed').volatile(),
|
|
250
|
+
tlsCertPath: z.string().volatile(),
|
|
251
|
+
tlsKeyPath: z.string().volatile(),
|
|
252
|
+
tlsSelfSignedHosts: z.string().default('localhost').volatile(),
|
|
253
|
+
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
|
|
254
|
+
allowInsecurePlaintext: z.boolean().default(false).volatile(),
|
|
255
|
+
trustedTerminator: z.string().volatile(),
|
|
256
|
+
secureCookies: z.boolean().volatile(),
|
|
216
257
|
})
|
|
217
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Unwrap the config references into the plain values every other function in
|
|
261
|
+
* this file reads. Called on each access rather than once, because a settings
|
|
262
|
+
* write updates the references in place.
|
|
263
|
+
* @param refs - the config object handed to `apply`.
|
|
264
|
+
* @returns one detached plain snapshot.
|
|
265
|
+
*/
|
|
266
|
+
export function readConfig(refs: ConfigRefs): Config {
|
|
267
|
+
const dshTargetPort = refs.dshTargetPort?.get()
|
|
268
|
+
const authRequired = refs.authRequired?.get()
|
|
269
|
+
const tlsCertPath = refs.tlsCertPath?.get()
|
|
270
|
+
const tlsKeyPath = refs.tlsKeyPath?.get()
|
|
271
|
+
const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get()
|
|
272
|
+
const trustedTerminator = refs.trustedTerminator?.get()
|
|
273
|
+
const secureCookies = refs.secureCookies?.get()
|
|
274
|
+
return {
|
|
275
|
+
enabled: refs.enabled.get(),
|
|
276
|
+
gatewayPort: refs.gatewayPort.get(),
|
|
277
|
+
lanCidrs: [...refs.lanCidrs.get()],
|
|
278
|
+
lanPasswordless: refs.lanPasswordless.get(),
|
|
279
|
+
cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
|
|
280
|
+
cookieName: refs.cookieName.get(),
|
|
281
|
+
tlsEnabled: refs.tlsEnabled.get(),
|
|
282
|
+
tlsMode: refs.tlsMode.get(),
|
|
283
|
+
tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
|
|
284
|
+
allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
|
|
285
|
+
// `exactOptionalPropertyTypes` is on, so an absent optional key must stay
|
|
286
|
+
// absent rather than be assigned `undefined`. Every field here has a schema
|
|
287
|
+
// default or is genuinely optional, so an absent reference means unset.
|
|
288
|
+
...(dshTargetPort !== undefined ? { dshTargetPort } : {}),
|
|
289
|
+
...(authRequired !== undefined ? { authRequired } : {}),
|
|
290
|
+
...(tlsCertPath !== undefined ? { tlsCertPath } : {}),
|
|
291
|
+
...(tlsKeyPath !== undefined ? { tlsKeyPath } : {}),
|
|
292
|
+
...(tlsSelfSignedHosts !== undefined ? { tlsSelfSignedHosts } : {}),
|
|
293
|
+
...(trustedTerminator !== undefined ? { trustedTerminator } : {}),
|
|
294
|
+
...(secureCookies !== undefined ? { secureCookies } : {}),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Build the reference-shaped config `apply` receives, exactly as the Loader
|
|
300
|
+
* builds it. Exported for tests that drive `apply` directly.
|
|
301
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
302
|
+
* @returns one reference per volatile field.
|
|
303
|
+
*/
|
|
304
|
+
export function configRefs(raw: object): ConfigRefs {
|
|
305
|
+
return Config(raw) as unknown as ConfigRefs
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Validate a raw config object the way the Loader does, and unwrap it.
|
|
310
|
+
*
|
|
311
|
+
* `Config` marks every field volatile, so a validation hands the values back as
|
|
312
|
+
* references (typed deeply-readonly by schemastery); this returns the plain
|
|
313
|
+
* shape the rest of the file reads. Used to judge a config the Settings card is
|
|
314
|
+
* about to save, before it is persisted.
|
|
315
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
316
|
+
* @returns the validated plain config.
|
|
317
|
+
*/
|
|
318
|
+
export function validateConfig(raw: object): Config {
|
|
319
|
+
return readConfig(configRefs(raw))
|
|
320
|
+
}
|
|
321
|
+
|
|
218
322
|
/** Facts the fail-closed start guard needs to judge a config. */
|
|
219
323
|
export interface StartFacts {
|
|
220
324
|
/** Whether the dsh base enforces browser-session auth (auto-detected). */
|
|
@@ -315,35 +419,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
|
|
|
315
419
|
])
|
|
316
420
|
}
|
|
317
421
|
|
|
318
|
-
/**
|
|
422
|
+
/**
|
|
423
|
+
* One-line TLS description for status output.
|
|
424
|
+
*
|
|
425
|
+
* Never generates: this is the read path behind `GET /lan-gateway/config` and
|
|
426
|
+
* `lan_gateway status`, and a status query that mints an RSA key and writes a
|
|
427
|
+
* certificate to disk is not a read. The material is created when the listener
|
|
428
|
+
* starts, or by `lan_gateway tls-regenerate`.
|
|
429
|
+
*/
|
|
319
430
|
function tlsStatusLine(cfg: Config): string {
|
|
320
431
|
if (!cfg.tlsEnabled) return 'off'
|
|
321
432
|
if (cfg.tlsMode === 'custom') {
|
|
322
433
|
return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
|
|
323
434
|
}
|
|
324
435
|
try {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
const info = describeCert(
|
|
436
|
+
const status = readSelfSignedStatus()
|
|
437
|
+
if (status === undefined) return 'self-signed (not generated yet — created when the listener starts)'
|
|
438
|
+
const info = describeCert(status.cert)
|
|
328
439
|
return `self-signed [${info.subject}] exp ${info.validTo}`
|
|
329
440
|
} catch (error) {
|
|
330
441
|
return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
|
|
331
442
|
}
|
|
332
443
|
}
|
|
333
444
|
|
|
334
|
-
/** Whether `hostname` is loopback (127/8, localhost, ::1). */
|
|
335
|
-
function isLoopbackHost(hostname: string): boolean {
|
|
336
|
-
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
337
|
-
const parts = hostname.split('.')
|
|
338
|
-
return (
|
|
339
|
-
parts.length === 4
|
|
340
|
-
&& parts[0] === '127'
|
|
341
|
-
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
346
|
-
|
|
347
445
|
/**
|
|
348
446
|
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
349
447
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
@@ -370,22 +468,107 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
|
|
|
370
468
|
return true
|
|
371
469
|
}
|
|
372
470
|
|
|
373
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Turn a submitted config patch into the next user section: only keys the card
|
|
473
|
+
* can edit, only real values, and `null` (or an emptied optional) removes the
|
|
474
|
+
* key rather than storing it.
|
|
475
|
+
*
|
|
476
|
+
* The patch is built from the *submitted* object, never from a schema call's
|
|
477
|
+
* output. Schemastery fills defaults into whatever it validates and passes
|
|
478
|
+
* unknown keys through, so deriving the section from `Config(submitted)` wrote
|
|
479
|
+
* `authRequired: true` (a capability that exists only to be refused) and any
|
|
480
|
+
* stray key into the user's settings on every save — and, because it also
|
|
481
|
+
* materialized `cookieName`, reset an operator's custom cookie name to the
|
|
482
|
+
* schema default.
|
|
483
|
+
*
|
|
484
|
+
* A `null` value is the card's clear: the key is dropped from the patch, which
|
|
485
|
+
* leaves it absent from the section, so it re-inherits the composition layer.
|
|
486
|
+
*/
|
|
487
|
+
export function buildConfigPatch(submitted: Record<string, unknown>): {
|
|
488
|
+
patch: Record<string, unknown>
|
|
489
|
+
clear: string[]
|
|
490
|
+
unknown: string[]
|
|
491
|
+
} {
|
|
492
|
+
const patch: Record<string, unknown> = {}
|
|
493
|
+
const clear: string[] = []
|
|
494
|
+
const unknown: string[] = []
|
|
495
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
496
|
+
if (!CONFIG_FIELD_KEYS.has(key)) {
|
|
497
|
+
unknown.push(key)
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
if (value === null || value === undefined) {
|
|
501
|
+
clear.push(key)
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) {
|
|
505
|
+
clear.push(key)
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
// An empty string on a non-optional text field is a real value the card
|
|
509
|
+
// refuses to submit, but a hand-written POST could send one; let the schema
|
|
510
|
+
// reject it rather than inventing a meaning here.
|
|
511
|
+
patch[key] = value
|
|
512
|
+
}
|
|
513
|
+
return { patch, clear, unknown }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export function apply(ctx: Context, config: ConfigRefs): void {
|
|
374
517
|
let state = loadState()
|
|
375
518
|
let gateway: LanGateway | undefined
|
|
376
519
|
let startedWith: string | undefined
|
|
377
520
|
let lastError: string | undefined
|
|
521
|
+
/**
|
|
522
|
+
* The operator's run intent, used only while no settings service is attached.
|
|
523
|
+
* With settings present, `enabled` in the settings section *is* the intent —
|
|
524
|
+
* the card and the tool write the same field, so there is one truth rather
|
|
525
|
+
* than two that disagree.
|
|
526
|
+
*/
|
|
378
527
|
let manualOverride: boolean | undefined
|
|
379
528
|
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
380
529
|
let upstreamSessionAvailable = false
|
|
530
|
+
/**
|
|
531
|
+
* Identifies the current `connection` handler. A provider that detaches and a
|
|
532
|
+
* new one that attaches run their disposers in an order the plugin does not
|
|
533
|
+
* control, and a stale disposer clearing `makeRelay` would strand the live
|
|
534
|
+
* provider — so a disposer only acts if it is still the latest generation.
|
|
535
|
+
*/
|
|
536
|
+
let connectionGeneration = 0
|
|
381
537
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
382
538
|
let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
|
|
383
|
-
/**
|
|
384
|
-
let
|
|
385
|
-
/**
|
|
386
|
-
|
|
539
|
+
/** Whether the settings service is attached, so writes reach the profile entry. */
|
|
540
|
+
let settingsAttached = false
|
|
541
|
+
/**
|
|
542
|
+
* This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
|
|
543
|
+
* the *entry id* — the `lan-gateway` namespace this plugin used to register
|
|
544
|
+
* with is gone along with `settingsScope`.
|
|
545
|
+
*/
|
|
546
|
+
let settingsEntryId: string | undefined
|
|
547
|
+
/**
|
|
548
|
+
* The settings service, for the one write a merge patch cannot express: a key
|
|
549
|
+
* must be *removed* to re-inherit the composition layer, and only its
|
|
550
|
+
* path-addressed `mutate` can unset one.
|
|
551
|
+
*/
|
|
552
|
+
let settingsProvider: SettingsService | undefined
|
|
553
|
+
/**
|
|
554
|
+
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
555
|
+
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
556
|
+
* two of them can never interleave a stop with a start.
|
|
557
|
+
*/
|
|
558
|
+
let lifecycle: Promise<void> = Promise.resolve()
|
|
559
|
+
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
560
|
+
let disposed = false
|
|
387
561
|
|
|
388
|
-
const effective = (): Config =>
|
|
562
|
+
const effective = (): Config => readConfig(config)
|
|
563
|
+
|
|
564
|
+
/** Queue one lifecycle action behind every action already running. */
|
|
565
|
+
const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
|
|
566
|
+
lifecycle = lifecycle.then(action).catch((error: unknown) => {
|
|
567
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
568
|
+
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
|
|
569
|
+
})
|
|
570
|
+
return lifecycle
|
|
571
|
+
}
|
|
389
572
|
|
|
390
573
|
const startGateway = async (cfg: Config): Promise<void> => {
|
|
391
574
|
if (gateway !== undefined) return
|
|
@@ -420,6 +603,13 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
420
603
|
},
|
|
421
604
|
}, state)
|
|
422
605
|
await next.listen()
|
|
606
|
+
// The listen above is asynchronous and the tree can be disposed while it is
|
|
607
|
+
// in flight. Publishing the listener after that would leave a socket owned
|
|
608
|
+
// by nobody — the dispose hook already ran and saw `gateway` undefined.
|
|
609
|
+
if (disposed) {
|
|
610
|
+
await next.close()
|
|
611
|
+
return
|
|
612
|
+
}
|
|
423
613
|
gateway = next
|
|
424
614
|
startedWith = listenerKey(cfg, makeRelay !== undefined)
|
|
425
615
|
ctx.logger.info(
|
|
@@ -445,46 +635,72 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
445
635
|
}
|
|
446
636
|
}
|
|
447
637
|
|
|
638
|
+
/** The config the listener should be running under, intent included. */
|
|
639
|
+
const desiredConfig = (): Config => {
|
|
640
|
+
const cfg = effective()
|
|
641
|
+
// Without a settings service there is nowhere to record the tool's intent,
|
|
642
|
+
// so it lives in memory as an override on the composition entry. With one
|
|
643
|
+
// attached, the profile entry's `enabled` already carries it and an override
|
|
644
|
+
// would shadow the card — the defect this replaces.
|
|
645
|
+
if (settingsAttached) return cfg
|
|
646
|
+
return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
|
|
647
|
+
}
|
|
648
|
+
|
|
448
649
|
/** Reconcile the listener with the effective config (start/stop/restart). */
|
|
449
650
|
const syncGateway = (reason: string): Promise<void> => {
|
|
450
|
-
|
|
651
|
+
return enqueue(reason, async () => {
|
|
451
652
|
lastError = undefined
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
if (
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
await startGateway(cfg)
|
|
462
|
-
}
|
|
463
|
-
} catch (error) {
|
|
464
|
-
lastError = error instanceof Error ? error.message : String(error)
|
|
465
|
-
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
|
|
653
|
+
if (disposed) return
|
|
654
|
+
const cfg = desiredConfig()
|
|
655
|
+
if (gateway === undefined) {
|
|
656
|
+
if (cfg.enabled) await startGateway(cfg)
|
|
657
|
+
} else if (!cfg.enabled) {
|
|
658
|
+
await stopGateway()
|
|
659
|
+
} else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
|
|
660
|
+
await stopGateway()
|
|
661
|
+
await startGateway(cfg)
|
|
466
662
|
}
|
|
467
663
|
})
|
|
468
|
-
return syncing
|
|
469
664
|
}
|
|
470
665
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
666
|
+
/** Record the run intent where it will survive: the profile entry, or memory. */
|
|
667
|
+
const setRunIntent = async (enabled: boolean): Promise<void> => {
|
|
668
|
+
if (settingsAttached && settingsProvider !== undefined && settingsEntryId !== undefined) {
|
|
669
|
+
// The same field the Settings card writes, in this plugin's own profile
|
|
670
|
+
// entry. A merge patch, so nothing else in the entry is disturbed.
|
|
671
|
+
await settingsProvider.update(settingsEntryId, { enabled })
|
|
672
|
+
// The write updates the config references in place and the loader then
|
|
673
|
+
// emits `loader/volatile-update`, which queues the reconcile; waiting on
|
|
674
|
+
// that here would deadlock behind this same write.
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
manualOverride = enabled
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
|
|
681
|
+
// it by *entry id*: the `lan-gateway` settings namespace this plugin used to
|
|
682
|
+
// register (and the scope handle it wrote through) no longer exist. The entry
|
|
683
|
+
// id is the Loader's, so it comes from the fiber; without a Loader there is no
|
|
684
|
+
// entry to write and the composition value stands alone.
|
|
478
685
|
ctx.inject(['settings'], (sctx) => {
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
686
|
+
const entryId = ctx.fiber.entry?.options.id
|
|
687
|
+
if (entryId === undefined) return
|
|
688
|
+
settingsEntryId = entryId
|
|
689
|
+
settingsProvider = sctx.settings
|
|
690
|
+
settingsAttached = true
|
|
691
|
+
// This plugin ships its own card, so it owns its page policy.
|
|
692
|
+
sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
|
|
693
|
+
// A committed write updates the volatile references in place (no remount)
|
|
694
|
+
// and emits this, so the listener follows the new config.
|
|
695
|
+
sctx.effect(() => ctx.on('loader/volatile-update', () => { void syncGateway('settings change') }))
|
|
483
696
|
sctx.effect(() => () => {
|
|
484
|
-
// The settings
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
697
|
+
// The settings service went away (disposal / provider reload): the
|
|
698
|
+
// composition entry is authoritative again, and the tool's intent falls
|
|
699
|
+
// back to memory. Reconcile so the listener follows it.
|
|
700
|
+
settingsEntryId = undefined
|
|
701
|
+
settingsProvider = undefined
|
|
702
|
+
settingsAttached = false
|
|
703
|
+
void syncGateway('settings detach')
|
|
488
704
|
})
|
|
489
705
|
void syncGateway('settings attach')
|
|
490
706
|
})
|
|
@@ -495,6 +711,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
495
711
|
// relay exchanges. Optional: on an older base the callback never runs, the
|
|
496
712
|
// gateway forwards without a relay, and lanPasswordless stays refused.
|
|
497
713
|
ctx.inject(['connection'], (ccx) => {
|
|
714
|
+
const generation = ++connectionGeneration
|
|
498
715
|
upstreamSessionAvailable = true
|
|
499
716
|
ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
|
|
500
717
|
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
@@ -504,6 +721,15 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
504
721
|
// and looks exactly like a base with no browser sessions.
|
|
505
722
|
log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
|
|
506
723
|
})
|
|
724
|
+
ccx.effect(() => () => {
|
|
725
|
+
// The provider went away. Drop the relay factory rather than hold one
|
|
726
|
+
// bound to a disposed context, and let the listenerKey see the change so
|
|
727
|
+
// the running listener does not keep serving through a dead provider.
|
|
728
|
+
if (generation !== connectionGeneration) return
|
|
729
|
+
makeRelay = undefined
|
|
730
|
+
upstreamSessionAvailable = false
|
|
731
|
+
void syncGateway('connection detach')
|
|
732
|
+
})
|
|
507
733
|
// A listener that started before the connection service appeared must
|
|
508
734
|
// restart so it picks up the relay (and the now-correct fail-closed facts).
|
|
509
735
|
void syncGateway('connection attach')
|
|
@@ -516,6 +742,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
516
742
|
// reach it — a genuine local user, or a local process that could already read
|
|
517
743
|
// ~/.dsh.
|
|
518
744
|
const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
745
|
+
const snapshot = (): Record<string, unknown> => {
|
|
746
|
+
const cfg = effective()
|
|
747
|
+
return {
|
|
748
|
+
config: cfg,
|
|
749
|
+
running: gateway !== undefined,
|
|
750
|
+
port: cfg.gatewayPort,
|
|
751
|
+
tls: tlsStatusLine(cfg),
|
|
752
|
+
upstreamSessionAvailable,
|
|
753
|
+
lastError: lastError ?? null,
|
|
754
|
+
}
|
|
755
|
+
}
|
|
519
756
|
const send = (status: number, body: unknown): void => {
|
|
520
757
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
521
758
|
res.end(JSON.stringify(body))
|
|
@@ -525,15 +762,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
525
762
|
return
|
|
526
763
|
}
|
|
527
764
|
if (req.method === 'GET') {
|
|
528
|
-
|
|
529
|
-
send(200, {
|
|
530
|
-
config: cfg,
|
|
531
|
-
running: gateway !== undefined,
|
|
532
|
-
port: cfg.gatewayPort,
|
|
533
|
-
tls: tlsStatusLine(cfg),
|
|
534
|
-
upstreamSessionAvailable,
|
|
535
|
-
lastError: lastError ?? null,
|
|
536
|
-
})
|
|
765
|
+
send(200, snapshot())
|
|
537
766
|
return
|
|
538
767
|
}
|
|
539
768
|
if (req.method !== 'POST') {
|
|
@@ -553,20 +782,23 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
553
782
|
send(400, { error: 'body must be a config object' })
|
|
554
783
|
return
|
|
555
784
|
}
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
send(400, { error: error instanceof Error ? error.message : String(error) })
|
|
563
|
-
return
|
|
564
|
-
}
|
|
565
|
-
if (settingsScope === undefined) {
|
|
785
|
+
// A settings write is addressed by this plugin's profile entry id; without
|
|
786
|
+
// one (no Loader, or no settings service) the composition value is all there
|
|
787
|
+
// is, and the operator edits the profile patch instead.
|
|
788
|
+
const settings = settingsProvider
|
|
789
|
+
const entryId = settingsEntryId
|
|
790
|
+
if (settings === undefined || entryId === undefined) {
|
|
566
791
|
send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
|
|
567
792
|
return
|
|
568
793
|
}
|
|
569
|
-
//
|
|
794
|
+
// The patch names only the keys the card can edit; a clear is expressed by
|
|
795
|
+
// omitting the key from the patch, which is what `unset` does to the section
|
|
796
|
+
// as it stands. Unknown keys are reported rather than silently stored.
|
|
797
|
+
const { patch, clear, unknown } = buildConfigPatch(submitted as Record<string, unknown>)
|
|
798
|
+
// Validate the candidate the patch would produce — schema defaults included,
|
|
799
|
+
// exactly as the listener will resolve it — so the save fails closed on an
|
|
800
|
+
// unusable combination instead of persisting it.
|
|
801
|
+
const candidate = validateConfig({ ...effective(), ...patch })
|
|
570
802
|
// A structural problem (legacy authRequired:false, lanPasswordless without
|
|
571
803
|
// a session-capable base) is invalid however it is reached; a start
|
|
572
804
|
// condition (plaintext without TLS/terminator/opt-in) only blocks a save
|
|
@@ -579,28 +811,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
579
811
|
send(409, { error: `config cannot start: ${problems.join(' ')}` })
|
|
580
812
|
return
|
|
581
813
|
}
|
|
582
|
-
// Build the next user section: drop null/undefined and empty optionals
|
|
583
|
-
// (an empty path field re-inherits the composition layer).
|
|
584
|
-
const section: Record<string, unknown> = {}
|
|
585
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
586
|
-
if (value === null || value === undefined) continue
|
|
587
|
-
if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) continue
|
|
588
|
-
section[key] = value
|
|
589
|
-
}
|
|
590
814
|
try {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
815
|
+
const ops = [
|
|
816
|
+
...Object.entries(patch).map(([key, value]) => ({ op: 'set' as const, path: [key], value })),
|
|
817
|
+
...clear.map(key => ({ op: 'unset' as const, path: [key] })),
|
|
818
|
+
]
|
|
819
|
+
// Path-addressed edits, not a merge patch: a clear has to *remove* the
|
|
820
|
+
// key so it re-inherits the composition layer. Storing null instead would
|
|
821
|
+
// leave a null where the config expects a string, and `!== undefined`
|
|
822
|
+
// tests elsewhere would then read that null as a declared value.
|
|
823
|
+
if (ops.length > 0) await settings.mutate(entryId, ops)
|
|
824
|
+
// The write commits through the section's watcher; reconcile explicitly so
|
|
825
|
+
// the response reports a settled listener rather than a mid-restart one.
|
|
594
826
|
await syncGateway('config route save')
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
lastError: lastError ?? null,
|
|
603
|
-
})
|
|
827
|
+
const next = { ...snapshot() }
|
|
828
|
+
if (unknown.length > 0) {
|
|
829
|
+
// Not an error: an older client may post fields this build dropped. Say
|
|
830
|
+
// so rather than dropping them silently.
|
|
831
|
+
next['ignored'] = unknown
|
|
832
|
+
}
|
|
833
|
+
send(200, next)
|
|
604
834
|
} catch (error) {
|
|
605
835
|
send(409, { error: error instanceof Error ? error.message : String(error) })
|
|
606
836
|
}
|
|
@@ -612,7 +842,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
612
842
|
|
|
613
843
|
const controller: GatewayController = {
|
|
614
844
|
status(): ToolResult {
|
|
615
|
-
const cfg =
|
|
845
|
+
const cfg = desiredConfig()
|
|
616
846
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
|
|
617
847
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
|
|
618
848
|
return {
|
|
@@ -627,21 +857,21 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
627
857
|
+ `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
|
|
628
858
|
+ `\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'}`
|
|
629
859
|
+ `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
|
|
630
|
-
+ (manualOverride !== undefined
|
|
860
|
+
+ (manualOverride !== undefined && !settingsAttached
|
|
631
861
|
? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
|
|
632
862
|
: '')
|
|
633
863
|
+ (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
|
|
634
864
|
}
|
|
635
865
|
},
|
|
636
866
|
async enable(): Promise<ToolResult> {
|
|
637
|
-
|
|
867
|
+
await setRunIntent(true)
|
|
638
868
|
await syncGateway('tool enable')
|
|
639
869
|
return gateway !== undefined
|
|
640
870
|
? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
|
|
641
871
|
: { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
|
|
642
872
|
},
|
|
643
873
|
async disable(): Promise<ToolResult> {
|
|
644
|
-
|
|
874
|
+
await setRunIntent(false)
|
|
645
875
|
await syncGateway('tool disable')
|
|
646
876
|
return { ok: true, message: 'Gateway disabled.' }
|
|
647
877
|
},
|
|
@@ -650,26 +880,29 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
650
880
|
return { ok: false, message: 'Password must be at least 8 characters.' }
|
|
651
881
|
}
|
|
652
882
|
const setting = password !== undefined && password.length > 0
|
|
653
|
-
const
|
|
654
|
-
state = setPassword(state, setting ? password : undefined)
|
|
883
|
+
const hadPassword = state.password !== undefined
|
|
884
|
+
state = await setPassword(state, setting ? password : undefined)
|
|
655
885
|
saveState(state)
|
|
656
886
|
gateway?.setState(state)
|
|
657
887
|
if (!setting) {
|
|
658
888
|
// Clearing the credential must not leave an open gateway serving
|
|
659
889
|
// sessions the old password authorized: stop the listener. A password
|
|
660
890
|
// is required to run, so a later enable fails closed.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
await stopGateway()
|
|
891
|
+
await setRunIntent(false)
|
|
892
|
+
return enqueue('password cleared', async () => {
|
|
893
|
+
if (gateway !== undefined) await stopGateway()
|
|
664
894
|
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
return {
|
|
895
|
+
}).then(() => ({
|
|
668
896
|
ok: true,
|
|
669
897
|
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
670
|
-
}
|
|
898
|
+
}))
|
|
671
899
|
}
|
|
672
|
-
|
|
900
|
+
// The first password turns a dormant "enabled but unpassworded" intent
|
|
901
|
+
// into a startable one, so reconcile: the listener was refused a moment
|
|
902
|
+
// ago for a reason that no longer holds. A later password change needs no
|
|
903
|
+
// reconcile (the listener is already running or already refused for some
|
|
904
|
+
// other reason), and reconciling anyway would be harmless but noisy.
|
|
905
|
+
if (!hadPassword) await syncGateway('password set')
|
|
673
906
|
return {
|
|
674
907
|
ok: true,
|
|
675
908
|
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
@@ -697,29 +930,40 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
697
930
|
if (hosts.length === 0) {
|
|
698
931
|
return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
|
|
699
932
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
933
|
+
let failure: string | undefined
|
|
934
|
+
// Through the queue like every other lifecycle action: minting the
|
|
935
|
+
// certificate and restarting must not interleave with a settings-driven
|
|
936
|
+
// restart, which is how two listeners ended up racing for one port.
|
|
937
|
+
await enqueue('tls regenerate', async () => {
|
|
938
|
+
try {
|
|
939
|
+
regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
|
|
940
|
+
if (gateway !== undefined) {
|
|
941
|
+
await stopGateway()
|
|
942
|
+
await startGateway(effective())
|
|
943
|
+
}
|
|
705
944
|
lastError = undefined
|
|
945
|
+
} catch (error) {
|
|
946
|
+
failure = error instanceof Error ? error.message : String(error)
|
|
706
947
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
|
712
|
-
}
|
|
713
|
-
}
|
|
948
|
+
})
|
|
949
|
+
return failure === undefined
|
|
950
|
+
? { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
|
|
951
|
+
: { ok: false, message: `Failed to regenerate TLS certificate: ${failure}` }
|
|
714
952
|
},
|
|
715
953
|
}
|
|
716
954
|
|
|
717
955
|
// Register the management tool once.
|
|
718
956
|
ctx.tools.register(lanGatewayTool(controller))
|
|
719
957
|
|
|
720
|
-
// Own the gateway lifecycle with the cordis tree.
|
|
958
|
+
// Own the gateway lifecycle with the cordis tree. The dispose hook only marks
|
|
959
|
+
// the tree gone and queues the stop: everything that could be mid-flight is
|
|
960
|
+
// already holding the queue, and `startGateway` undoes its own listener when
|
|
961
|
+
// it notices the flag.
|
|
721
962
|
ctx.effect(() => {
|
|
722
963
|
void syncGateway('boot')
|
|
723
|
-
return
|
|
964
|
+
return async () => {
|
|
965
|
+
disposed = true
|
|
966
|
+
await enqueue('dispose', stopGateway)
|
|
967
|
+
}
|
|
724
968
|
}, 'dsh-lan-gateway: listener lifecycle')
|
|
725
969
|
}
|