@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/lib/index.js
CHANGED
|
@@ -1897,33 +1897,91 @@ const name = "dsh-lan-gateway";
|
|
|
1897
1897
|
/** Requires the web server service (binds before this row's apply runs) and the tool registry. */
|
|
1898
1898
|
const inject = ["webServer", "tools"];
|
|
1899
1899
|
/**
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1900
|
+
* Schemastery configuration validated by the Loader.
|
|
1901
|
+
*
|
|
1902
|
+
* Every field is `.volatile()`, which is what lets the Settings service write
|
|
1903
|
+
* it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
|
|
1904
|
+
* other path (`not volatile`). The mark also changes the runtime shape — a
|
|
1905
|
+
* volatile field arrives as a reference (see `ConfigRefs`), never as the plain
|
|
1906
|
+
* value the rest of this file expects — so read it through `readConfig`.
|
|
1904
1907
|
*/
|
|
1905
|
-
const NS = "lan-gateway";
|
|
1906
|
-
/** Schemastery configuration validated by the Loader. */
|
|
1907
1908
|
const Config = z.object({
|
|
1908
|
-
enabled: z.boolean().default(false),
|
|
1909
|
-
gatewayPort: z.natural().min(1).max(65535).default(3081),
|
|
1910
|
-
dshTargetPort: z.natural().min(1).max(65535),
|
|
1911
|
-
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
|
|
1912
|
-
lanPasswordless: z.boolean().default(false),
|
|
1913
|
-
authRequired: z.boolean().default(true),
|
|
1914
|
-
cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
|
|
1915
|
-
cookieName: z.string().default("dsh_gw_auth"),
|
|
1916
|
-
tlsEnabled: z.boolean().default(false),
|
|
1917
|
-
tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
|
|
1918
|
-
tlsCertPath: z.string(),
|
|
1919
|
-
tlsKeyPath: z.string(),
|
|
1920
|
-
tlsSelfSignedHosts: z.string().default("localhost"),
|
|
1921
|
-
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
|
|
1922
|
-
allowInsecurePlaintext: z.boolean().default(false),
|
|
1923
|
-
trustedTerminator: z.string(),
|
|
1924
|
-
secureCookies: z.boolean()
|
|
1909
|
+
enabled: z.boolean().default(false).volatile(),
|
|
1910
|
+
gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
|
|
1911
|
+
dshTargetPort: z.natural().min(1).max(65535).volatile(),
|
|
1912
|
+
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
|
|
1913
|
+
lanPasswordless: z.boolean().default(false).volatile(),
|
|
1914
|
+
authRequired: z.boolean().default(true).volatile(),
|
|
1915
|
+
cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
|
|
1916
|
+
cookieName: z.string().default("dsh_gw_auth").volatile(),
|
|
1917
|
+
tlsEnabled: z.boolean().default(false).volatile(),
|
|
1918
|
+
tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed").volatile(),
|
|
1919
|
+
tlsCertPath: z.string().volatile(),
|
|
1920
|
+
tlsKeyPath: z.string().volatile(),
|
|
1921
|
+
tlsSelfSignedHosts: z.string().default("localhost").volatile(),
|
|
1922
|
+
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
|
|
1923
|
+
allowInsecurePlaintext: z.boolean().default(false).volatile(),
|
|
1924
|
+
trustedTerminator: z.string().volatile(),
|
|
1925
|
+
secureCookies: z.boolean().volatile()
|
|
1925
1926
|
});
|
|
1926
1927
|
/**
|
|
1928
|
+
* Unwrap the config references into the plain values every other function in
|
|
1929
|
+
* this file reads. Called on each access rather than once, because a settings
|
|
1930
|
+
* write updates the references in place.
|
|
1931
|
+
* @param refs - the config object handed to `apply`.
|
|
1932
|
+
* @returns one detached plain snapshot.
|
|
1933
|
+
*/
|
|
1934
|
+
function readConfig(refs) {
|
|
1935
|
+
const dshTargetPort = refs.dshTargetPort?.get();
|
|
1936
|
+
const authRequired = refs.authRequired?.get();
|
|
1937
|
+
const tlsCertPath = refs.tlsCertPath?.get();
|
|
1938
|
+
const tlsKeyPath = refs.tlsKeyPath?.get();
|
|
1939
|
+
const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get();
|
|
1940
|
+
const trustedTerminator = refs.trustedTerminator?.get();
|
|
1941
|
+
const secureCookies = refs.secureCookies?.get();
|
|
1942
|
+
return {
|
|
1943
|
+
enabled: refs.enabled.get(),
|
|
1944
|
+
gatewayPort: refs.gatewayPort.get(),
|
|
1945
|
+
lanCidrs: [...refs.lanCidrs.get()],
|
|
1946
|
+
lanPasswordless: refs.lanPasswordless.get(),
|
|
1947
|
+
cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
|
|
1948
|
+
cookieName: refs.cookieName.get(),
|
|
1949
|
+
tlsEnabled: refs.tlsEnabled.get(),
|
|
1950
|
+
tlsMode: refs.tlsMode.get(),
|
|
1951
|
+
tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
|
|
1952
|
+
allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
|
|
1953
|
+
...dshTargetPort !== void 0 ? { dshTargetPort } : {},
|
|
1954
|
+
...authRequired !== void 0 ? { authRequired } : {},
|
|
1955
|
+
...tlsCertPath !== void 0 ? { tlsCertPath } : {},
|
|
1956
|
+
...tlsKeyPath !== void 0 ? { tlsKeyPath } : {},
|
|
1957
|
+
...tlsSelfSignedHosts !== void 0 ? { tlsSelfSignedHosts } : {},
|
|
1958
|
+
...trustedTerminator !== void 0 ? { trustedTerminator } : {},
|
|
1959
|
+
...secureCookies !== void 0 ? { secureCookies } : {}
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Build the reference-shaped config `apply` receives, exactly as the Loader
|
|
1964
|
+
* builds it. Exported for tests that drive `apply` directly.
|
|
1965
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
1966
|
+
* @returns one reference per volatile field.
|
|
1967
|
+
*/
|
|
1968
|
+
function configRefs(raw) {
|
|
1969
|
+
return Config(raw);
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Validate a raw config object the way the Loader does, and unwrap it.
|
|
1973
|
+
*
|
|
1974
|
+
* `Config` marks every field volatile, so a validation hands the values back as
|
|
1975
|
+
* references (typed deeply-readonly by schemastery); this returns the plain
|
|
1976
|
+
* shape the rest of the file reads. Used to judge a config the Settings card is
|
|
1977
|
+
* about to save, before it is persisted.
|
|
1978
|
+
* @param raw - a config object; missing fields take their schema defaults.
|
|
1979
|
+
* @returns the validated plain config.
|
|
1980
|
+
*/
|
|
1981
|
+
function validateConfig(raw) {
|
|
1982
|
+
return readConfig(configRefs(raw));
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1927
1985
|
* The fail-closed problems that prevent a config from enabling the listener.
|
|
1928
1986
|
* Returns every problem (not just the first) so the operator sees the full
|
|
1929
1987
|
* migration at once. Exported for tests.
|
|
@@ -2101,16 +2159,18 @@ function apply(ctx, config) {
|
|
|
2101
2159
|
let connectionGeneration = 0;
|
|
2102
2160
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
2103
2161
|
let makeRelay;
|
|
2104
|
-
/**
|
|
2105
|
-
let configSource = () => config;
|
|
2106
|
-
/** Whether writes go to the settings section rather than staying in memory. */
|
|
2162
|
+
/** Whether the settings service is attached, so writes reach the profile entry. */
|
|
2107
2163
|
let settingsAttached = false;
|
|
2108
|
-
/** The settings scope for the `lan-gateway` namespace, while one is attached. */
|
|
2109
|
-
let settingsScope;
|
|
2110
2164
|
/**
|
|
2111
|
-
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2165
|
+
* This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
|
|
2166
|
+
* the *entry id* — the `lan-gateway` namespace this plugin used to register
|
|
2167
|
+
* with is gone along with `settingsScope`.
|
|
2168
|
+
*/
|
|
2169
|
+
let settingsEntryId;
|
|
2170
|
+
/**
|
|
2171
|
+
* The settings service, for the one write a merge patch cannot express: a key
|
|
2172
|
+
* must be *removed* to re-inherit the composition layer, and only its
|
|
2173
|
+
* path-addressed `mutate` can unset one.
|
|
2114
2174
|
*/
|
|
2115
2175
|
let settingsProvider;
|
|
2116
2176
|
/**
|
|
@@ -2121,7 +2181,7 @@ function apply(ctx, config) {
|
|
|
2121
2181
|
let lifecycle = Promise.resolve();
|
|
2122
2182
|
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
2123
2183
|
let disposed = false;
|
|
2124
|
-
const effective = () =>
|
|
2184
|
+
const effective = () => readConfig(config);
|
|
2125
2185
|
/** Queue one lifecycle action behind every action already running. */
|
|
2126
2186
|
const enqueue = (reason, action) => {
|
|
2127
2187
|
lifecycle = lifecycle.then(action).catch((error) => {
|
|
@@ -2198,26 +2258,62 @@ function apply(ctx, config) {
|
|
|
2198
2258
|
}
|
|
2199
2259
|
});
|
|
2200
2260
|
};
|
|
2201
|
-
/** Record the run intent where it will survive: the
|
|
2261
|
+
/** Record the run intent where it will survive: the profile entry, or memory. */
|
|
2202
2262
|
const setRunIntent = async (enabled) => {
|
|
2203
|
-
if (settingsAttached &&
|
|
2204
|
-
await
|
|
2263
|
+
if (settingsAttached && settingsProvider !== void 0 && settingsEntryId !== void 0) {
|
|
2264
|
+
await settingsProvider.update(settingsEntryId, { enabled });
|
|
2205
2265
|
return;
|
|
2206
2266
|
}
|
|
2207
2267
|
manualOverride = enabled;
|
|
2208
2268
|
};
|
|
2269
|
+
/**
|
|
2270
|
+
* Apply a login-password change and reconcile the listener. One
|
|
2271
|
+
* implementation behind both surfaces the operator has — the `lan_gateway`
|
|
2272
|
+
* tool's `set-password` command and the Settings card's
|
|
2273
|
+
* `/lan-gateway/password` route — so the epoch bump, the listener stop on a
|
|
2274
|
+
* clear, and the first-password reconcile cannot diverge between them.
|
|
2275
|
+
*
|
|
2276
|
+
* The credential itself is never read back out: `state` holds a scrypt hash
|
|
2277
|
+
* and salt, and neither is returned to a caller.
|
|
2278
|
+
*/
|
|
2279
|
+
const applyPassword = async (password) => {
|
|
2280
|
+
if (password !== void 0 && password.length > 0 && password.length < 8) return {
|
|
2281
|
+
ok: false,
|
|
2282
|
+
message: `Password must be at least 8 characters.`
|
|
2283
|
+
};
|
|
2284
|
+
const setting = password !== void 0 && password.length > 0;
|
|
2285
|
+
const hadPassword = state.password !== void 0;
|
|
2286
|
+
state = await setPassword(state, setting ? password : void 0);
|
|
2287
|
+
saveState(state);
|
|
2288
|
+
gateway?.setState(state);
|
|
2289
|
+
if (!setting) {
|
|
2290
|
+
await setRunIntent(false);
|
|
2291
|
+
return enqueue("password cleared", async () => {
|
|
2292
|
+
if (gateway !== void 0) await stopGateway();
|
|
2293
|
+
lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
|
|
2294
|
+
}).then(() => ({
|
|
2295
|
+
ok: true,
|
|
2296
|
+
message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
|
|
2297
|
+
}));
|
|
2298
|
+
}
|
|
2299
|
+
if (!hadPassword) await syncGateway("password set");
|
|
2300
|
+
return {
|
|
2301
|
+
ok: true,
|
|
2302
|
+
message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
|
|
2303
|
+
};
|
|
2304
|
+
};
|
|
2209
2305
|
ctx.inject(["settings"], (sctx) => {
|
|
2210
|
-
const
|
|
2211
|
-
|
|
2306
|
+
const entryId = ctx.fiber.entry?.options.id;
|
|
2307
|
+
if (entryId === void 0) return;
|
|
2308
|
+
settingsEntryId = entryId;
|
|
2212
2309
|
settingsProvider = sctx.settings;
|
|
2213
2310
|
settingsAttached = true;
|
|
2214
|
-
|
|
2215
|
-
sctx.effect(() =>
|
|
2311
|
+
sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
|
|
2312
|
+
sctx.effect(() => ctx.on("loader/volatile-update", () => {
|
|
2216
2313
|
syncGateway("settings change");
|
|
2217
2314
|
}));
|
|
2218
2315
|
sctx.effect(() => () => {
|
|
2219
|
-
|
|
2220
|
-
settingsScope = void 0;
|
|
2316
|
+
settingsEntryId = void 0;
|
|
2221
2317
|
settingsProvider = void 0;
|
|
2222
2318
|
settingsAttached = false;
|
|
2223
2319
|
syncGateway("settings detach");
|
|
@@ -2241,22 +2337,29 @@ function apply(ctx, config) {
|
|
|
2241
2337
|
});
|
|
2242
2338
|
syncGateway("connection attach");
|
|
2243
2339
|
});
|
|
2244
|
-
const
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2340
|
+
const sendJson = (res, status, body) => {
|
|
2341
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
2342
|
+
res.end(JSON.stringify(body));
|
|
2343
|
+
};
|
|
2344
|
+
/**
|
|
2345
|
+
* What both card routes report. `passwordSet` is a boolean on purpose: the
|
|
2346
|
+
* card must be able to say whether a credential exists without any part of
|
|
2347
|
+
* that credential — not even its length — ever leaving this process.
|
|
2348
|
+
*/
|
|
2349
|
+
const snapshot = () => {
|
|
2350
|
+
const cfg = effective();
|
|
2351
|
+
return {
|
|
2352
|
+
config: cfg,
|
|
2353
|
+
running: gateway !== void 0,
|
|
2354
|
+
port: cfg.gatewayPort,
|
|
2355
|
+
tls: tlsStatusLine(cfg),
|
|
2356
|
+
upstreamSessionAvailable,
|
|
2357
|
+
passwordSet: state.password !== void 0,
|
|
2358
|
+
lastError: lastError ?? null
|
|
2259
2359
|
};
|
|
2360
|
+
};
|
|
2361
|
+
const configRouteHandler = async (req, res) => {
|
|
2362
|
+
const send = (status, body) => sendJson(res, status, body);
|
|
2260
2363
|
if (!isTrustedConfigRequest(req)) {
|
|
2261
2364
|
send(403, { error: "request refused: this route answers same-origin loopback requests only" });
|
|
2262
2365
|
return;
|
|
@@ -2282,12 +2385,14 @@ function apply(ctx, config) {
|
|
|
2282
2385
|
send(400, { error: "body must be a config object" });
|
|
2283
2386
|
return;
|
|
2284
2387
|
}
|
|
2285
|
-
|
|
2388
|
+
const settings = settingsProvider;
|
|
2389
|
+
const entryId = settingsEntryId;
|
|
2390
|
+
if (settings === void 0 || entryId === void 0) {
|
|
2286
2391
|
send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
|
|
2287
2392
|
return;
|
|
2288
2393
|
}
|
|
2289
2394
|
const { patch, clear, unknown } = buildConfigPatch(submitted);
|
|
2290
|
-
const candidate =
|
|
2395
|
+
const candidate = validateConfig({
|
|
2291
2396
|
...effective(),
|
|
2292
2397
|
...patch
|
|
2293
2398
|
});
|
|
@@ -2306,7 +2411,7 @@ function apply(ctx, config) {
|
|
|
2306
2411
|
op: "unset",
|
|
2307
2412
|
path: [key]
|
|
2308
2413
|
}))];
|
|
2309
|
-
if (ops.length > 0) await
|
|
2414
|
+
if (ops.length > 0) await settings.mutate(entryId, ops);
|
|
2310
2415
|
await syncGateway("config route save");
|
|
2311
2416
|
const next = { ...snapshot() };
|
|
2312
2417
|
if (unknown.length > 0) next["ignored"] = unknown;
|
|
@@ -2320,7 +2425,42 @@ function apply(ctx, config) {
|
|
|
2320
2425
|
path: "/lan-gateway/config",
|
|
2321
2426
|
handler: configRouteHandler
|
|
2322
2427
|
}), "dsh-lan-gateway: config route");
|
|
2323
|
-
|
|
2428
|
+
const passwordRouteHandler = async (req, res) => {
|
|
2429
|
+
if (!isTrustedConfigRequest(req)) {
|
|
2430
|
+
sendJson(res, 403, { error: "request refused: this route answers same-origin loopback requests only" });
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
if (req.method !== "POST") {
|
|
2434
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const body = await readBody(req, 4096, res);
|
|
2438
|
+
if (body === void 0) return;
|
|
2439
|
+
let submitted;
|
|
2440
|
+
try {
|
|
2441
|
+
submitted = JSON.parse(body);
|
|
2442
|
+
} catch {
|
|
2443
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const password = typeof submitted === "object" && submitted !== null && !Array.isArray(submitted) ? submitted["password"] : void 0;
|
|
2447
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
2448
|
+
sendJson(res, 400, { error: `password must be a non-empty string of at least 8 characters; this route only sets one — use the lan_gateway tool to clear it` });
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
const result = await applyPassword(password);
|
|
2452
|
+
if (!result.ok) {
|
|
2453
|
+
sendJson(res, 400, { error: result.message });
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
sendJson(res, 200, snapshot());
|
|
2457
|
+
};
|
|
2458
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2459
|
+
kind: "exact",
|
|
2460
|
+
path: "/lan-gateway/password",
|
|
2461
|
+
handler: passwordRouteHandler
|
|
2462
|
+
}), "dsh-lan-gateway: password route");
|
|
2463
|
+
const controller = {
|
|
2324
2464
|
status() {
|
|
2325
2465
|
const cfg = desiredConfig();
|
|
2326
2466
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
@@ -2349,32 +2489,7 @@ function apply(ctx, config) {
|
|
|
2349
2489
|
message: "Gateway disabled."
|
|
2350
2490
|
};
|
|
2351
2491
|
},
|
|
2352
|
-
|
|
2353
|
-
if (password !== void 0 && password.length > 0 && password.length < 8) return {
|
|
2354
|
-
ok: false,
|
|
2355
|
-
message: "Password must be at least 8 characters."
|
|
2356
|
-
};
|
|
2357
|
-
const setting = password !== void 0 && password.length > 0;
|
|
2358
|
-
const hadPassword = state.password !== void 0;
|
|
2359
|
-
state = await setPassword(state, setting ? password : void 0);
|
|
2360
|
-
saveState(state);
|
|
2361
|
-
gateway?.setState(state);
|
|
2362
|
-
if (!setting) {
|
|
2363
|
-
await setRunIntent(false);
|
|
2364
|
-
return enqueue("password cleared", async () => {
|
|
2365
|
-
if (gateway !== void 0) await stopGateway();
|
|
2366
|
-
lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
|
|
2367
|
-
}).then(() => ({
|
|
2368
|
-
ok: true,
|
|
2369
|
-
message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
|
|
2370
|
-
}));
|
|
2371
|
-
}
|
|
2372
|
-
if (!hadPassword) await syncGateway("password set");
|
|
2373
|
-
return {
|
|
2374
|
-
ok: true,
|
|
2375
|
-
message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
|
|
2376
|
-
};
|
|
2377
|
-
},
|
|
2492
|
+
setPassword: applyPassword,
|
|
2378
2493
|
rotateSecret() {
|
|
2379
2494
|
const next = {
|
|
2380
2495
|
cookieSecret: randomBytes(32).toString("base64"),
|
|
@@ -2424,7 +2539,8 @@ function apply(ctx, config) {
|
|
|
2424
2539
|
message: `Failed to regenerate TLS certificate: ${failure}`
|
|
2425
2540
|
};
|
|
2426
2541
|
}
|
|
2427
|
-
}
|
|
2542
|
+
};
|
|
2543
|
+
ctx.tools.register(lanGatewayTool(controller));
|
|
2428
2544
|
ctx.effect(() => {
|
|
2429
2545
|
syncGateway("boot");
|
|
2430
2546
|
return async () => {
|
|
@@ -2434,4 +2550,4 @@ function apply(ctx, config) {
|
|
|
2434
2550
|
}, "dsh-lan-gateway: listener lifecycle");
|
|
2435
2551
|
}
|
|
2436
2552
|
//#endregion
|
|
2437
|
-
export { Config, apply, buildConfigPatch, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
|
|
2553
|
+
export { Config, apply, buildConfigPatch, configRefs, gatewayStartProblems, inject, isTrustedConfigRequest, name, readConfig, resolveSecureCookies, validateConfig };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riceawa/dsh-lan-gateway",
|
|
3
|
-
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, TLS, and the login password (the card overwrites the stored credential without ever displaying the old one; the password stays a scrypt hash in state.json, out of the config schema). Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
|
|
4
|
+
"version": "0.6.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -42,22 +42,34 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
46
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
47
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
48
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
|
|
47
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.4"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@deepseek-ai/cordis": "4.0.
|
|
52
|
-
"@deepseek-ai/
|
|
51
|
+
"@deepseek-ai/cordis": "4.0.4",
|
|
52
|
+
"@deepseek-ai/cordis-plugin-loader": "1.0.5",
|
|
53
|
+
"@deepseek-ai/cosmokit": "1.8.5",
|
|
54
|
+
"@deepseek-ai/dsh-brand": "0.1.7-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-agent": "0.1.7-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
|
|
60
|
+
"@deepseek-ai/dsh-session": "0.1.7-rc.2",
|
|
61
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
|
|
62
|
+
"@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
|
|
53
63
|
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/dsh-
|
|
57
|
-
"@deepseek-ai/dsh-
|
|
58
|
-
"@deepseek-ai/dsh-
|
|
59
|
-
"@deepseek-ai/dsh-
|
|
60
|
-
"@deepseek-ai/
|
|
64
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-llm": "0.1.7-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-scope": "0.1.7-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-settings": "0.1.7-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-tools": "0.1.7-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
|
|
72
|
+
"@deepseek-ai/schemastery": "3.18.4",
|
|
61
73
|
"@types/node": "^22.0.0",
|
|
62
74
|
"@types/react": "~18.3.1",
|
|
63
75
|
"react": "^18.2.0",
|
package/skills/lan-gateway.md
CHANGED
|
@@ -41,12 +41,26 @@ Do not edit state files by hand — use the `lan_gateway` tool.
|
|
|
41
41
|
- `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
|
|
42
42
|
(min 8 chars) — set the login password. Setting it revokes every existing session
|
|
43
43
|
(all sources, LAN included, must sign in again). Pass an empty password to clear —
|
|
44
|
-
clearing stops the listener (a password is required to run).
|
|
44
|
+
clearing stops the listener (a password is required to run). The user can also
|
|
45
|
+
change it themselves from the Plugins card; see below.
|
|
45
46
|
- `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
|
|
46
47
|
cookie and every live WebSocket (users must sign in again).
|
|
47
48
|
- `lan_gateway` with `command: "tls-regenerate"` — mint a fresh self-signed
|
|
48
49
|
certificate and hot-restart the listener (tlsMode must be `self-signed`).
|
|
49
50
|
|
|
51
|
+
## The user can change it without the agent
|
|
52
|
+
|
|
53
|
+
The **Plugins** page's LAN gateway card, on the machine running dsh, carries a
|
|
54
|
+
login-password form. It reports Set / Not set (never the stored password), takes
|
|
55
|
+
the new password twice, and overwrites the old one directly — no old password
|
|
56
|
+
required. Point the user there when they would rather not hand the password to
|
|
57
|
+
the model; use the tool when they ask you to do it.
|
|
58
|
+
|
|
59
|
+
The card is loopback-only, so a browser reaching dsh through the gateway gets a
|
|
60
|
+
403 for it, and it can only *set* a password: clearing stops the listener, which
|
|
61
|
+
stays a `lan_gateway set-password` command. A change made there behaves exactly
|
|
62
|
+
like the tool's — the session epoch advances and every session is revoked.
|
|
63
|
+
|
|
50
64
|
## After enabling
|
|
51
65
|
|
|
52
66
|
Tell the user the gateway URL they can share:
|
package/src/client/index.ts
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
|
|
8
8
|
* module scope. With TLS enabled the origin is secure and the shim is a
|
|
9
9
|
* no-op.
|
|
10
|
-
* 2. Settings card: registers the LAN gateway card into the official
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* adjustable from the GUI.
|
|
10
|
+
* 2. Settings card: registers the LAN gateway card into the official Plugins
|
|
11
|
+
* page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
|
|
12
|
+
* from the GUI.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
/** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
|
|
@@ -62,12 +61,25 @@ export function installRandomUuidShim(): boolean {
|
|
|
62
61
|
installRandomUuidShim()
|
|
63
62
|
|
|
64
63
|
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
65
|
-
|
|
64
|
+
// Type-only, and never bundled: the Plugins page's slot contract (`plugins.item`)
|
|
65
|
+
// and the settings domain's `configForms` service both resolve from the web
|
|
66
|
+
// shell's frozen module table. Importing them is what subjects the registration
|
|
67
|
+
// below to the platform's own contract instead of a local copy that drifts the
|
|
68
|
+
// next time upstream renames a slot.
|
|
69
|
+
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
70
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
71
|
+
import { LanGatewayCard, cardTitle } from './lan-gateway-card.tsx'
|
|
66
72
|
|
|
67
73
|
export const name = 'dsh-lan-gateway'
|
|
68
74
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The profile entry id this plugin's bundle patch composes it under, and the
|
|
77
|
+
* settings namespace dsh ≥ 0.1.7 addresses every write by.
|
|
78
|
+
*/
|
|
79
|
+
const ENTRY_ID = 'dsh-lan-gateway'
|
|
80
|
+
|
|
81
|
+
/** The slots service the card rides, and the settings mirror the gate reads. */
|
|
82
|
+
export const inject = ['slots', 'configForms']
|
|
71
83
|
|
|
72
84
|
/**
|
|
73
85
|
* Mount the settings card and the UUID shim.
|
|
@@ -76,28 +88,26 @@ export const inject = ['slots']
|
|
|
76
88
|
export function apply(ctx: ClientContext): void {
|
|
77
89
|
installRandomUuidShim()
|
|
78
90
|
|
|
79
|
-
// The card rides the official Plugins
|
|
80
|
-
//
|
|
81
|
-
//
|
|
91
|
+
// The card rides the official Plugins page. Like ModLens it registers with no
|
|
92
|
+
// inject face and fetches its own loopback config route, so it depends on no
|
|
93
|
+
// settings, locale, or connection service.
|
|
82
94
|
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
95
|
+
// dsh 0.1.7 replaced the namespace-keyed `settings.plugin.item` slot with the
|
|
96
|
+
// list slot `plugins.item`, which is where a host-plane plugin's own
|
|
97
|
+
// configuration page belongs ("one companion package per host-plane
|
|
98
|
+
// namespace"); the page renders the contribution as the card's one-liner and,
|
|
99
|
+
// once opened, as the body of the plugin's own page. Registering into the
|
|
100
|
+
// retired slot left the card invisible on 0.1.7.
|
|
89
101
|
//
|
|
90
|
-
// `
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}, LanGatewayCard)
|
|
102
|
-
})
|
|
102
|
+
// `whileServed` keeps the entry off the page until the Host's settings mirror
|
|
103
|
+
// serves this plugin's entry. That is the one gate worth having: without a
|
|
104
|
+
// Loader entry there is nothing to write to, and the card would appear only
|
|
105
|
+
// to fail every save with the route's 409.
|
|
106
|
+
ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () =>
|
|
107
|
+
ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
|
108
|
+
name: 'plugins.item',
|
|
109
|
+
id: ENTRY_ID,
|
|
110
|
+
order: 30,
|
|
111
|
+
label: () => cardTitle(),
|
|
112
|
+
}, LanGatewayCard))))
|
|
103
113
|
}
|