@riceawa/dsh-lan-gateway 0.5.4 → 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 +12 -6
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +44 -5
- package/lib/index.js +1379 -985
- package/package.json +1 -1
- package/src/auth.ts +7 -14
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +272 -118
- 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,20 @@
|
|
|
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
|
-
import type { SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
48
|
+
import type { SettingsProvider, SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
49
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'
|
|
50
54
|
import { LanGateway } from './gateway.ts'
|
|
51
55
|
import { readBody } from './login.ts'
|
|
56
|
+
import {
|
|
57
|
+
isLoopbackHost,
|
|
58
|
+
READ_ONLY_METHODS,
|
|
59
|
+
} from './request-policy.ts'
|
|
52
60
|
import {
|
|
53
61
|
loadState,
|
|
54
62
|
saveState,
|
|
@@ -57,10 +65,10 @@ import {
|
|
|
57
65
|
} from './state.ts'
|
|
58
66
|
import {
|
|
59
67
|
describeCert,
|
|
60
|
-
loadCustomCert,
|
|
61
|
-
loadOrCreateSelfSigned,
|
|
62
68
|
loadOrRenewSelfSigned,
|
|
69
|
+
loadCustomCert,
|
|
63
70
|
parseSelfSignedHosts,
|
|
71
|
+
readSelfSignedStatus,
|
|
64
72
|
regenerateSelfSigned,
|
|
65
73
|
type TlsMaterial,
|
|
66
74
|
} from './tls.ts'
|
|
@@ -132,12 +140,17 @@ export interface Config {
|
|
|
132
140
|
/**
|
|
133
141
|
* Removed capability: authentication is always required. Retained only so an
|
|
134
142
|
* explicit legacy `authRequired: false` is rejected loudly instead of
|
|
135
|
-
* 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.
|
|
136
145
|
*/
|
|
137
146
|
authRequired?: boolean
|
|
138
147
|
/** Session cookie lifetime in days. */
|
|
139
148
|
cookieMaxAgeDays: number
|
|
140
|
-
/**
|
|
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
|
+
*/
|
|
141
154
|
cookieName: string
|
|
142
155
|
/** Whether the gateway listener speaks TLS. */
|
|
143
156
|
tlsEnabled: boolean
|
|
@@ -147,7 +160,13 @@ export interface Config {
|
|
|
147
160
|
tlsCertPath?: string
|
|
148
161
|
/** Custom mode: path to the PEM private key. */
|
|
149
162
|
tlsKeyPath?: string
|
|
150
|
-
/**
|
|
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
|
+
*/
|
|
151
170
|
tlsSelfSignedHosts?: string
|
|
152
171
|
/**
|
|
153
172
|
* Self-signed certificate validity in days (default 825 ≈ 27 months).
|
|
@@ -160,6 +179,8 @@ export interface Config {
|
|
|
160
179
|
* self-signed certificate is either clicked through or trusted by hand,
|
|
161
180
|
* there is nothing to gain from the shorter window and a re-trust to lose
|
|
162
181
|
* every time it lapses.
|
|
182
|
+
*
|
|
183
|
+
* Like `tlsSelfSignedHosts`, this applies to the next generation only.
|
|
163
184
|
*/
|
|
164
185
|
tlsCertMaxAgeDays: number
|
|
165
186
|
/**
|
|
@@ -171,6 +192,11 @@ export interface Config {
|
|
|
171
192
|
* An identifier for a trusted TLS-terminating proxy in front of the gateway.
|
|
172
193
|
* Declaring one marks the ingress encrypted (Secure cookies, passes the
|
|
173
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.
|
|
174
200
|
*/
|
|
175
201
|
trustedTerminator?: string
|
|
176
202
|
/**
|
|
@@ -191,9 +217,6 @@ export interface Config {
|
|
|
191
217
|
*/
|
|
192
218
|
const NS = 'lan-gateway'
|
|
193
219
|
|
|
194
|
-
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
195
|
-
const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
|
|
196
|
-
|
|
197
220
|
/** Schemastery configuration validated by the Loader. */
|
|
198
221
|
export const Config: z<Config> = z.object({
|
|
199
222
|
enabled: z.boolean().default(false),
|
|
@@ -315,35 +338,29 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
|
|
|
315
338
|
])
|
|
316
339
|
}
|
|
317
340
|
|
|
318
|
-
/**
|
|
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
|
+
*/
|
|
319
349
|
function tlsStatusLine(cfg: Config): string {
|
|
320
350
|
if (!cfg.tlsEnabled) return 'off'
|
|
321
351
|
if (cfg.tlsMode === 'custom') {
|
|
322
352
|
return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
|
|
323
353
|
}
|
|
324
354
|
try {
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
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)
|
|
328
358
|
return `self-signed [${info.subject}] exp ${info.validTo}`
|
|
329
359
|
} catch (error) {
|
|
330
360
|
return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
|
|
331
361
|
}
|
|
332
362
|
}
|
|
333
363
|
|
|
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
364
|
/**
|
|
348
365
|
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
349
366
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
@@ -370,23 +387,106 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
|
|
|
370
387
|
return true
|
|
371
388
|
}
|
|
372
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
|
+
|
|
373
435
|
export function apply(ctx: Context, config: Config): void {
|
|
374
436
|
let state = loadState()
|
|
375
437
|
let gateway: LanGateway | undefined
|
|
376
438
|
let startedWith: string | undefined
|
|
377
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
|
+
*/
|
|
378
446
|
let manualOverride: boolean | undefined
|
|
379
447
|
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
380
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
|
|
381
456
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
382
457
|
let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
|
|
383
458
|
/** The authoritative config: settings section when attached, else composition. */
|
|
384
459
|
let configSource: () => Config = () => config
|
|
385
|
-
/**
|
|
386
|
-
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
|
|
387
478
|
|
|
388
479
|
const effective = (): Config => configSource()
|
|
389
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
|
+
|
|
390
490
|
const startGateway = async (cfg: Config): Promise<void> => {
|
|
391
491
|
if (gateway !== undefined) return
|
|
392
492
|
const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable })
|
|
@@ -420,6 +520,13 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
420
520
|
},
|
|
421
521
|
}, state)
|
|
422
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
|
+
}
|
|
423
530
|
gateway = next
|
|
424
531
|
startedWith = listenerKey(cfg, makeRelay !== undefined)
|
|
425
532
|
ctx.logger.info(
|
|
@@ -445,46 +552,70 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
445
552
|
}
|
|
446
553
|
}
|
|
447
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
|
+
|
|
448
566
|
/** Reconcile the listener with the effective config (start/stop/restart). */
|
|
449
567
|
const syncGateway = (reason: string): Promise<void> => {
|
|
450
|
-
|
|
568
|
+
return enqueue(reason, async () => {
|
|
451
569
|
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}`)
|
|
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)
|
|
466
579
|
}
|
|
467
580
|
})
|
|
468
|
-
|
|
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
|
|
469
594
|
}
|
|
470
595
|
|
|
471
596
|
// The tunables also live in the `lan-gateway` settings section: while the
|
|
472
597
|
// settings service exists, the section (composition base + user overrides)
|
|
473
598
|
// is the authoritative config, and every committed change re-syncs the
|
|
474
599
|
// listener — so the Settings → Plugins page adjusts the gateway live.
|
|
475
|
-
// Registered directly (not via
|
|
476
|
-
//
|
|
477
|
-
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.
|
|
478
602
|
ctx.inject(['settings'], (sctx) => {
|
|
479
603
|
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
480
604
|
settingsScope = scope
|
|
605
|
+
settingsProvider = sctx.settings
|
|
606
|
+
settingsAttached = true
|
|
481
607
|
configSource = () => scope.get()
|
|
482
608
|
sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
|
|
483
609
|
sctx.effect(() => () => {
|
|
484
|
-
// The settings provider went away (disposal / provider reload): fall
|
|
485
|
-
//
|
|
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.
|
|
486
614
|
configSource = () => config
|
|
487
615
|
settingsScope = undefined
|
|
616
|
+
settingsProvider = undefined
|
|
617
|
+
settingsAttached = false
|
|
618
|
+
void syncGateway('settings detach')
|
|
488
619
|
})
|
|
489
620
|
void syncGateway('settings attach')
|
|
490
621
|
})
|
|
@@ -495,6 +626,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
495
626
|
// relay exchanges. Optional: on an older base the callback never runs, the
|
|
496
627
|
// gateway forwards without a relay, and lanPasswordless stays refused.
|
|
497
628
|
ctx.inject(['connection'], (ccx) => {
|
|
629
|
+
const generation = ++connectionGeneration
|
|
498
630
|
upstreamSessionAvailable = true
|
|
499
631
|
ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
|
|
500
632
|
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
@@ -504,6 +636,15 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
504
636
|
// and looks exactly like a base with no browser sessions.
|
|
505
637
|
log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
|
|
506
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
|
+
})
|
|
507
648
|
// A listener that started before the connection service appeared must
|
|
508
649
|
// restart so it picks up the relay (and the now-correct fail-closed facts).
|
|
509
650
|
void syncGateway('connection attach')
|
|
@@ -516,6 +657,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
516
657
|
// reach it — a genuine local user, or a local process that could already read
|
|
517
658
|
// ~/.dsh.
|
|
518
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
|
+
}
|
|
519
671
|
const send = (status: number, body: unknown): void => {
|
|
520
672
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
521
673
|
res.end(JSON.stringify(body))
|
|
@@ -525,15 +677,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
525
677
|
return
|
|
526
678
|
}
|
|
527
679
|
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
|
-
})
|
|
680
|
+
send(200, snapshot())
|
|
537
681
|
return
|
|
538
682
|
}
|
|
539
683
|
if (req.method !== 'POST') {
|
|
@@ -553,20 +697,18 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
553
697
|
send(400, { error: 'body must be a config object' })
|
|
554
698
|
return
|
|
555
699
|
}
|
|
556
|
-
|
|
557
|
-
// descriptive message on any invalid value.
|
|
558
|
-
let candidate: Config
|
|
559
|
-
try {
|
|
560
|
-
candidate = Config(submitted as Config)
|
|
561
|
-
} catch (error) {
|
|
562
|
-
send(400, { error: error instanceof Error ? error.message : String(error) })
|
|
563
|
-
return
|
|
564
|
-
}
|
|
565
|
-
if (settingsScope === undefined) {
|
|
700
|
+
if (settingsProvider === undefined) {
|
|
566
701
|
send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
|
|
567
702
|
return
|
|
568
703
|
}
|
|
569
|
-
//
|
|
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 })
|
|
570
712
|
// A structural problem (legacy authRequired:false, lanPasswordless without
|
|
571
713
|
// a session-capable base) is invalid however it is reached; a start
|
|
572
714
|
// condition (plaintext without TLS/terminator/opt-in) only blocks a save
|
|
@@ -579,28 +721,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
579
721
|
send(409, { error: `config cannot start: ${problems.join(' ')}` })
|
|
580
722
|
return
|
|
581
723
|
}
|
|
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
724
|
try {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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.
|
|
594
736
|
await syncGateway('config route save')
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
lastError: lastError ?? null,
|
|
603
|
-
})
|
|
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)
|
|
604
744
|
} catch (error) {
|
|
605
745
|
send(409, { error: error instanceof Error ? error.message : String(error) })
|
|
606
746
|
}
|
|
@@ -612,7 +752,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
612
752
|
|
|
613
753
|
const controller: GatewayController = {
|
|
614
754
|
status(): ToolResult {
|
|
615
|
-
const cfg =
|
|
755
|
+
const cfg = desiredConfig()
|
|
616
756
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
|
|
617
757
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
|
|
618
758
|
return {
|
|
@@ -627,21 +767,21 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
627
767
|
+ `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
|
|
628
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'}`
|
|
629
769
|
+ `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
|
|
630
|
-
+ (manualOverride !== undefined
|
|
770
|
+
+ (manualOverride !== undefined && !settingsAttached
|
|
631
771
|
? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
|
|
632
772
|
: '')
|
|
633
773
|
+ (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
|
|
634
774
|
}
|
|
635
775
|
},
|
|
636
776
|
async enable(): Promise<ToolResult> {
|
|
637
|
-
|
|
777
|
+
await setRunIntent(true)
|
|
638
778
|
await syncGateway('tool enable')
|
|
639
779
|
return gateway !== undefined
|
|
640
780
|
? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
|
|
641
781
|
: { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
|
|
642
782
|
},
|
|
643
783
|
async disable(): Promise<ToolResult> {
|
|
644
|
-
|
|
784
|
+
await setRunIntent(false)
|
|
645
785
|
await syncGateway('tool disable')
|
|
646
786
|
return { ok: true, message: 'Gateway disabled.' }
|
|
647
787
|
},
|
|
@@ -650,26 +790,29 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
650
790
|
return { ok: false, message: 'Password must be at least 8 characters.' }
|
|
651
791
|
}
|
|
652
792
|
const setting = password !== undefined && password.length > 0
|
|
653
|
-
const
|
|
654
|
-
state = setPassword(state, setting ? password : undefined)
|
|
793
|
+
const hadPassword = state.password !== undefined
|
|
794
|
+
state = await setPassword(state, setting ? password : undefined)
|
|
655
795
|
saveState(state)
|
|
656
796
|
gateway?.setState(state)
|
|
657
797
|
if (!setting) {
|
|
658
798
|
// Clearing the credential must not leave an open gateway serving
|
|
659
799
|
// sessions the old password authorized: stop the listener. A password
|
|
660
800
|
// is required to run, so a later enable fails closed.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
await stopGateway()
|
|
801
|
+
await setRunIntent(false)
|
|
802
|
+
return enqueue('password cleared', async () => {
|
|
803
|
+
if (gateway !== undefined) await stopGateway()
|
|
664
804
|
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
return {
|
|
805
|
+
}).then(() => ({
|
|
668
806
|
ok: true,
|
|
669
807
|
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
670
|
-
}
|
|
808
|
+
}))
|
|
671
809
|
}
|
|
672
|
-
|
|
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')
|
|
673
816
|
return {
|
|
674
817
|
ok: true,
|
|
675
818
|
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
@@ -697,29 +840,40 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
697
840
|
if (hosts.length === 0) {
|
|
698
841
|
return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
|
|
699
842
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
+
}
|
|
705
854
|
lastError = undefined
|
|
855
|
+
} catch (error) {
|
|
856
|
+
failure = error instanceof Error ? error.message : String(error)
|
|
706
857
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
|
|
712
|
-
}
|
|
713
|
-
}
|
|
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}` }
|
|
714
862
|
},
|
|
715
863
|
}
|
|
716
864
|
|
|
717
865
|
// Register the management tool once.
|
|
718
866
|
ctx.tools.register(lanGatewayTool(controller))
|
|
719
867
|
|
|
720
|
-
// 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.
|
|
721
872
|
ctx.effect(() => {
|
|
722
873
|
void syncGateway('boot')
|
|
723
|
-
return
|
|
874
|
+
return async () => {
|
|
875
|
+
disposed = true
|
|
876
|
+
await enqueue('dispose', stopGateway)
|
|
877
|
+
}
|
|
724
878
|
}, 'dsh-lan-gateway: listener lifecycle')
|
|
725
879
|
}
|
package/src/login.ts
CHANGED
|
@@ -14,12 +14,9 @@ export const LOGIN_PATH = '/__login' as const
|
|
|
14
14
|
/** Path the gateway owns and never forwards: signs the session out. */
|
|
15
15
|
export const LOGOUT_PATH = '/__logout' as const
|
|
16
16
|
|
|
17
|
-
/** The cookie name used for the signed session. */
|
|
18
|
-
export const COOKIE_NAME = 'dsh_gw_auth' as const
|
|
19
|
-
|
|
20
17
|
export interface LoginPageOptions {
|
|
21
18
|
error?: string
|
|
22
|
-
/**
|
|
19
|
+
/** The attempt was refused by the rate limiter, not by a wrong password. */
|
|
23
20
|
limited?: boolean
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -95,19 +92,6 @@ export function serveLoginGet(res: ServerResponse, extraHeaders: OutgoingHttpHea
|
|
|
95
92
|
res.end(renderLoginPage())
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
/** Parse an application/x-www-form-urlencoded body into its fields. */
|
|
99
|
-
export function parseFormBody(body: string): Record<string, string> {
|
|
100
|
-
const out: Record<string, string> = {}
|
|
101
|
-
for (const pair of body.split('&')) {
|
|
102
|
-
if (pair === '') continue
|
|
103
|
-
const eq = pair.indexOf('=')
|
|
104
|
-
const key = eq === -1 ? pair : pair.slice(0, eq)
|
|
105
|
-
const value = eq === -1 ? '' : pair.slice(eq + 1)
|
|
106
|
-
out[decodeURIComponent(key.replaceAll('+', ' '))] = decodeURIComponent(value.replaceAll('+', ' '))
|
|
107
|
-
}
|
|
108
|
-
return out
|
|
109
|
-
}
|
|
110
|
-
|
|
111
95
|
/** Read a request body up to a byte ceiling, rejecting anything larger. */
|
|
112
96
|
export function readBody(req: IncomingMessage, maxBytes: number, res: ServerResponse): Promise<string | undefined> {
|
|
113
97
|
return new Promise((resolve) => {
|