@riceawa/dsh-lan-gateway 0.5.5 → 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 +28 -18
- package/lib/client.js +38 -26
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +86 -4
- package/lib/index.js +107 -45
- package/package.json +26 -14
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +41 -27
- package/src/index.ts +155 -65
package/src/index.ts
CHANGED
|
@@ -45,7 +45,11 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
45
45
|
import { randomBytes } from 'node:crypto'
|
|
46
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'
|
|
50
54
|
import {
|
|
51
55
|
CONFIG_FIELD_KEYS,
|
|
@@ -209,35 +213,112 @@ export interface Config {
|
|
|
209
213
|
secureCookies?: boolean
|
|
210
214
|
}
|
|
211
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
|
+
|
|
212
230
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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`.
|
|
217
238
|
*/
|
|
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(),
|
|
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(),
|
|
239
257
|
})
|
|
240
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
|
+
|
|
241
322
|
/** Facts the fail-closed start guard needs to judge a config. */
|
|
242
323
|
export interface StartFacts {
|
|
243
324
|
/** Whether the dsh base enforces browser-session auth (auto-detected). */
|
|
@@ -432,7 +513,7 @@ export function buildConfigPatch(submitted: Record<string, unknown>): {
|
|
|
432
513
|
return { patch, clear, unknown }
|
|
433
514
|
}
|
|
434
515
|
|
|
435
|
-
export function apply(ctx: Context, config:
|
|
516
|
+
export function apply(ctx: Context, config: ConfigRefs): void {
|
|
436
517
|
let state = loadState()
|
|
437
518
|
let gateway: LanGateway | undefined
|
|
438
519
|
let startedWith: string | undefined
|
|
@@ -455,18 +536,20 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
455
536
|
let connectionGeneration = 0
|
|
456
537
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
457
538
|
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. */
|
|
539
|
+
/** Whether the settings service is attached, so writes reach the profile entry. */
|
|
461
540
|
let settingsAttached = false
|
|
462
|
-
/** The settings scope for the `lan-gateway` namespace, while one is attached. */
|
|
463
|
-
let settingsScope: SettingsScope<Config> | undefined
|
|
464
541
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
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.
|
|
468
551
|
*/
|
|
469
|
-
let settingsProvider:
|
|
552
|
+
let settingsProvider: SettingsService | undefined
|
|
470
553
|
/**
|
|
471
554
|
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
472
555
|
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
@@ -476,7 +559,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
476
559
|
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
477
560
|
let disposed = false
|
|
478
561
|
|
|
479
|
-
const effective = (): Config =>
|
|
562
|
+
const effective = (): Config => readConfig(config)
|
|
480
563
|
|
|
481
564
|
/** Queue one lifecycle action behind every action already running. */
|
|
482
565
|
const enqueue = (reason: string, action: () => Promise<void>): Promise<void> => {
|
|
@@ -557,8 +640,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
557
640
|
const cfg = effective()
|
|
558
641
|
// Without a settings service there is nowhere to record the tool's intent,
|
|
559
642
|
// 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.
|
|
643
|
+
// attached, the profile entry's `enabled` already carries it and an override
|
|
644
|
+
// would shadow the card — the defect this replaces.
|
|
562
645
|
if (settingsAttached) return cfg
|
|
563
646
|
return manualOverride === undefined ? cfg : { ...cfg, enabled: manualOverride }
|
|
564
647
|
}
|
|
@@ -580,39 +663,41 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
580
663
|
})
|
|
581
664
|
}
|
|
582
665
|
|
|
583
|
-
/** Record the run intent where it will survive: the
|
|
666
|
+
/** Record the run intent where it will survive: the profile entry, or memory. */
|
|
584
667
|
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
|
-
//
|
|
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.
|
|
591
675
|
return
|
|
592
676
|
}
|
|
593
677
|
manualOverride = enabled
|
|
594
678
|
}
|
|
595
679
|
|
|
596
|
-
// The tunables
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
// available to the /lan-gateway/config route for writes.
|
|
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.
|
|
602
685
|
ctx.inject(['settings'], (sctx) => {
|
|
603
|
-
const
|
|
604
|
-
|
|
686
|
+
const entryId = ctx.fiber.entry?.options.id
|
|
687
|
+
if (entryId === undefined) return
|
|
688
|
+
settingsEntryId = entryId
|
|
605
689
|
settingsProvider = sctx.settings
|
|
606
690
|
settingsAttached = true
|
|
607
|
-
|
|
608
|
-
sctx.effect(() =>
|
|
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') }))
|
|
609
696
|
sctx.effect(() => () => {
|
|
610
|
-
// The settings
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
configSource = () => config
|
|
615
|
-
settingsScope = undefined
|
|
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
|
|
616
701
|
settingsProvider = undefined
|
|
617
702
|
settingsAttached = false
|
|
618
703
|
void syncGateway('settings detach')
|
|
@@ -697,7 +782,12 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
697
782
|
send(400, { error: 'body must be a config object' })
|
|
698
783
|
return
|
|
699
784
|
}
|
|
700
|
-
|
|
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) {
|
|
701
791
|
send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
|
|
702
792
|
return
|
|
703
793
|
}
|
|
@@ -708,7 +798,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
708
798
|
// Validate the candidate the patch would produce — schema defaults included,
|
|
709
799
|
// exactly as the listener will resolve it — so the save fails closed on an
|
|
710
800
|
// unusable combination instead of persisting it.
|
|
711
|
-
const candidate =
|
|
801
|
+
const candidate = validateConfig({ ...effective(), ...patch })
|
|
712
802
|
// A structural problem (legacy authRequired:false, lanPasswordless without
|
|
713
803
|
// a session-capable base) is invalid however it is reached; a start
|
|
714
804
|
// condition (plaintext without TLS/terminator/opt-in) only blocks a save
|
|
@@ -730,7 +820,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
730
820
|
// key so it re-inherits the composition layer. Storing null instead would
|
|
731
821
|
// leave a null where the config expects a string, and `!== undefined`
|
|
732
822
|
// tests elsewhere would then read that null as a declared value.
|
|
733
|
-
if (ops.length > 0) await
|
|
823
|
+
if (ops.length > 0) await settings.mutate(entryId, ops)
|
|
734
824
|
// The write commits through the section's watcher; reconcile explicitly so
|
|
735
825
|
// the response reports a settled listener rather than a mid-restart one.
|
|
736
826
|
await syncGateway('config route save')
|