@riceawa/dsh-lan-gateway 0.5.5 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -22
- package/lib/client.js +271 -33
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +86 -4
- package/lib/index.js +204 -88
- package/package.json +27 -15
- package/skills/lan-gateway.md +15 -1
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +276 -36
- package/src/config-fields.ts +13 -0
- package/src/index.ts +284 -119
package/src/index.ts
CHANGED
|
@@ -31,9 +31,12 @@
|
|
|
31
31
|
* Every tunable is also exposed as the `lan-gateway` user-settings namespace
|
|
32
32
|
* (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
|
|
33
33
|
* port, CIDRs, auth, and TLS live; the running listener restarts on change.
|
|
34
|
-
* The card reads/writes through the loopback-only `/lan-gateway/config` route
|
|
35
|
-
*
|
|
36
|
-
* gateway
|
|
34
|
+
* The card reads/writes through the loopback-only `/lan-gateway/config` route
|
|
35
|
+
* and changes the login password through the loopback-only
|
|
36
|
+
* `/lan-gateway/password` route (the credential is a secret in `state.json`,
|
|
37
|
+
* never a settings key, so it cannot ride a config patch); remote browsers get
|
|
38
|
+
* a 403 from the gateway for that whole prefix and manage the gateway through
|
|
39
|
+
* the `lan_gateway` tool instead.
|
|
37
40
|
*
|
|
38
41
|
* Disabled by default in the bundle patch (safe): the listener opens only
|
|
39
42
|
* after `lan_gateway enable` or `enabled: true`.
|
|
@@ -45,10 +48,15 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
45
48
|
import { randomBytes } from 'node:crypto'
|
|
46
49
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
47
50
|
import z from '@deepseek-ai/schemastery'
|
|
48
|
-
|
|
51
|
+
// Type-only: the `settings` service (0.1.7 addresses a write by profile entry
|
|
52
|
+
// id) and the Loader's `fiber.entry`, which is where this plugin reads its own
|
|
53
|
+
// entry id from.
|
|
54
|
+
import type SettingsService from '@deepseek-ai/dsh-settings'
|
|
55
|
+
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
49
56
|
import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
|
|
50
57
|
import {
|
|
51
58
|
CONFIG_FIELD_KEYS,
|
|
59
|
+
MIN_PASSWORD_LENGTH,
|
|
52
60
|
OPTIONAL_CONFIG_KEYS,
|
|
53
61
|
} from './config-fields.ts'
|
|
54
62
|
import { LanGateway } from './gateway.ts'
|
|
@@ -209,35 +217,112 @@ export interface Config {
|
|
|
209
217
|
secureCookies?: boolean
|
|
210
218
|
}
|
|
211
219
|
|
|
220
|
+
/** A `.volatile()` config field as the Loader hands it to `apply`. */
|
|
221
|
+
export interface ConfigRef<T> {
|
|
222
|
+
/** The current value; updated in place when the profile entry is written. */
|
|
223
|
+
get(): T
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The config object `apply` receives: dsh 0.1.7 hands every `.volatile()` field
|
|
228
|
+
* over as a reference rather than a value, so the plugin reads its live config
|
|
229
|
+
* through `readConfig`. A field declared optional and left unset resolves to
|
|
230
|
+
* `undefined` instead of a reference, hence the union inside `ConfigRef`.
|
|
231
|
+
*/
|
|
232
|
+
export type ConfigRefs = { [K in keyof Required<Config>]: ConfigRef<Config[K]> }
|
|
233
|
+
|
|
212
234
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
235
|
+
* Schemastery configuration validated by the Loader.
|
|
236
|
+
*
|
|
237
|
+
* Every field is `.volatile()`, which is what lets the Settings service write
|
|
238
|
+
* it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
|
|
239
|
+
* other path (`not volatile`). The mark also changes the runtime shape — a
|
|
240
|
+
* volatile field arrives as a reference (see `ConfigRefs`), never as the plain
|
|
241
|
+
* value the rest of this file expects — so read it through `readConfig`.
|
|
217
242
|
*/
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
allowInsecurePlaintext: z.boolean().default(false),
|
|
237
|
-
trustedTerminator: z.string(),
|
|
238
|
-
secureCookies: z.boolean(),
|
|
243
|
+
export const Config = z.object({
|
|
244
|
+
enabled: z.boolean().default(false).volatile(),
|
|
245
|
+
gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
|
|
246
|
+
dshTargetPort: z.natural().min(1).max(65535).volatile(),
|
|
247
|
+
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
|
|
248
|
+
lanPasswordless: z.boolean().default(false).volatile(),
|
|
249
|
+
authRequired: z.boolean().default(true).volatile(),
|
|
250
|
+
cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
|
|
251
|
+
cookieName: z.string().default('dsh_gw_auth').volatile(),
|
|
252
|
+
tlsEnabled: z.boolean().default(false).volatile(),
|
|
253
|
+
tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed').volatile(),
|
|
254
|
+
tlsCertPath: z.string().volatile(),
|
|
255
|
+
tlsKeyPath: z.string().volatile(),
|
|
256
|
+
tlsSelfSignedHosts: z.string().default('localhost').volatile(),
|
|
257
|
+
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
|
|
258
|
+
allowInsecurePlaintext: z.boolean().default(false).volatile(),
|
|
259
|
+
trustedTerminator: z.string().volatile(),
|
|
260
|
+
secureCookies: z.boolean().volatile(),
|
|
239
261
|
})
|
|
240
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Unwrap the config references into the plain values every other function in
|
|
265
|
+
* this file reads. Called on each access rather than once, because a settings
|
|
266
|
+
* write updates the references in place.
|
|
267
|
+
* @param refs - the config object handed to `apply`.
|
|
268
|
+
* @returns one detached plain snapshot.
|
|
269
|
+
*/
|
|
270
|
+
export function readConfig(refs: ConfigRefs): Config {
|
|
271
|
+
const dshTargetPort = refs.dshTargetPort?.get()
|
|
272
|
+
const authRequired = refs.authRequired?.get()
|
|
273
|
+
const tlsCertPath = refs.tlsCertPath?.get()
|
|
274
|
+
const tlsKeyPath = refs.tlsKeyPath?.get()
|
|
275
|
+
const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get()
|
|
276
|
+
const trustedTerminator = refs.trustedTerminator?.get()
|
|
277
|
+
const secureCookies = refs.secureCookies?.get()
|
|
278
|
+
return {
|
|
279
|
+
enabled: refs.enabled.get(),
|
|
280
|
+
gatewayPort: refs.gatewayPort.get(),
|
|
281
|
+
lanCidrs: [...refs.lanCidrs.get()],
|
|
282
|
+
lanPasswordless: refs.lanPasswordless.get(),
|
|
283
|
+
cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
|
|
284
|
+
cookieName: refs.cookieName.get(),
|
|
285
|
+
tlsEnabled: refs.tlsEnabled.get(),
|
|
286
|
+
tlsMode: refs.tlsMode.get(),
|
|
287
|
+
tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
|
|
288
|
+
allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
|
|
289
|
+
// `exactOptionalPropertyTypes` is on, so an absent optional key must stay
|
|
290
|
+
// absent rather than be assigned `undefined`. Every field here has a schema
|
|
291
|
+
// default or is genuinely optional, so an absent reference means unset.
|
|
292
|
+
...(dshTargetPort !== undefined ? { dshTargetPort } : {}),
|
|
293
|
+
...(authRequired !== undefined ? { authRequired } : {}),
|
|
294
|
+
...(tlsCertPath !== undefined ? { tlsCertPath } : {}),
|
|
295
|
+
...(tlsKeyPath !== undefined ? { tlsKeyPath } : {}),
|
|
296
|
+
...(tlsSelfSignedHosts !== undefined ? { tlsSelfSignedHosts } : {}),
|
|
297
|
+
...(trustedTerminator !== undefined ? { trustedTerminator } : {}),
|
|
298
|
+
...(secureCookies !== undefined ? { secureCookies } : {}),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Build the reference-shaped config `apply` receives, exactly as the Loader
|
|
304
|
+
* builds it. Exported for tests that drive `apply` directly.
|
|
305
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
306
|
+
* @returns one reference per volatile field.
|
|
307
|
+
*/
|
|
308
|
+
export function configRefs(raw: object): ConfigRefs {
|
|
309
|
+
return Config(raw) as unknown as ConfigRefs
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Validate a raw config object the way the Loader does, and unwrap it.
|
|
314
|
+
*
|
|
315
|
+
* `Config` marks every field volatile, so a validation hands the values back as
|
|
316
|
+
* references (typed deeply-readonly by schemastery); this returns the plain
|
|
317
|
+
* shape the rest of the file reads. Used to judge a config the Settings card is
|
|
318
|
+
* about to save, before it is persisted.
|
|
319
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
320
|
+
* @returns the validated plain config.
|
|
321
|
+
*/
|
|
322
|
+
export function validateConfig(raw: object): Config {
|
|
323
|
+
return readConfig(configRefs(raw))
|
|
324
|
+
}
|
|
325
|
+
|
|
241
326
|
/** Facts the fail-closed start guard needs to judge a config. */
|
|
242
327
|
export interface StartFacts {
|
|
243
328
|
/** Whether the dsh base enforces browser-session auth (auto-detected). */
|
|
@@ -432,7 +517,7 @@ export function buildConfigPatch(submitted: Record<string, unknown>): {
|
|
|
432
517
|
return { patch, clear, unknown }
|
|
433
518
|
}
|
|
434
519
|
|
|
435
|
-
export function apply(ctx: Context, config:
|
|
520
|
+
export function apply(ctx: Context, config: ConfigRefs): void {
|
|
436
521
|
let state = loadState()
|
|
437
522
|
let gateway: LanGateway | undefined
|
|
438
523
|
let startedWith: string | undefined
|
|
@@ -455,18 +540,20 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
455
540
|
let connectionGeneration = 0
|
|
456
541
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
457
542
|
let makeRelay: ((dshPort: number) => UpstreamSession) | undefined
|
|
458
|
-
/**
|
|
459
|
-
let configSource: () => Config = () => config
|
|
460
|
-
/** Whether writes go to the settings section rather than staying in memory. */
|
|
543
|
+
/** Whether the settings service is attached, so writes reach the profile entry. */
|
|
461
544
|
let settingsAttached = false
|
|
462
|
-
/** The settings scope for the `lan-gateway` namespace, while one is attached. */
|
|
463
|
-
let settingsScope: SettingsScope<Config> | undefined
|
|
464
545
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
546
|
+
* This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
|
|
547
|
+
* the *entry id* — the `lan-gateway` namespace this plugin used to register
|
|
548
|
+
* with is gone along with `settingsScope`.
|
|
549
|
+
*/
|
|
550
|
+
let settingsEntryId: string | undefined
|
|
551
|
+
/**
|
|
552
|
+
* The settings service, for the one write a merge patch cannot express: a key
|
|
553
|
+
* must be *removed* to re-inherit the composition layer, and only its
|
|
554
|
+
* path-addressed `mutate` can unset one.
|
|
468
555
|
*/
|
|
469
|
-
let settingsProvider:
|
|
556
|
+
let settingsProvider: SettingsService | undefined
|
|
470
557
|
/**
|
|
471
558
|
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
472
559
|
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
@@ -476,7 +563,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
476
563
|
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
477
564
|
let disposed = false
|
|
478
565
|
|
|
479
|
-
const effective = (): Config =>
|
|
566
|
+
const effective = (): Config => readConfig(config)
|
|
480
567
|
|
|
481
568
|
/** Queue one lifecycle action behind every action already running. */
|
|
482
569
|
const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
|
|
@@ -557,8 +644,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
557
644
|
const cfg = effective()
|
|
558
645
|
// Without a settings service there is nowhere to record the tool's intent,
|
|
559
646
|
// so it lives in memory as an override on the composition entry. With one
|
|
560
|
-
// attached, `enabled` already carries it and an override
|
|
561
|
-
// card — the defect this replaces.
|
|
647
|
+
// attached, the profile entry's `enabled` already carries it and an override
|
|
648
|
+
// would shadow the card — the defect this replaces.
|
|
562
649
|
if (settingsAttached) return cfg
|
|
563
650
|
return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
|
|
564
651
|
}
|
|
@@ -580,39 +667,85 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
580
667
|
})
|
|
581
668
|
}
|
|
582
669
|
|
|
583
|
-
/** Record the run intent where it will survive: the
|
|
670
|
+
/** Record the run intent where it will survive: the profile entry, or memory. */
|
|
584
671
|
const setRunIntent = async (enabled: boolean): Promise<void> => {
|
|
585
|
-
if (settingsAttached &&
|
|
586
|
-
// The same field the Settings card writes
|
|
587
|
-
// in the
|
|
588
|
-
await
|
|
589
|
-
// The
|
|
590
|
-
//
|
|
672
|
+
if (settingsAttached && settingsProvider !== undefined && settingsEntryId !== undefined) {
|
|
673
|
+
// The same field the Settings card writes, in this plugin's own profile
|
|
674
|
+
// entry. A merge patch, so nothing else in the entry is disturbed.
|
|
675
|
+
await settingsProvider.update(settingsEntryId, { enabled })
|
|
676
|
+
// The write updates the config references in place and the loader then
|
|
677
|
+
// emits `loader/volatile-update`, which queues the reconcile; waiting on
|
|
678
|
+
// that here would deadlock behind this same write.
|
|
591
679
|
return
|
|
592
680
|
}
|
|
593
681
|
manualOverride = enabled
|
|
594
682
|
}
|
|
595
683
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
684
|
+
/**
|
|
685
|
+
* Apply a login-password change and reconcile the listener. One
|
|
686
|
+
* implementation behind both surfaces the operator has — the `lan_gateway`
|
|
687
|
+
* tool's `set-password` command and the Settings card's
|
|
688
|
+
* `/lan-gateway/password` route — so the epoch bump, the listener stop on a
|
|
689
|
+
* clear, and the first-password reconcile cannot diverge between them.
|
|
690
|
+
*
|
|
691
|
+
* The credential itself is never read back out: `state` holds a scrypt hash
|
|
692
|
+
* and salt, and neither is returned to a caller.
|
|
693
|
+
*/
|
|
694
|
+
const applyPassword = async (password: string | undefined): Promise<ToolResult> => {
|
|
695
|
+
if (password !== undefined && password.length > 0 && password.length < MIN_PASSWORD_LENGTH) {
|
|
696
|
+
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` }
|
|
697
|
+
}
|
|
698
|
+
const setting = password !== undefined && password.length > 0
|
|
699
|
+
const hadPassword = state.password !== undefined
|
|
700
|
+
state = await setPassword(state, setting ? password : undefined)
|
|
701
|
+
saveState(state)
|
|
702
|
+
gateway?.setState(state)
|
|
703
|
+
if (!setting) {
|
|
704
|
+
// Clearing the credential must not leave an open gateway serving
|
|
705
|
+
// sessions the old password authorized: stop the listener. A password
|
|
706
|
+
// is required to run, so a later enable fails closed.
|
|
707
|
+
await setRunIntent(false)
|
|
708
|
+
return enqueue('password cleared', async () => {
|
|
709
|
+
if (gateway !== undefined) await stopGateway()
|
|
710
|
+
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
711
|
+
}).then(() => ({
|
|
712
|
+
ok: true,
|
|
713
|
+
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
714
|
+
}))
|
|
715
|
+
}
|
|
716
|
+
// The first password turns a dormant "enabled but unpassworded" intent
|
|
717
|
+
// into a startable one, so reconcile: the listener was refused a moment
|
|
718
|
+
// ago for a reason that no longer holds. A later password change needs no
|
|
719
|
+
// reconcile (the listener is already running or already refused for some
|
|
720
|
+
// other reason), and reconciling anyway would be harmless but noisy.
|
|
721
|
+
if (!hadPassword) await syncGateway('password set')
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
|
|
729
|
+
// it by *entry id*: the `lan-gateway` settings namespace this plugin used to
|
|
730
|
+
// register (and the scope handle it wrote through) no longer exist. The entry
|
|
731
|
+
// id is the Loader's, so it comes from the fiber; without a Loader there is no
|
|
732
|
+
// entry to write and the composition value stands alone.
|
|
602
733
|
ctx.inject(['settings'], (sctx) => {
|
|
603
|
-
const
|
|
604
|
-
|
|
734
|
+
const entryId = ctx.fiber.entry?.options.id
|
|
735
|
+
if (entryId === undefined) return
|
|
736
|
+
settingsEntryId = entryId
|
|
605
737
|
settingsProvider = sctx.settings
|
|
606
738
|
settingsAttached = true
|
|
607
|
-
|
|
608
|
-
sctx.effect(() =>
|
|
739
|
+
// This plugin ships its own card, so it owns its page policy.
|
|
740
|
+
sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber))
|
|
741
|
+
// A committed write updates the volatile references in place (no remount)
|
|
742
|
+
// and emits this, so the listener follows the new config.
|
|
743
|
+
sctx.effect(() => ctx.on('loader/volatile-update', () => { void syncGateway('settings change') }))
|
|
609
744
|
sctx.effect(() => () => {
|
|
610
|
-
// The settings
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
configSource = () => config
|
|
615
|
-
settingsScope = undefined
|
|
745
|
+
// The settings service went away (disposal / provider reload): the
|
|
746
|
+
// composition entry is authoritative again, and the tool's intent falls
|
|
747
|
+
// back to memory. Reconcile so the listener follows it.
|
|
748
|
+
settingsEntryId = undefined
|
|
616
749
|
settingsProvider = undefined
|
|
617
750
|
settingsAttached = false
|
|
618
751
|
void syncGateway('settings detach')
|
|
@@ -650,28 +783,35 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
650
783
|
void syncGateway('connection attach')
|
|
651
784
|
})
|
|
652
785
|
|
|
653
|
-
// The Settings → Plugins card reads and writes through
|
|
654
|
-
// JSON
|
|
786
|
+
// The Settings → Plugins card reads and writes through these loopback-only
|
|
787
|
+
// JSON routes (ModLens-style: the browser never touches the settings seam
|
|
655
788
|
// directly, so the card has no service dependencies to resolve). The gateway
|
|
656
|
-
// refuses to relay this prefix, so only the native loopback listener can
|
|
789
|
+
// refuses to relay this whole prefix, so only the native loopback listener can
|
|
657
790
|
// reach it — a genuine local user, or a local process that could already read
|
|
658
791
|
// ~/.dsh.
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
792
|
+
const sendJson = (res: ServerResponse, status: number, body: unknown): void => {
|
|
793
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
794
|
+
res.end(JSON.stringify(body))
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* What both card routes report. `passwordSet` is a boolean on purpose: the
|
|
798
|
+
* card must be able to say whether a credential exists without any part of
|
|
799
|
+
* that credential — not even its length — ever leaving this process.
|
|
800
|
+
*/
|
|
801
|
+
const snapshot = (): Record<string, unknown> => {
|
|
802
|
+
const cfg = effective()
|
|
803
|
+
return {
|
|
804
|
+
config: cfg,
|
|
805
|
+
running: gateway !== undefined,
|
|
806
|
+
port: cfg.gatewayPort,
|
|
807
|
+
tls: tlsStatusLine(cfg),
|
|
808
|
+
upstreamSessionAvailable,
|
|
809
|
+
passwordSet: state.password !== undefined,
|
|
810
|
+
lastError: lastError ?? null,
|
|
674
811
|
}
|
|
812
|
+
}
|
|
813
|
+
const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
814
|
+
const send = (status: number, body: unknown): void => sendJson(res, status, body)
|
|
675
815
|
if (!isTrustedConfigRequest(req)) {
|
|
676
816
|
send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
|
|
677
817
|
return
|
|
@@ -697,7 +837,12 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
697
837
|
send(400, { error: 'body must be a config object' })
|
|
698
838
|
return
|
|
699
839
|
}
|
|
700
|
-
|
|
840
|
+
// A settings write is addressed by this plugin's profile entry id; without
|
|
841
|
+
// one (no Loader, or no settings service) the composition value is all there
|
|
842
|
+
// is, and the operator edits the profile patch instead.
|
|
843
|
+
const settings = settingsProvider
|
|
844
|
+
const entryId = settingsEntryId
|
|
845
|
+
if (settings === undefined || entryId === undefined) {
|
|
701
846
|
send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
|
|
702
847
|
return
|
|
703
848
|
}
|
|
@@ -708,7 +853,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
708
853
|
// Validate the candidate the patch would produce — schema defaults included,
|
|
709
854
|
// exactly as the listener will resolve it — so the save fails closed on an
|
|
710
855
|
// unusable combination instead of persisting it.
|
|
711
|
-
const candidate =
|
|
856
|
+
const candidate = validateConfig({ ...effective(), ...patch })
|
|
712
857
|
// A structural problem (legacy authRequired:false, lanPasswordless without
|
|
713
858
|
// a session-capable base) is invalid however it is reached; a start
|
|
714
859
|
// condition (plaintext without TLS/terminator/opt-in) only blocks a save
|
|
@@ -730,7 +875,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
730
875
|
// key so it re-inherits the composition layer. Storing null instead would
|
|
731
876
|
// leave a null where the config expects a string, and `!== undefined`
|
|
732
877
|
// tests elsewhere would then read that null as a declared value.
|
|
733
|
-
if (ops.length > 0) await
|
|
878
|
+
if (ops.length > 0) await settings.mutate(entryId, ops)
|
|
734
879
|
// The write commits through the section's watcher; reconcile explicitly so
|
|
735
880
|
// the response reports a settled listener rather than a mid-restart one.
|
|
736
881
|
await syncGateway('config route save')
|
|
@@ -750,6 +895,58 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
750
895
|
'dsh-lan-gateway: config route',
|
|
751
896
|
)
|
|
752
897
|
|
|
898
|
+
// The card's password control, on its own loopback-only route. It cannot ride
|
|
899
|
+
// the config patch: the credential is a scrypt hash in `state.json`, not a
|
|
900
|
+
// settings key, and a key that reached `--dump-config` would be exactly the
|
|
901
|
+
// leak the state file exists to prevent. Same fence as the config route, so
|
|
902
|
+
// remote browsers (which the gateway answers 403 for this whole prefix) keep
|
|
903
|
+
// using the `lan_gateway` tool.
|
|
904
|
+
//
|
|
905
|
+
// This surface only *sets* a password. Clearing stops the listener by design,
|
|
906
|
+
// and a card button that silently takes remote access down is a footgun; the
|
|
907
|
+
// tool's `set-password` with an empty password remains the way to clear.
|
|
908
|
+
const passwordRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
909
|
+
if (!isTrustedConfigRequest(req)) {
|
|
910
|
+
sendJson(res, 403, { error: 'request refused: this route answers same-origin loopback requests only' })
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
if (req.method !== 'POST') {
|
|
914
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
const body = await readBody(req, 4 * 1024, res)
|
|
918
|
+
if (body === undefined) return // response already sent (413/400)
|
|
919
|
+
let submitted: unknown
|
|
920
|
+
try {
|
|
921
|
+
submitted = JSON.parse(body)
|
|
922
|
+
} catch {
|
|
923
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
const password = typeof submitted === 'object' && submitted !== null && !Array.isArray(submitted)
|
|
927
|
+
? (submitted as Record<string, unknown>)['password']
|
|
928
|
+
: undefined
|
|
929
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
930
|
+
sendJson(res, 400, {
|
|
931
|
+
error: `password must be a non-empty string of at least ${MIN_PASSWORD_LENGTH} characters; `
|
|
932
|
+
+ 'this route only sets one — use the lan_gateway tool to clear it',
|
|
933
|
+
})
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
const result = await applyPassword(password)
|
|
937
|
+
if (!result.ok) {
|
|
938
|
+
sendJson(res, 400, { error: result.message })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
// The snapshot flips `passwordSet` for the card's status badge. Nothing here
|
|
942
|
+
// carries the password, its hash, or its length back to the browser.
|
|
943
|
+
sendJson(res, 200, snapshot())
|
|
944
|
+
}
|
|
945
|
+
ctx.effect(
|
|
946
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/lan-gateway/password', handler: passwordRouteHandler }),
|
|
947
|
+
'dsh-lan-gateway: password route',
|
|
948
|
+
)
|
|
949
|
+
|
|
753
950
|
const controller: GatewayController = {
|
|
754
951
|
status(): ToolResult {
|
|
755
952
|
const cfg = desiredConfig()
|
|
@@ -785,39 +982,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
785
982
|
await syncGateway('tool disable')
|
|
786
983
|
return { ok: true, message: 'Gateway disabled.' }
|
|
787
984
|
},
|
|
788
|
-
|
|
789
|
-
if (password !== undefined && password.length > 0 && password.length < 8) {
|
|
790
|
-
return { ok: false, message: 'Password must be at least 8 characters.' }
|
|
791
|
-
}
|
|
792
|
-
const setting = password !== undefined && password.length > 0
|
|
793
|
-
const hadPassword = state.password !== undefined
|
|
794
|
-
state = await setPassword(state, setting ? password : undefined)
|
|
795
|
-
saveState(state)
|
|
796
|
-
gateway?.setState(state)
|
|
797
|
-
if (!setting) {
|
|
798
|
-
// Clearing the credential must not leave an open gateway serving
|
|
799
|
-
// sessions the old password authorized: stop the listener. A password
|
|
800
|
-
// is required to run, so a later enable fails closed.
|
|
801
|
-
await setRunIntent(false)
|
|
802
|
-
return enqueue('password cleared', async () => {
|
|
803
|
-
if (gateway !== undefined) await stopGateway()
|
|
804
|
-
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
805
|
-
}).then(() => ({
|
|
806
|
-
ok: true,
|
|
807
|
-
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
808
|
-
}))
|
|
809
|
-
}
|
|
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')
|
|
816
|
-
return {
|
|
817
|
-
ok: true,
|
|
818
|
-
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
819
|
-
}
|
|
820
|
-
},
|
|
985
|
+
setPassword: applyPassword,
|
|
821
986
|
rotateSecret(): ToolResult {
|
|
822
987
|
const next: GatewayState = {
|
|
823
988
|
cookieSecret: randomBytes(32).toString('base64'),
|