@riceawa/dsh-lan-gateway 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -21
- package/lib/client.js +190 -146
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +129 -8
- package/lib/index.js +1424 -968
- package/package.json +26 -14
- package/src/auth.ts +7 -14
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +93 -145
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +401 -157
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +16 -5
- package/src/tls.ts +23 -0
- package/src/upstream-session.ts +24 -8
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt,
|
|
1
|
+
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import https from "node:https";
|
|
@@ -56,6 +56,13 @@ function normalizeAddress(raw) {
|
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Classify a source address string into one of the three trust tiers.
|
|
59
|
+
*
|
|
60
|
+
* The input is a *socket* address — `req.socket.remoteAddress`, unwrapped from
|
|
61
|
+
* its `::ffff:` mapping — which is a different domain from the URL hostname
|
|
62
|
+
* `isLoopbackHost` in `request-policy.ts` judges. The two agree on the common
|
|
63
|
+
* inputs but are not interchangeable: this one never sees `[::1]`, and that one
|
|
64
|
+
* never sees a mapped form. Both spans are documented where each lives.
|
|
65
|
+
*
|
|
59
66
|
* @param remoteAddress - the raw value of `req.socket.remoteAddress`.
|
|
60
67
|
* @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
|
|
61
68
|
* @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
|
|
@@ -216,6 +223,85 @@ var RateLimiter = class RateLimiter {
|
|
|
216
223
|
}
|
|
217
224
|
};
|
|
218
225
|
//#endregion
|
|
226
|
+
//#region src/config-fields.ts
|
|
227
|
+
/**
|
|
228
|
+
* The editable settings, in display order. Adding a config key means adding it
|
|
229
|
+
* here (the host whitelist and the card's controls both follow), to the
|
|
230
|
+
* `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
|
|
231
|
+
* behavior.
|
|
232
|
+
*/
|
|
233
|
+
const FIELDS = [
|
|
234
|
+
{
|
|
235
|
+
field: "enabled",
|
|
236
|
+
kind: "boolean"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
field: "gatewayPort",
|
|
240
|
+
kind: "number"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
field: "dshTargetPort",
|
|
244
|
+
kind: "number",
|
|
245
|
+
optional: true
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
field: "lanCidrs",
|
|
249
|
+
kind: "cidrs"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
field: "lanPasswordless",
|
|
253
|
+
kind: "boolean"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
field: "cookieMaxAgeDays",
|
|
257
|
+
kind: "number"
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
field: "tlsEnabled",
|
|
261
|
+
kind: "boolean"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
field: "tlsMode",
|
|
265
|
+
kind: "select",
|
|
266
|
+
options: ["self-signed", "custom"]
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
field: "tlsSelfSignedHosts",
|
|
270
|
+
kind: "text"
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
field: "tlsCertPath",
|
|
274
|
+
kind: "text",
|
|
275
|
+
optional: true
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
field: "tlsKeyPath",
|
|
279
|
+
kind: "text",
|
|
280
|
+
optional: true
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
field: "tlsCertMaxAgeDays",
|
|
284
|
+
kind: "number"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
field: "allowInsecurePlaintext",
|
|
288
|
+
kind: "boolean"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
field: "trustedTerminator",
|
|
292
|
+
kind: "text",
|
|
293
|
+
optional: true
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
field: "secureCookies",
|
|
297
|
+
kind: "tristate"
|
|
298
|
+
}
|
|
299
|
+
];
|
|
300
|
+
/** Every settings key the config route accepts; anything else is ignored. */
|
|
301
|
+
const CONFIG_FIELD_KEYS = new Set(FIELDS.map((def) => def.field));
|
|
302
|
+
/** Keys an empty submitted value clears back to the composition layer. */
|
|
303
|
+
const OPTIONAL_CONFIG_KEYS = new Set(FIELDS.filter((def) => def.optional === true).map((def) => def.field));
|
|
304
|
+
//#endregion
|
|
219
305
|
//#region src/login.ts
|
|
220
306
|
/** Path the gateway owns and never forwards. */
|
|
221
307
|
const LOGIN_PATH = "/__login";
|
|
@@ -306,246 +392,257 @@ function readBody(req, maxBytes, res) {
|
|
|
306
392
|
});
|
|
307
393
|
}
|
|
308
394
|
//#endregion
|
|
309
|
-
//#region src/
|
|
395
|
+
//#region src/upstream-session.ts
|
|
310
396
|
/**
|
|
311
|
-
*
|
|
312
|
-
* the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
|
|
313
|
-
* NOT in the schemastery Config — secrets must never surface in
|
|
314
|
-
* `--dump-config` output. Writes are atomic (temp file + rename).
|
|
397
|
+
* Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
|
|
315
398
|
*
|
|
316
|
-
*
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*/
|
|
338
|
-
async function verifyPassword(state, password) {
|
|
339
|
-
if (state.password === void 0) return false;
|
|
340
|
-
const { hash, salt } = state.password;
|
|
341
|
-
try {
|
|
342
|
-
const expected = Buffer.from(hash, "hex");
|
|
343
|
-
const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
|
|
344
|
-
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
345
|
-
} catch {
|
|
346
|
-
return false;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
351
|
-
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
352
|
-
* password change must invalidate sessions the old password authorized.
|
|
399
|
+
* When dsh added browser-session authentication it stopped trusting a loopback
|
|
400
|
+
* Host header alone: every `/api` request (and the remote WebSocket mux) must
|
|
401
|
+
* now present a signed cookie bound to the authority it names
|
|
402
|
+
* (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
|
|
403
|
+
* process launch token. A reverse proxy that rewrites Host to loopback — which
|
|
404
|
+
* is what this gateway does — therefore gets a 401 no matter how the Host is
|
|
405
|
+
* forged. The gateway cannot mint that cookie itself (the signing secret lives
|
|
406
|
+
* in dsh's credential provider), so it does exactly what a browser does: on the
|
|
407
|
+
* loopback transport it visits the launch-token URL, keeps the Set-Cookie it
|
|
408
|
+
* earns, and replays that one shared session on every request it forwards.
|
|
409
|
+
*
|
|
410
|
+
* Semantics match the pre-existing "single password = single operator" model:
|
|
411
|
+
* whoever passes the gateway's own login rides this one upstream session. It is
|
|
412
|
+
* not multi-user authorization, and upstream (which holds the secret) remains
|
|
413
|
+
* the actual authority over what the session may do.
|
|
414
|
+
*
|
|
415
|
+
* The relay is a no-op on a base without browser sessions: acquisition fails
|
|
416
|
+
* and `cookie()` returns undefined, so the gateway simply forwards without a
|
|
417
|
+
* session cookie exactly as it did against an older dsh.
|
|
418
|
+
*
|
|
419
|
+
* @module @riceawa/dsh-lan-gateway/upstream-session
|
|
353
420
|
*/
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const salt = randomBytes(16);
|
|
361
|
-
const hash = scryptSync(password, salt, 64);
|
|
362
|
-
return {
|
|
363
|
-
...base,
|
|
364
|
-
password: {
|
|
365
|
-
hash: hash.toString("hex"),
|
|
366
|
-
salt: salt.toString("hex")
|
|
367
|
-
}
|
|
368
|
-
};
|
|
421
|
+
/** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
|
|
422
|
+
const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
|
|
423
|
+
/** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
|
|
424
|
+
function nameValueOnly(setCookie) {
|
|
425
|
+
const semi = setCookie.indexOf(";");
|
|
426
|
+
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
369
427
|
}
|
|
370
428
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* expired entries here is what keeps the list bounded.
|
|
429
|
+
* The pathname of a URL, for logging. Never the whole URL: the authenticated
|
|
430
|
+
* URL carries the launch token as a query parameter, and that token is a
|
|
431
|
+
* bearer credential for the upstream harness.
|
|
375
432
|
*/
|
|
376
|
-
function
|
|
377
|
-
const now = Date.now();
|
|
378
|
-
const revoked = {};
|
|
379
|
-
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
|
|
380
|
-
revoked[sid] = expiresMs;
|
|
381
|
-
return {
|
|
382
|
-
...state,
|
|
383
|
-
revokedSessions: revoked
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
/** Whether `sid` names a session that has been signed out. */
|
|
387
|
-
function isSessionRevoked(state, sid) {
|
|
388
|
-
if (sid === void 0) return false;
|
|
389
|
-
return Object.hasOwn(state.revokedSessions ?? {}, sid);
|
|
390
|
-
}
|
|
391
|
-
function defaultState() {
|
|
392
|
-
return {
|
|
393
|
-
cookieSecret: randomBytes(32).toString("base64"),
|
|
394
|
-
sessionEpoch: 0
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
/** Keep the still-live entries of a persisted revocation list, or undefined. */
|
|
398
|
-
function parseRevokedSessions(raw) {
|
|
399
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
400
|
-
const now = Date.now();
|
|
401
|
-
const out = {};
|
|
402
|
-
let anyLive = false;
|
|
403
|
-
for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
|
|
404
|
-
out[sid] = exp;
|
|
405
|
-
anyLive = true;
|
|
406
|
-
}
|
|
407
|
-
return anyLive ? out : void 0;
|
|
408
|
-
}
|
|
409
|
-
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
410
|
-
function loadState(home = homedir()) {
|
|
411
|
-
const dir = stateDir(home);
|
|
433
|
+
function pathOf$1(url) {
|
|
412
434
|
try {
|
|
413
|
-
|
|
414
|
-
const parsed = JSON.parse(raw);
|
|
415
|
-
if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
|
|
416
|
-
const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
|
|
417
|
-
const base = {
|
|
418
|
-
cookieSecret: parsed.cookieSecret,
|
|
419
|
-
sessionEpoch
|
|
420
|
-
};
|
|
421
|
-
if (parsed.password !== void 0) base.password = parsed.password;
|
|
422
|
-
const revoked = parseRevokedSessions(parsed.revokedSessions);
|
|
423
|
-
if (revoked !== void 0) base.revokedSessions = revoked;
|
|
424
|
-
return base;
|
|
425
|
-
}
|
|
426
|
-
return defaultState();
|
|
435
|
+
return new URL(url).pathname;
|
|
427
436
|
} catch {
|
|
428
|
-
return
|
|
437
|
+
return "<unparseable>";
|
|
429
438
|
}
|
|
430
439
|
}
|
|
431
|
-
/**
|
|
432
|
-
function
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
const target = join(dir, STATE_FILENAME);
|
|
436
|
-
const tmp = join(dir, `.state.${process.pid}.tmp`);
|
|
437
|
-
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
|
|
438
|
-
renameSync(tmp, target);
|
|
439
|
-
try {
|
|
440
|
-
chmodSync(target, 384);
|
|
441
|
-
} catch {}
|
|
440
|
+
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
441
|
+
function cookieNameOf(setCookie) {
|
|
442
|
+
const eq = setCookie.indexOf("=");
|
|
443
|
+
return eq === -1 ? "" : setCookie.slice(0, eq).trim();
|
|
442
444
|
}
|
|
443
|
-
//#endregion
|
|
444
|
-
//#region src/gateway.ts
|
|
445
|
-
/**
|
|
446
|
-
* The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
|
|
447
|
-
* address (dual-stack, so IPv6 clients reach it too) that forwards every
|
|
448
|
-
* request to the loopback dsh web server.
|
|
449
|
-
*
|
|
450
|
-
* Security model (post-QVD / session-base):
|
|
451
|
-
* - Source is classified from `socket.remoteAddress` only (never
|
|
452
|
-
* `X-Forwarded-For`). Classification alone grants nothing: by default every
|
|
453
|
-
* source — loopback, LAN, internet — must present a valid gateway session.
|
|
454
|
-
* `lanPasswordless` (an explicit opt-in, false by default) is the one way a
|
|
455
|
-
* LAN/loopback source skips the gateway login, and it is only ever allowed
|
|
456
|
-
* against a session-capable dsh base (enforced by the plugin, which owns the
|
|
457
|
-
* fail-closed guard).
|
|
458
|
-
* - The gateway never forwards its own management surface (`/lan-gateway/*`)
|
|
459
|
-
* or its login/logout paths; those are handled locally or refused.
|
|
460
|
-
* - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
|
|
461
|
-
* blinded — so the gateway runs its own origin check on every relayed
|
|
462
|
-
* request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
|
|
463
|
-
* `sec-fetch-site: cross-site`, reject any Origin that does not name the
|
|
464
|
-
* gateway authority the browser actually used, and require an Origin on
|
|
465
|
-
* state-changing methods and on every WebSocket upgrade.
|
|
466
|
-
* - Against a session-capable dsh base the Host/Origin rewrite alone would
|
|
467
|
-
* still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
|
|
468
|
-
* authority-bound session cookie). The gateway therefore relays one shared
|
|
469
|
-
* upstream session acquired through the launch-token exchange and replays it
|
|
470
|
-
* on every forwarded request. See `upstream-session.ts`.
|
|
471
|
-
* - Sessions are revocable two ways. Each carries a random id, so signing out
|
|
472
|
-
* retires exactly that session and the WebSockets it opened; and each
|
|
473
|
-
* carries a revocation epoch, so a password change or secret rotation kills
|
|
474
|
-
* every session at once — cookie, socket, and all.
|
|
475
|
-
*
|
|
476
|
-
* @module @riceawa/dsh-lan-gateway/gateway
|
|
477
|
-
*/
|
|
478
|
-
const DEFAULT_BODY_LIMIT_BYTES = 65536;
|
|
479
|
-
const LOGIN_ATTEMPTS_LIMIT = 5;
|
|
480
|
-
const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
|
|
481
|
-
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
482
|
-
const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
|
|
483
|
-
"GET",
|
|
484
|
-
"HEAD",
|
|
485
|
-
"OPTIONS"
|
|
486
|
-
]);
|
|
487
|
-
/** The upstream browser-session cookie name prefix; the relay owns this namespace. */
|
|
488
|
-
const UPSTREAM_COOKIE_PREFIX$1 = "dsh-auth-";
|
|
489
|
-
/**
|
|
490
|
-
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
491
|
-
* the non-standard proxy-connection.
|
|
492
|
-
*/
|
|
493
|
-
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
494
|
-
"connection",
|
|
495
|
-
"keep-alive",
|
|
496
|
-
"proxy-authenticate",
|
|
497
|
-
"proxy-authorization",
|
|
498
|
-
"proxy-connection",
|
|
499
|
-
"te",
|
|
500
|
-
"trailer",
|
|
501
|
-
"transfer-encoding",
|
|
502
|
-
"upgrade"
|
|
503
|
-
]);
|
|
504
445
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
446
|
+
* Whether one `name=value` fragment of a request `Cookie` header names the
|
|
447
|
+
* upstream session namespace, and so must be dropped before the relay's own
|
|
448
|
+
* copy is appended. This is the *filter* rule: it matches the whole reserved
|
|
449
|
+
* namespace, name only, whether or not the pair is a well-formed session.
|
|
508
450
|
*/
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
"x-forwarded-for",
|
|
512
|
-
"x-forwarded-host",
|
|
513
|
-
"x-forwarded-port",
|
|
514
|
-
"x-forwarded-proto",
|
|
515
|
-
"x-real-ip"
|
|
516
|
-
];
|
|
517
|
-
/** Whether a Cookie fragment names the upstream session cookie. */
|
|
518
|
-
function isUpstreamSessionPair(pair) {
|
|
519
|
-
return pair.startsWith(UPSTREAM_COOKIE_PREFIX$1);
|
|
451
|
+
function isUpstreamCookiePair(pair) {
|
|
452
|
+
return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX);
|
|
520
453
|
}
|
|
521
454
|
/**
|
|
522
|
-
*
|
|
523
|
-
* (
|
|
455
|
+
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
456
|
+
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
457
|
+
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
458
|
+
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
459
|
+
* and silently relays every request anonymously.
|
|
524
460
|
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
* 401 as "upstream revoked our session" and discards it, the next request
|
|
531
|
-
* re-acquires, and the client's stale cookie shadows that one too: a loop that
|
|
532
|
-
* never converges. Stripping the namespace makes the relay's copy the only one.
|
|
461
|
+
* This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
|
|
462
|
+
* on purpose: filtering drops a whole namespace the gateway owns, whereas
|
|
463
|
+
* accepting a session has to recognize the one cookie upstream actually mints.
|
|
464
|
+
* Both live here because this module owns the protocol fact; a consumer that
|
|
465
|
+
* re-derives it is how the two rules drifted apart before.
|
|
533
466
|
*/
|
|
534
|
-
function
|
|
535
|
-
|
|
467
|
+
function isUpstreamSessionCookie(setCookie) {
|
|
468
|
+
const name = cookieNameOf(setCookie);
|
|
469
|
+
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
|
|
536
470
|
}
|
|
537
|
-
/**
|
|
538
|
-
function
|
|
539
|
-
|
|
471
|
+
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
472
|
+
function maxAgeSeconds(setCookie) {
|
|
473
|
+
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
474
|
+
return match === null ? void 0 : Number(match[1]);
|
|
540
475
|
}
|
|
541
476
|
/**
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
|
|
548
|
-
|
|
477
|
+
* Perform the token exchange over loopback: GET the launch-token URL with the
|
|
478
|
+
* upstream authority as Host, read the Set-Cookie the index route mints, and
|
|
479
|
+
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
480
|
+
* or no session cookie came back — e.g. an older base without browser
|
|
481
|
+
* sessions).
|
|
482
|
+
*/
|
|
483
|
+
function exchange(url, authority, port, log) {
|
|
484
|
+
return new Promise((resolve) => {
|
|
485
|
+
let target;
|
|
486
|
+
try {
|
|
487
|
+
target = new URL(url);
|
|
488
|
+
} catch {
|
|
489
|
+
log("exchange: authenticatedUrl is not parseable");
|
|
490
|
+
resolve(void 0);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const request = http.request({
|
|
494
|
+
host: "127.0.0.1",
|
|
495
|
+
port,
|
|
496
|
+
method: "GET",
|
|
497
|
+
path: `${target.pathname}${target.search}`,
|
|
498
|
+
headers: {
|
|
499
|
+
host: authority,
|
|
500
|
+
accept: "text/html"
|
|
501
|
+
}
|
|
502
|
+
}, (response) => {
|
|
503
|
+
const setCookies = response.headers["set-cookie"];
|
|
504
|
+
response.resume();
|
|
505
|
+
if (setCookies === void 0) {
|
|
506
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
|
|
507
|
+
resolve(void 0);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const all = Array.isArray(setCookies) ? setCookies : [setCookies];
|
|
511
|
+
const raw = all.find(isUpstreamSessionCookie);
|
|
512
|
+
if (raw === void 0) {
|
|
513
|
+
const names = all.map(cookieNameOf).filter((name) => name !== "");
|
|
514
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
|
|
515
|
+
resolve(void 0);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const header = nameValueOnly(raw);
|
|
519
|
+
const maxAge = maxAgeSeconds(raw);
|
|
520
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
|
|
521
|
+
resolve({
|
|
522
|
+
header,
|
|
523
|
+
expiresAt: Date.now() + (maxAge ?? 0) * 1e3
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
request.on("error", (error) => {
|
|
527
|
+
log(`exchange error: ${error.message}`);
|
|
528
|
+
resolve(void 0);
|
|
529
|
+
});
|
|
530
|
+
request.setTimeout(5e3, () => {
|
|
531
|
+
log("exchange timeout (5s)");
|
|
532
|
+
request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
|
|
533
|
+
});
|
|
534
|
+
request.end();
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* A cached {@link UpstreamSession} acquired through the launch-token exchange.
|
|
539
|
+
* Acquisition runs at most once concurrently and the result is cached until it
|
|
540
|
+
* nears expiry or {@link invalidate} is called.
|
|
541
|
+
*/
|
|
542
|
+
var UpstreamSessionRelay = class {
|
|
543
|
+
port;
|
|
544
|
+
authority;
|
|
545
|
+
authenticatedUrl;
|
|
546
|
+
log;
|
|
547
|
+
held;
|
|
548
|
+
inflight;
|
|
549
|
+
constructor(options) {
|
|
550
|
+
this.port = options.port;
|
|
551
|
+
this.authority = options.authority ?? `127.0.0.1:${options.port}`;
|
|
552
|
+
this.authenticatedUrl = options.authenticatedUrl;
|
|
553
|
+
this.log = options.log ?? (() => {});
|
|
554
|
+
}
|
|
555
|
+
/** Whether the held session is still comfortably inside its lifetime. */
|
|
556
|
+
fresh() {
|
|
557
|
+
const held = this.held;
|
|
558
|
+
if (held === void 0) return false;
|
|
559
|
+
return Date.now() < held.expiresAt - 6e4;
|
|
560
|
+
}
|
|
561
|
+
invalidate() {
|
|
562
|
+
if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
|
|
563
|
+
this.held = void 0;
|
|
564
|
+
}
|
|
565
|
+
async cookie() {
|
|
566
|
+
if (this.fresh()) return this.held?.header;
|
|
567
|
+
return this.acquire();
|
|
568
|
+
}
|
|
569
|
+
acquire() {
|
|
570
|
+
if (this.inflight !== void 0) return this.inflight;
|
|
571
|
+
const pending = this.doExchange().finally(() => {
|
|
572
|
+
this.inflight = void 0;
|
|
573
|
+
});
|
|
574
|
+
this.inflight = pending;
|
|
575
|
+
return pending;
|
|
576
|
+
}
|
|
577
|
+
async doExchange() {
|
|
578
|
+
const url = this.authenticatedUrl();
|
|
579
|
+
if (url === void 0) {
|
|
580
|
+
this.log("authenticatedUrl() returned undefined; keeping current session");
|
|
581
|
+
return this.held?.header;
|
|
582
|
+
}
|
|
583
|
+
this.log(`acquiring session from ${pathOf$1(url)}`);
|
|
584
|
+
const result = await exchange(url, this.authority, this.port, this.log);
|
|
585
|
+
if (result !== void 0) {
|
|
586
|
+
this.held = result;
|
|
587
|
+
this.log("session acquired and cached");
|
|
588
|
+
} else this.log("exchange failed; keeping current session");
|
|
589
|
+
return this.held?.header;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/request-policy.ts
|
|
594
|
+
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
595
|
+
const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
596
|
+
"GET",
|
|
597
|
+
"HEAD",
|
|
598
|
+
"OPTIONS"
|
|
599
|
+
]);
|
|
600
|
+
/**
|
|
601
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
602
|
+
* the non-standard proxy-connection.
|
|
603
|
+
*/
|
|
604
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
605
|
+
"connection",
|
|
606
|
+
"keep-alive",
|
|
607
|
+
"proxy-authenticate",
|
|
608
|
+
"proxy-authorization",
|
|
609
|
+
"proxy-connection",
|
|
610
|
+
"te",
|
|
611
|
+
"trailer",
|
|
612
|
+
"transfer-encoding",
|
|
613
|
+
"upgrade"
|
|
614
|
+
]);
|
|
615
|
+
/**
|
|
616
|
+
* Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
|
|
617
|
+
* exchange that negotiates Connection/Upgrade, so they survive there and
|
|
618
|
+
* nowhere else.
|
|
619
|
+
*/
|
|
620
|
+
const UPGRADE_HANDSHAKE_HEADERS = /* @__PURE__ */ new Set(["connection", "upgrade"]);
|
|
621
|
+
/**
|
|
622
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
623
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
624
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
625
|
+
*/
|
|
626
|
+
const FORWARDING_HEADERS = [
|
|
627
|
+
"forwarded",
|
|
628
|
+
"x-forwarded-for",
|
|
629
|
+
"x-forwarded-host",
|
|
630
|
+
"x-forwarded-port",
|
|
631
|
+
"x-forwarded-proto",
|
|
632
|
+
"x-real-ip"
|
|
633
|
+
];
|
|
634
|
+
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
635
|
+
function isOwnedPath(pathname) {
|
|
636
|
+
return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
640
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
641
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
642
|
+
*
|
|
643
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
644
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
645
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
549
646
|
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
550
647
|
* config route once Host has been rewritten to loopback. Forwarding still
|
|
551
648
|
* relays the raw target: dsh applies the same normalization itself.
|
|
@@ -557,131 +654,507 @@ function isOwnedPath(pathname) {
|
|
|
557
654
|
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
558
655
|
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
559
656
|
*/
|
|
560
|
-
function pathOf
|
|
657
|
+
function pathOf(url) {
|
|
561
658
|
try {
|
|
562
659
|
return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
|
|
563
660
|
} catch {
|
|
564
661
|
return url;
|
|
565
662
|
}
|
|
566
663
|
}
|
|
567
|
-
/** A fresh per-session id: 128 random bits, URL-safe. */
|
|
568
|
-
function newSessionId() {
|
|
569
|
-
return randomBytes(16).toString("base64url");
|
|
570
|
-
}
|
|
571
664
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
665
|
+
* Whether `hostname` is loopback (127/8, localhost, ::1).
|
|
666
|
+
*
|
|
667
|
+
* This validates a URL *hostname* — the loopback fence on the gateway's own
|
|
668
|
+
* config route, where the input is the browser's Host header — so it accepts
|
|
669
|
+
* the spellings a URL parser produces, `[::1]` included. `classifySource` in
|
|
670
|
+
* `auth.ts` answers a different question about a different input (a socket
|
|
671
|
+
* address, unwrapped from its `::ffff:` mapping, and including LAN space); the
|
|
672
|
+
* two are related but not interchangeable, and neither should be rewritten in
|
|
673
|
+
* terms of the other without moving its input domain too.
|
|
575
674
|
*/
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
this.state = state;
|
|
593
|
-
const handle = (req, res) => {
|
|
594
|
-
this.handleHttp(req, res);
|
|
595
|
-
};
|
|
596
|
-
this.server = this.config.tls !== void 0 ? https.createServer({
|
|
597
|
-
cert: this.config.tls.cert,
|
|
598
|
-
key: this.config.tls.key
|
|
599
|
-
}, handle) : http.createServer(handle);
|
|
600
|
-
this.server.on("upgrade", (req, socket, head) => {
|
|
601
|
-
this.handleUpgrade(req, socket, head);
|
|
602
|
-
});
|
|
675
|
+
function isLoopbackHost(hostname) {
|
|
676
|
+
if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
|
|
677
|
+
const parts = hostname.split(".");
|
|
678
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
679
|
+
}
|
|
680
|
+
/** Whether this source must present a gateway session (default: everyone). */
|
|
681
|
+
function requiresLogin(source, lanPasswordless) {
|
|
682
|
+
return !(lanPasswordless && source !== "internet");
|
|
683
|
+
}
|
|
684
|
+
/** Parse the session cookie out of a Cookie header. */
|
|
685
|
+
function sessionCookie(headers, cookieName) {
|
|
686
|
+
const header = headers.cookie;
|
|
687
|
+
if (typeof header !== "string") return void 0;
|
|
688
|
+
for (const part of header.split(";")) {
|
|
689
|
+
const trimmed = part.trim();
|
|
690
|
+
if (trimmed.startsWith(`${cookieName}=`)) return trimmed.slice(cookieName.length + 1);
|
|
603
691
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The cross-site test shared by every gateway-owned entry point, applied before
|
|
695
|
+
* any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
|
|
696
|
+
* does not name the authority the browser actually used.
|
|
697
|
+
*
|
|
698
|
+
* Only claims a cross-site page cannot suppress are read, which is what makes
|
|
699
|
+
* this usable on the login POST too (see {@link loginOriginAllowed}).
|
|
700
|
+
*/
|
|
701
|
+
function isCrossSiteRequest(headers) {
|
|
702
|
+
if (headers["sec-fetch-site"] === "cross-site") return true;
|
|
703
|
+
const origin = headers.origin;
|
|
704
|
+
if (origin !== void 0 && !originMatchesHost(origin, headers.host)) return true;
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
|
|
709
|
+
* applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
710
|
+
* state-changing requests and to every WebSocket handshake; reads without an
|
|
711
|
+
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
712
|
+
*/
|
|
713
|
+
function sameSiteAllowed(req, upgrade) {
|
|
714
|
+
if (isCrossSiteRequest(req.headers)) return false;
|
|
715
|
+
const origin = req.headers.origin;
|
|
716
|
+
if (upgrade) return origin !== void 0;
|
|
717
|
+
if (!READ_ONLY_METHODS.has(req.method ?? "GET")) return origin !== void 0;
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The fence on the login POST. Issuing a session is as much a state change as
|
|
722
|
+
* retiring one — and a cross-site form post burns the victim's source address
|
|
723
|
+
* through the login rate limiter — so the login route runs the same cross-site
|
|
724
|
+
* test as everything else.
|
|
725
|
+
*
|
|
726
|
+
* It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
|
|
727
|
+
* request must carry an Origin" rule: a browser always sends an Origin on a
|
|
728
|
+
* form POST, but curl, the dsh CLI and other non-browser clients legitimately
|
|
729
|
+
* do not, and requiring one would lock them out of signing in. What remains is
|
|
730
|
+
* what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
|
|
731
|
+
* that disagrees with the Host the request names.
|
|
732
|
+
*/
|
|
733
|
+
function loginOriginAllowed(headers) {
|
|
734
|
+
return !isCrossSiteRequest(headers);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
738
|
+
* (possibly '').
|
|
739
|
+
*
|
|
740
|
+
* The relay's session is appended to the client's own cookie, and upstream
|
|
741
|
+
* reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
|
|
742
|
+
* typically one minted before dsh's signing secret was reset, so still present
|
|
743
|
+
* but no longer verifying — would therefore shadow the relay's session on every
|
|
744
|
+
* request. That draws a 401, the gateway reads the 401 as "upstream revoked our
|
|
745
|
+
* session" and discards it, the next request re-acquires, and the client's
|
|
746
|
+
* stale cookie shadows that one too: a loop that never converges. Stripping the
|
|
747
|
+
* namespace makes the relay's copy the only one.
|
|
748
|
+
*
|
|
749
|
+
* This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
|
|
750
|
+
* be *accepted* from upstream. The two are deliberately different rules.
|
|
751
|
+
*/
|
|
752
|
+
function withoutUpstreamSessionPairs(cookie) {
|
|
753
|
+
return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamCookiePair(pair)).join("; ");
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Build the outbound headers for one relayed request: rewrite Host/Origin to
|
|
757
|
+
* the loopback upstream, drop hop-by-hop and caller-supplied forwarding
|
|
758
|
+
* headers, clear the upstream cookie namespace the relay owns, and attach the
|
|
759
|
+
* relayed session.
|
|
760
|
+
*/
|
|
761
|
+
function upstreamRequestHeaders(headers, options) {
|
|
762
|
+
const out = { ...headers };
|
|
763
|
+
out.host = `127.0.0.1:${options.dshPort}`;
|
|
764
|
+
if (typeof out.origin === "string") out.origin = `http://127.0.0.1:${options.dshPort}`;
|
|
765
|
+
delete out["proxy-connection"];
|
|
766
|
+
if (!options.keepUpgrade) {
|
|
767
|
+
delete out.connection;
|
|
768
|
+
delete out.upgrade;
|
|
608
769
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
* not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
|
|
615
|
-
* unable to reach a gateway that classifies them.
|
|
616
|
-
*/
|
|
617
|
-
async listen() {
|
|
618
|
-
return new Promise((resolve, reject) => {
|
|
619
|
-
const onError = (err) => {
|
|
620
|
-
this.server.off("listening", onListening);
|
|
621
|
-
reject(err);
|
|
622
|
-
};
|
|
623
|
-
const onListening = () => {
|
|
624
|
-
this.server.off("error", onError);
|
|
625
|
-
resolve();
|
|
626
|
-
};
|
|
627
|
-
this.server.once("error", onError);
|
|
628
|
-
this.server.once("listening", onListening);
|
|
629
|
-
this.server.listen(this.config.gatewayPort);
|
|
630
|
-
});
|
|
770
|
+
for (const name of FORWARDING_HEADERS) delete out[name];
|
|
771
|
+
if (typeof out.cookie === "string") {
|
|
772
|
+
const kept = withoutUpstreamSessionPairs(out.cookie);
|
|
773
|
+
if (kept === "") delete out.cookie;
|
|
774
|
+
else out.cookie = kept;
|
|
631
775
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
|
|
776
|
+
const relayed = options.upstreamCookie;
|
|
777
|
+
if (relayed !== void 0 && relayed !== "") {
|
|
778
|
+
const existing = out.cookie;
|
|
779
|
+
out.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${relayed}` : relayed;
|
|
637
780
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Filter one direction's worth of headers through the same cookie rule, so the
|
|
785
|
+
* HTTP and WebSocket branches cannot drift apart on it.
|
|
786
|
+
*/
|
|
787
|
+
function stripUpstreamCookies(entry) {
|
|
788
|
+
return !isUpstreamSessionCookie(entry.trim());
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and the
|
|
792
|
+
* upstream session cookie withheld. Upstream's one cookie-minting route is the
|
|
793
|
+
* launch-token exchange at `/`, so a client that already holds a gateway session
|
|
794
|
+
* could otherwise post the token through the gateway and walk away with a
|
|
795
|
+
* durable upstream credential the relay exists to keep on this side. Cookies
|
|
796
|
+
* from other routes (plugins) still pass through.
|
|
797
|
+
*/
|
|
798
|
+
function downstreamResponseHeaders(upstream) {
|
|
799
|
+
const headers = {};
|
|
800
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
801
|
+
if (value === void 0) continue;
|
|
802
|
+
const lower = key.toLowerCase();
|
|
803
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
804
|
+
if (lower === "set-cookie") {
|
|
805
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
|
|
806
|
+
if (list.length > 0) headers[key] = list;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
headers[key] = value;
|
|
810
|
+
}
|
|
811
|
+
return headers;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* The headers of a 101 Switching Protocols response, replayed to the client on
|
|
815
|
+
* the socket the gateway just spliced.
|
|
816
|
+
*
|
|
817
|
+
* {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
|
|
818
|
+
* upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
|
|
819
|
+
* response. The cookie rule is not relaxed with them — the relay's session is
|
|
820
|
+
* withheld on this path too, so upstream cannot hand a client a durable
|
|
821
|
+
* credential by attaching it to the handshake.
|
|
822
|
+
*/
|
|
823
|
+
function upgradeResponseHeaders(upstream) {
|
|
824
|
+
const headers = {};
|
|
825
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
826
|
+
if (value === void 0) continue;
|
|
827
|
+
const lower = key.toLowerCase();
|
|
828
|
+
if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue;
|
|
829
|
+
if (lower === "set-cookie") {
|
|
830
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
|
|
831
|
+
if (list.length > 0) headers[key] = list;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
headers[key] = value;
|
|
835
|
+
}
|
|
836
|
+
return headers;
|
|
837
|
+
}
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/state.ts
|
|
840
|
+
/**
|
|
841
|
+
* Persistent runtime state for the LAN gateway: the cookie-signing secret and
|
|
842
|
+
* the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
|
|
843
|
+
* NOT in the schemastery Config — secrets must never surface in
|
|
844
|
+
* `--dump-config` output. Writes are atomic (temp file + rename).
|
|
845
|
+
*
|
|
846
|
+
* @module @riceawa/dsh-lan-gateway/state
|
|
847
|
+
*/
|
|
848
|
+
/** The state directory: `~/.dsh/lan-gateway`. */
|
|
849
|
+
function stateDir(home = homedir()) {
|
|
850
|
+
return join(home, ".dsh", "lan-gateway");
|
|
851
|
+
}
|
|
852
|
+
const STATE_FILENAME = "state.json";
|
|
853
|
+
/** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
|
|
854
|
+
function deriveKey(password, salt, keylen) {
|
|
855
|
+
return new Promise((resolve, reject) => {
|
|
856
|
+
scrypt(password, salt, keylen, (error, derived) => {
|
|
857
|
+
if (error !== null) reject(error);
|
|
858
|
+
else resolve(derived);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Whether a password is present and passes scrypt verification. Asynchronous
|
|
864
|
+
* on purpose: `scryptSync` occupies the event loop for tens of milliseconds
|
|
865
|
+
* per attempt, and that loop is shared with the dsh process the gateway is
|
|
866
|
+
* forwarding to.
|
|
867
|
+
*/
|
|
868
|
+
async function verifyPassword(state, password) {
|
|
869
|
+
if (state.password === void 0) return false;
|
|
870
|
+
const { hash, salt } = state.password;
|
|
871
|
+
try {
|
|
872
|
+
const expected = Buffer.from(hash, "hex");
|
|
873
|
+
const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
|
|
874
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
875
|
+
} catch {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
881
|
+
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
882
|
+
* password change must invalidate sessions the old password authorized.
|
|
883
|
+
*
|
|
884
|
+
* Deriving the key is asynchronous for the same reason
|
|
885
|
+
* {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
|
|
886
|
+
* milliseconds, and that loop is shared with the dsh process the gateway
|
|
887
|
+
* forwards to. Every caller is already async.
|
|
888
|
+
*/
|
|
889
|
+
async function setPassword(state, password) {
|
|
890
|
+
const base = {
|
|
891
|
+
cookieSecret: state.cookieSecret,
|
|
892
|
+
sessionEpoch: state.sessionEpoch + 1
|
|
893
|
+
};
|
|
894
|
+
if (password === void 0) return base;
|
|
895
|
+
const salt = randomBytes(16);
|
|
896
|
+
const hash = await deriveKey(password, salt, 64);
|
|
897
|
+
return {
|
|
898
|
+
...base,
|
|
899
|
+
password: {
|
|
900
|
+
hash: hash.toString("hex"),
|
|
901
|
+
salt: salt.toString("hex")
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Record a session id as revoked.
|
|
907
|
+
* @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
|
|
908
|
+
* rejected on its own account, so the entry is no longer needed; dropping
|
|
909
|
+
* expired entries here is what keeps the list bounded.
|
|
910
|
+
* @param now - epoch millis to judge the existing entries against, injected so
|
|
911
|
+
* a test can age the list without fake timers.
|
|
912
|
+
*/
|
|
913
|
+
function revokeSession(state, sid, expiresMs, now = Date.now()) {
|
|
914
|
+
const revoked = {};
|
|
915
|
+
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
|
|
916
|
+
revoked[sid] = expiresMs;
|
|
917
|
+
return {
|
|
918
|
+
...state,
|
|
919
|
+
revokedSessions: revoked
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/** Whether `sid` names a session that has been signed out. */
|
|
923
|
+
function isSessionRevoked(state, sid) {
|
|
924
|
+
if (sid === void 0) return false;
|
|
925
|
+
return Object.hasOwn(state.revokedSessions ?? {}, sid);
|
|
926
|
+
}
|
|
927
|
+
function defaultState() {
|
|
928
|
+
return {
|
|
929
|
+
cookieSecret: randomBytes(32).toString("base64"),
|
|
930
|
+
sessionEpoch: 0
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** Keep the still-live entries of a persisted revocation list, or undefined. */
|
|
934
|
+
function parseRevokedSessions(raw) {
|
|
935
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
936
|
+
const now = Date.now();
|
|
937
|
+
const out = {};
|
|
938
|
+
let anyLive = false;
|
|
939
|
+
for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
|
|
940
|
+
out[sid] = exp;
|
|
941
|
+
anyLive = true;
|
|
942
|
+
}
|
|
943
|
+
return anyLive ? out : void 0;
|
|
944
|
+
}
|
|
945
|
+
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
946
|
+
function loadState(home = homedir()) {
|
|
947
|
+
const dir = stateDir(home);
|
|
948
|
+
try {
|
|
949
|
+
const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
|
|
950
|
+
const parsed = JSON.parse(raw);
|
|
951
|
+
if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
|
|
952
|
+
const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
|
|
953
|
+
const base = {
|
|
954
|
+
cookieSecret: parsed.cookieSecret,
|
|
955
|
+
sessionEpoch
|
|
956
|
+
};
|
|
957
|
+
if (parsed.password !== void 0) base.password = parsed.password;
|
|
958
|
+
const revoked = parseRevokedSessions(parsed.revokedSessions);
|
|
959
|
+
if (revoked !== void 0) base.revokedSessions = revoked;
|
|
960
|
+
return base;
|
|
961
|
+
}
|
|
962
|
+
return defaultState();
|
|
963
|
+
} catch {
|
|
964
|
+
return defaultState();
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Persist state atomically. */
|
|
968
|
+
function saveState(state, home = homedir()) {
|
|
969
|
+
const dir = stateDir(home);
|
|
970
|
+
mkdirSync(dir, { recursive: true });
|
|
971
|
+
const target = join(dir, STATE_FILENAME);
|
|
972
|
+
const tmp = join(dir, `.state.${process.pid}.tmp`);
|
|
973
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
|
|
974
|
+
renameSync(tmp, target);
|
|
975
|
+
try {
|
|
976
|
+
chmodSync(target, 384);
|
|
977
|
+
} catch {}
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/gateway.ts
|
|
981
|
+
/**
|
|
982
|
+
* The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
|
|
983
|
+
* address (dual-stack, so IPv6 clients reach it too) that forwards every
|
|
984
|
+
* request to the loopback dsh web server.
|
|
985
|
+
*
|
|
986
|
+
* Security model (post-QVD / session-base):
|
|
987
|
+
* - Source is classified from `socket.remoteAddress` only (never
|
|
988
|
+
* `X-Forwarded-For`). Classification alone grants nothing: by default every
|
|
989
|
+
* source — loopback, LAN, internet — must present a valid gateway session.
|
|
990
|
+
* `lanPasswordless` (an explicit opt-in, false by default) is the one way a
|
|
991
|
+
* LAN/loopback source skips the gateway login, and it is only ever allowed
|
|
992
|
+
* against a session-capable dsh base (enforced by the plugin, which owns the
|
|
993
|
+
* fail-closed guard).
|
|
994
|
+
* - The gateway never forwards its own management surface (`/lan-gateway/*`)
|
|
995
|
+
* or its login/logout paths; those are handled locally or refused.
|
|
996
|
+
* - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
|
|
997
|
+
* blinded — so the gateway runs its own origin check on every relayed
|
|
998
|
+
* request (HTTP and WebSocket upgrade) BEFORE rewriting. See
|
|
999
|
+
* `request-policy.ts`, which owns that decision along with every other
|
|
1000
|
+
* header/path/server decision; this module owns the transport.
|
|
1001
|
+
* - Against a session-capable dsh base the Host/Origin rewrite alone would
|
|
1002
|
+
* still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
|
|
1003
|
+
* authority-bound session cookie). The gateway therefore relays one shared
|
|
1004
|
+
* upstream session acquired through the launch-token exchange and replays it
|
|
1005
|
+
* on every forwarded request. See `upstream-session.ts`.
|
|
1006
|
+
* - Sessions are revocable two ways. Each carries a random id, so signing out
|
|
1007
|
+
* retires exactly that session and the WebSockets it opened; and each
|
|
1008
|
+
* carries a revocation epoch, so a password change or secret rotation kills
|
|
1009
|
+
* every session at once — cookie, socket, and all.
|
|
1010
|
+
*
|
|
1011
|
+
* @module @riceawa/dsh-lan-gateway/gateway
|
|
1012
|
+
*/
|
|
1013
|
+
const DEFAULT_BODY_LIMIT_BYTES = 65536;
|
|
1014
|
+
const LOGIN_ATTEMPTS_LIMIT = 5;
|
|
1015
|
+
const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
|
|
1016
|
+
/**
|
|
1017
|
+
* How long a half-open upstream WebSocket handshake may hang before the
|
|
1018
|
+
* gateway gives up on it. Without a deadline the client socket sits in the
|
|
1019
|
+
* pending table forever and never learns the upgrade failed — node's http
|
|
1020
|
+
* client would wait out its own socket timeout, which is measured in minutes.
|
|
1021
|
+
*/
|
|
1022
|
+
const UPGRADE_HANDSHAKE_TIMEOUT_MS = 15e3;
|
|
1023
|
+
/** A fresh per-session id: 128 random bits, URL-safe. */
|
|
1024
|
+
function newSessionId() {
|
|
1025
|
+
return randomBytes(16).toString("base64url");
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The running gateway: owns the HTTP server and the auth state needed per
|
|
1029
|
+
* request. Created by the plugin on enable; torn down by the plugin on
|
|
1030
|
+
* disable or tree disposal.
|
|
1031
|
+
*/
|
|
1032
|
+
var LanGateway = class {
|
|
1033
|
+
config;
|
|
1034
|
+
server;
|
|
1035
|
+
loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
|
|
1036
|
+
state;
|
|
1037
|
+
disposed = false;
|
|
1038
|
+
/**
|
|
1039
|
+
* Every WebSocket this gateway is responsible for, keyed by the client
|
|
1040
|
+
* socket: pending handshakes as well as established ones.
|
|
1041
|
+
*
|
|
1042
|
+
* A socket outlives the request that authenticated it, so it has to be
|
|
1043
|
+
* closable by session: on an epoch bump every socket dies, and on sign-out
|
|
1044
|
+
* only that session's. A handshake that is still waiting on the relay or on
|
|
1045
|
+
* upstream's 101 is tracked from the moment it passes the gates, not from the
|
|
1046
|
+
* moment it is spliced — otherwise a revocation that lands mid-handshake
|
|
1047
|
+
* closes the map's contents and then watches the abandoned handshake finish
|
|
1048
|
+
* and register itself as live.
|
|
1049
|
+
*/
|
|
1050
|
+
sockets = /* @__PURE__ */ new Map();
|
|
1051
|
+
/**
|
|
1052
|
+
* Bumped by every revocation (epoch change, per-session sign-out) and by
|
|
1053
|
+
* disposal. A socket is retired when the generation moves past the one it was
|
|
1054
|
+
* admitted under, which is what lets a pending handshake be judged by the
|
|
1055
|
+
* rules in force when it *completes* rather than when it started.
|
|
1056
|
+
*/
|
|
1057
|
+
gate = 0;
|
|
1058
|
+
constructor(config, state) {
|
|
1059
|
+
this.config = config;
|
|
1060
|
+
this.state = state;
|
|
1061
|
+
const handle = (req, res) => {
|
|
1062
|
+
this.handleHttp(req, res);
|
|
1063
|
+
};
|
|
1064
|
+
this.server = this.config.tls !== void 0 ? https.createServer({
|
|
1065
|
+
cert: this.config.tls.cert,
|
|
1066
|
+
key: this.config.tls.key
|
|
1067
|
+
}, handle) : http.createServer(handle);
|
|
1068
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
1069
|
+
this.handleUpgrade(req, socket, head);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
/** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
|
|
1073
|
+
setState(state) {
|
|
1074
|
+
if (state.sessionEpoch !== this.state.sessionEpoch) {
|
|
1075
|
+
this.gate += 1;
|
|
1076
|
+
this.destroyAllSockets();
|
|
1077
|
+
}
|
|
1078
|
+
this.state = state;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Start listening on the configured port. The listener is dual-stack: with
|
|
1082
|
+
* no host given, node binds the unspecified IPv6 address `::` — which also
|
|
1083
|
+
* accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
|
|
1084
|
+
* unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
|
|
1085
|
+
* not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
|
|
1086
|
+
* unable to reach a gateway that classifies them.
|
|
1087
|
+
*/
|
|
1088
|
+
async listen() {
|
|
1089
|
+
return new Promise((resolve, reject) => {
|
|
1090
|
+
const onError = (err) => {
|
|
1091
|
+
this.server.off("listening", onListening);
|
|
1092
|
+
reject(err);
|
|
1093
|
+
};
|
|
1094
|
+
const onListening = () => {
|
|
1095
|
+
this.server.off("error", onError);
|
|
1096
|
+
resolve();
|
|
1097
|
+
};
|
|
1098
|
+
this.server.once("error", onError);
|
|
1099
|
+
this.server.once("listening", onListening);
|
|
1100
|
+
this.server.listen(this.config.gatewayPort);
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
/** The address actually bound, for logs and status (never a claim about it). */
|
|
1104
|
+
boundAddress() {
|
|
1105
|
+
const address = this.server.address();
|
|
1106
|
+
if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
|
|
1107
|
+
return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
|
|
1108
|
+
}
|
|
1109
|
+
/** Close the server, drop every socket, and stop accepting connections. */
|
|
1110
|
+
async close() {
|
|
1111
|
+
if (this.disposed) return;
|
|
1112
|
+
this.disposed = true;
|
|
1113
|
+
this.gate += 1;
|
|
1114
|
+
this.destroyAllSockets();
|
|
1115
|
+
return new Promise((resolve) => {
|
|
644
1116
|
this.server.close(() => resolve());
|
|
645
1117
|
this.server.closeAllConnections();
|
|
646
1118
|
});
|
|
647
1119
|
}
|
|
648
|
-
|
|
649
|
-
for (const socket of this.
|
|
650
|
-
this.
|
|
1120
|
+
destroyAllSockets() {
|
|
1121
|
+
for (const socket of this.sockets.keys()) socket.destroy();
|
|
1122
|
+
this.sockets.clear();
|
|
651
1123
|
}
|
|
652
1124
|
/** Close the sockets one session opened, so signing out ends its live streams too. */
|
|
653
|
-
|
|
654
|
-
for (const [socket,
|
|
655
|
-
if (
|
|
656
|
-
this.
|
|
1125
|
+
destroySocketsFor(sid) {
|
|
1126
|
+
for (const [socket, tracked] of this.sockets) {
|
|
1127
|
+
if (tracked.sid !== sid) continue;
|
|
1128
|
+
this.sockets.delete(socket);
|
|
657
1129
|
socket.destroy();
|
|
658
1130
|
}
|
|
659
1131
|
}
|
|
660
|
-
|
|
661
|
-
|
|
1132
|
+
/** Track a socket from admission to close. */
|
|
1133
|
+
trackSocket(socket, sid) {
|
|
1134
|
+
this.sockets.set(socket, {
|
|
1135
|
+
sid,
|
|
1136
|
+
gate: this.gate
|
|
1137
|
+
});
|
|
662
1138
|
socket.on("close", () => {
|
|
663
|
-
this.
|
|
1139
|
+
this.sockets.delete(socket);
|
|
664
1140
|
});
|
|
665
1141
|
}
|
|
1142
|
+
/** Whether a socket is still tracked, undisposed, and admitted under the current gate. */
|
|
1143
|
+
stillAdmitted(socket) {
|
|
1144
|
+
if (this.disposed) return false;
|
|
1145
|
+
const tracked = this.sockets.get(socket);
|
|
1146
|
+
return tracked !== void 0 && tracked.gate === this.gate;
|
|
1147
|
+
}
|
|
666
1148
|
sourceOf(req) {
|
|
667
1149
|
return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
|
|
668
1150
|
}
|
|
669
|
-
/** Parse the session cookie out of a Cookie header. */
|
|
670
|
-
sessionCookie(req) {
|
|
671
|
-
const header = req.headers.cookie;
|
|
672
|
-
if (typeof header !== "string") return void 0;
|
|
673
|
-
for (const part of header.split(";")) {
|
|
674
|
-
const trimmed = part.trim();
|
|
675
|
-
if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
1151
|
/**
|
|
679
1152
|
* The session a request carries, or undefined when it presents none, presents
|
|
680
1153
|
* one that no longer verifies under the current epoch, or presents one whose
|
|
681
1154
|
* id has been signed out.
|
|
682
1155
|
*/
|
|
683
1156
|
session(req) {
|
|
684
|
-
const cookie =
|
|
1157
|
+
const cookie = sessionCookie(req.headers, this.config.cookieName);
|
|
685
1158
|
if (cookie === void 0) return void 0;
|
|
686
1159
|
const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
|
|
687
1160
|
if (claims === void 0) return void 0;
|
|
@@ -691,19 +1164,20 @@ var LanGateway = class {
|
|
|
691
1164
|
authorized(req) {
|
|
692
1165
|
return this.session(req) !== void 0;
|
|
693
1166
|
}
|
|
694
|
-
/**
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1167
|
+
/**
|
|
1168
|
+
* Send an unauthorized caller to the login form. The rate limiter's refusal
|
|
1169
|
+
* does not come through here: it is answered on the POST itself, where the
|
|
1170
|
+
* banner can be rendered without a round trip.
|
|
1171
|
+
*/
|
|
1172
|
+
serveUnauthorized(res) {
|
|
699
1173
|
res.writeHead(302, {
|
|
700
|
-
location:
|
|
1174
|
+
location: LOGIN_PATH,
|
|
701
1175
|
...this.securityHeaders()
|
|
702
1176
|
});
|
|
703
1177
|
res.end();
|
|
704
1178
|
}
|
|
705
|
-
serveLoginError(res, message) {
|
|
706
|
-
const opts = { error: message };
|
|
1179
|
+
serveLoginError(res, message, limited = false) {
|
|
1180
|
+
const opts = limited ? { limited: true } : { error: message };
|
|
707
1181
|
res.writeHead(401, {
|
|
708
1182
|
"content-type": "text/html; charset=utf-8",
|
|
709
1183
|
"cache-control": "no-store",
|
|
@@ -715,30 +1189,14 @@ var LanGateway = class {
|
|
|
715
1189
|
securityHeaders() {
|
|
716
1190
|
return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
|
|
717
1191
|
}
|
|
718
|
-
/**
|
|
719
|
-
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
|
|
720
|
-
* and applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
721
|
-
* state-changing requests and to every WebSocket handshake; reads without an
|
|
722
|
-
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
723
|
-
*/
|
|
724
|
-
sameSiteAllowed(req, upgrade) {
|
|
725
|
-
const headers = req.headers;
|
|
726
|
-
if (headers["sec-fetch-site"] === "cross-site") return false;
|
|
727
|
-
const origin = headers.origin;
|
|
728
|
-
const host = headers.host;
|
|
729
|
-
if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
|
|
730
|
-
if (upgrade) return origin !== void 0;
|
|
731
|
-
if (!READ_ONLY_METHODS$1.has(req.method ?? "GET")) return origin !== void 0;
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
1192
|
sessionSetCookie(value, maxAgeSeconds) {
|
|
735
1193
|
const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
|
|
736
1194
|
return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
|
|
737
1195
|
}
|
|
738
|
-
/** Handle one HTTP request:
|
|
1196
|
+
/** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
|
|
739
1197
|
async handleHttp(req, res) {
|
|
740
1198
|
const url = req.url ?? "/";
|
|
741
|
-
const pathname = pathOf
|
|
1199
|
+
const pathname = pathOf(url);
|
|
742
1200
|
const source = this.sourceOf(req);
|
|
743
1201
|
if (pathname === "/__login") {
|
|
744
1202
|
this.handleLogin(req, res);
|
|
@@ -753,11 +1211,11 @@ var LanGateway = class {
|
|
|
753
1211
|
res.end("forbidden");
|
|
754
1212
|
return;
|
|
755
1213
|
}
|
|
756
|
-
if (
|
|
757
|
-
this.serveUnauthorized(res
|
|
1214
|
+
if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
|
|
1215
|
+
this.serveUnauthorized(res);
|
|
758
1216
|
return;
|
|
759
1217
|
}
|
|
760
|
-
if (!
|
|
1218
|
+
if (!sameSiteAllowed(req, false)) {
|
|
761
1219
|
res.writeHead(403, this.securityHeaders());
|
|
762
1220
|
res.end("forbidden");
|
|
763
1221
|
return;
|
|
@@ -766,7 +1224,6 @@ var LanGateway = class {
|
|
|
766
1224
|
}
|
|
767
1225
|
/** Handle the login GET form / POST submission. */
|
|
768
1226
|
handleLogin(req, res) {
|
|
769
|
-
req.url?.includes("limited=1");
|
|
770
1227
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
771
1228
|
serveLoginGet(res, this.securityHeaders());
|
|
772
1229
|
return;
|
|
@@ -776,9 +1233,14 @@ var LanGateway = class {
|
|
|
776
1233
|
res.end();
|
|
777
1234
|
return;
|
|
778
1235
|
}
|
|
1236
|
+
if (!loginOriginAllowed(req.headers)) {
|
|
1237
|
+
res.writeHead(403, this.securityHeaders());
|
|
1238
|
+
res.end("forbidden");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
779
1241
|
const key = req.socket.remoteAddress ?? "unknown";
|
|
780
1242
|
if (!this.loginLimiter.allow(key)) {
|
|
781
|
-
this.serveLoginError(res, "Too many attempts — please wait a minute.");
|
|
1243
|
+
this.serveLoginError(res, "Too many attempts — please wait a minute.", true);
|
|
782
1244
|
return;
|
|
783
1245
|
}
|
|
784
1246
|
readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
|
|
@@ -789,7 +1251,13 @@ var LanGateway = class {
|
|
|
789
1251
|
} catch {
|
|
790
1252
|
password = void 0;
|
|
791
1253
|
}
|
|
792
|
-
|
|
1254
|
+
const checked = this.state;
|
|
1255
|
+
const accepted = password !== void 0 && await verifyPassword(checked, password);
|
|
1256
|
+
if (this.state !== checked || this.disposed) {
|
|
1257
|
+
this.serveLoginError(res, "Sign-in was interrupted — please try again.");
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (!accepted) {
|
|
793
1261
|
this.serveLoginError(res, "Incorrect password.");
|
|
794
1262
|
return;
|
|
795
1263
|
}
|
|
@@ -825,7 +1293,7 @@ var LanGateway = class {
|
|
|
825
1293
|
res.end();
|
|
826
1294
|
return;
|
|
827
1295
|
}
|
|
828
|
-
if (!
|
|
1296
|
+
if (!sameSiteAllowed(req, false)) {
|
|
829
1297
|
res.writeHead(403, this.securityHeaders());
|
|
830
1298
|
res.end("forbidden");
|
|
831
1299
|
return;
|
|
@@ -834,7 +1302,7 @@ var LanGateway = class {
|
|
|
834
1302
|
if (claims?.sid !== void 0) {
|
|
835
1303
|
this.state = revokeSession(this.state, claims.sid, claims.exp);
|
|
836
1304
|
this.config.onStateChange?.(this.state);
|
|
837
|
-
this.
|
|
1305
|
+
this.destroySocketsFor(claims.sid);
|
|
838
1306
|
}
|
|
839
1307
|
res.writeHead(302, {
|
|
840
1308
|
location: "/",
|
|
@@ -843,63 +1311,20 @@ var LanGateway = class {
|
|
|
843
1311
|
});
|
|
844
1312
|
res.end();
|
|
845
1313
|
}
|
|
846
|
-
/**
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
headers.host = `127.0.0.1:${this.config.dshPort}`;
|
|
850
|
-
if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
|
|
851
|
-
delete headers["proxy-connection"];
|
|
852
|
-
if (!keepUpgrade) {
|
|
853
|
-
delete headers.connection;
|
|
854
|
-
delete headers.upgrade;
|
|
855
|
-
}
|
|
856
|
-
for (const name of FORWARDING_HEADERS) delete headers[name];
|
|
857
|
-
if (typeof headers.cookie === "string") {
|
|
858
|
-
const kept = withoutUpstreamSessionPairs(headers.cookie);
|
|
859
|
-
if (kept === "") delete headers.cookie;
|
|
860
|
-
else headers.cookie = kept;
|
|
861
|
-
}
|
|
862
|
-
return headers;
|
|
863
|
-
}
|
|
864
|
-
/** Attach the shared upstream session cookie to the outbound headers, if any. */
|
|
865
|
-
attachUpstreamSession(headers) {
|
|
866
|
-
const session = this.config.upstreamSession;
|
|
867
|
-
if (session === void 0) return false;
|
|
868
|
-
const cookie = session.peek();
|
|
869
|
-
if (cookie === void 0) return false;
|
|
870
|
-
const existing = headers.cookie;
|
|
871
|
-
headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
|
|
872
|
-
return true;
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* The headers to send back to the client: hop-by-hop headers dropped, and
|
|
876
|
-
* the upstream session cookie withheld. Upstream's one cookie-minting route
|
|
877
|
-
* is the launch-token exchange at `/`, so a client that already holds a
|
|
878
|
-
* gateway session could otherwise post the token through the gateway and
|
|
879
|
-
* walk away with a durable upstream credential the relay exists to keep on
|
|
880
|
-
* this side. Cookies from other routes (plugins) still pass through.
|
|
881
|
-
*/
|
|
882
|
-
downstreamHeaders(upstream) {
|
|
883
|
-
const headers = {};
|
|
884
|
-
for (const [key, value] of Object.entries(upstream)) {
|
|
885
|
-
if (value === void 0) continue;
|
|
886
|
-
const lower = key.toLowerCase();
|
|
887
|
-
if (HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
888
|
-
if (lower === "set-cookie") {
|
|
889
|
-
const list = (Array.isArray(value) ? value : [value]).filter((entry) => !isUpstreamSessionPair(entry.trim()));
|
|
890
|
-
if (list.length > 0) headers[key] = list;
|
|
891
|
-
continue;
|
|
892
|
-
}
|
|
893
|
-
headers[key] = value;
|
|
894
|
-
}
|
|
895
|
-
return headers;
|
|
1314
|
+
/** The shared upstream session's cookie value, if the relay holds one. */
|
|
1315
|
+
async upstreamCookie() {
|
|
1316
|
+
return this.config.upstreamSession === void 0 ? void 0 : this.config.upstreamSession.cookie();
|
|
896
1317
|
}
|
|
897
1318
|
/** Forward an HTTP request to dsh, replaying the shared upstream session. */
|
|
898
1319
|
async relayHttp(req, res, url) {
|
|
899
1320
|
const session = this.config.upstreamSession;
|
|
900
|
-
|
|
901
|
-
const headers =
|
|
902
|
-
|
|
1321
|
+
const relayed = await this.upstreamCookie();
|
|
1322
|
+
const headers = upstreamRequestHeaders(req.headers, {
|
|
1323
|
+
dshPort: this.config.dshPort,
|
|
1324
|
+
keepUpgrade: false,
|
|
1325
|
+
upstreamCookie: relayed
|
|
1326
|
+
});
|
|
1327
|
+
const attached = relayed !== void 0;
|
|
903
1328
|
const proxyReq = http.request({
|
|
904
1329
|
host: "127.0.0.1",
|
|
905
1330
|
port: this.config.dshPort,
|
|
@@ -908,7 +1333,7 @@ var LanGateway = class {
|
|
|
908
1333
|
headers
|
|
909
1334
|
}, (proxyRes) => {
|
|
910
1335
|
if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
|
|
911
|
-
res.writeHead(proxyRes.statusCode ?? 502,
|
|
1336
|
+
res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers));
|
|
912
1337
|
proxyRes.pipe(res);
|
|
913
1338
|
});
|
|
914
1339
|
proxyReq.on("error", () => {
|
|
@@ -920,7 +1345,7 @@ var LanGateway = class {
|
|
|
920
1345
|
/** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
|
|
921
1346
|
async handleUpgrade(req, socket, head) {
|
|
922
1347
|
const url = req.url ?? "/";
|
|
923
|
-
const pathname = pathOf
|
|
1348
|
+
const pathname = pathOf(url);
|
|
924
1349
|
const source = this.sourceOf(req);
|
|
925
1350
|
const refuse = (status) => {
|
|
926
1351
|
socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
|
|
@@ -931,18 +1356,29 @@ var LanGateway = class {
|
|
|
931
1356
|
return;
|
|
932
1357
|
}
|
|
933
1358
|
const claims = this.session(req);
|
|
934
|
-
if (
|
|
1359
|
+
if (requiresLogin(source, this.config.lanPasswordless) && claims === void 0) {
|
|
935
1360
|
refuse(401);
|
|
936
1361
|
return;
|
|
937
1362
|
}
|
|
938
|
-
if (!
|
|
1363
|
+
if (!sameSiteAllowed(req, true)) {
|
|
939
1364
|
refuse(403);
|
|
940
1365
|
return;
|
|
941
1366
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1367
|
+
this.trackSocket(socket, claims?.sid);
|
|
1368
|
+
const retire = () => {
|
|
1369
|
+
if (!this.sockets.delete(socket)) return;
|
|
1370
|
+
socket.destroy();
|
|
1371
|
+
};
|
|
1372
|
+
const relayed = await this.upstreamCookie();
|
|
1373
|
+
if (!this.stillAdmitted(socket)) {
|
|
1374
|
+
retire();
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const headers = upstreamRequestHeaders(req.headers, {
|
|
1378
|
+
dshPort: this.config.dshPort,
|
|
1379
|
+
keepUpgrade: true,
|
|
1380
|
+
upstreamCookie: relayed
|
|
1381
|
+
});
|
|
946
1382
|
const proxyReq = http.request({
|
|
947
1383
|
host: "127.0.0.1",
|
|
948
1384
|
port: this.config.dshPort,
|
|
@@ -950,10 +1386,22 @@ var LanGateway = class {
|
|
|
950
1386
|
path: url,
|
|
951
1387
|
headers
|
|
952
1388
|
});
|
|
1389
|
+
const timer = setTimeout(() => {
|
|
1390
|
+
proxyReq.destroy();
|
|
1391
|
+
retire();
|
|
1392
|
+
}, UPGRADE_HANDSHAKE_TIMEOUT_MS);
|
|
1393
|
+
const settle = () => {
|
|
1394
|
+
clearTimeout(timer);
|
|
1395
|
+
};
|
|
953
1396
|
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
954
|
-
|
|
1397
|
+
settle();
|
|
1398
|
+
if (!this.stillAdmitted(socket)) {
|
|
1399
|
+
proxySocket.destroy();
|
|
1400
|
+
retire();
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
955
1403
|
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
|
|
956
|
-
const headerLines = Object.entries(proxyRes.headers).
|
|
1404
|
+
const headerLines = Object.entries(upgradeResponseHeaders(proxyRes.headers)).flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((entry) => `${key}: ${entry}\r\n`)).join("");
|
|
957
1405
|
socket.write(`${statusLine}${headerLines}\r\n`);
|
|
958
1406
|
if (head !== void 0 && head.length > 0) proxySocket.write(head);
|
|
959
1407
|
proxySocket.pipe(socket).pipe(proxySocket);
|
|
@@ -961,7 +1409,22 @@ var LanGateway = class {
|
|
|
961
1409
|
socket.on("error", () => proxySocket.destroy());
|
|
962
1410
|
proxySocket.on("error", () => socket.destroy());
|
|
963
1411
|
});
|
|
964
|
-
proxyReq.on("
|
|
1412
|
+
proxyReq.on("response", (proxyRes) => {
|
|
1413
|
+
settle();
|
|
1414
|
+
proxyRes.resume();
|
|
1415
|
+
if (!this.stillAdmitted(socket)) {
|
|
1416
|
+
retire();
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (proxyRes.statusCode === 401 && relayed !== void 0) this.config.upstreamSession?.invalidate();
|
|
1420
|
+
const body = `upstream refused the WebSocket upgrade (HTTP ${proxyRes.statusCode ?? 502})`;
|
|
1421
|
+
socket.write(`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? "Upstream Refused"}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
1422
|
+
retire();
|
|
1423
|
+
});
|
|
1424
|
+
proxyReq.on("error", () => {
|
|
1425
|
+
settle();
|
|
1426
|
+
retire();
|
|
1427
|
+
});
|
|
965
1428
|
proxyReq.end();
|
|
966
1429
|
}
|
|
967
1430
|
};
|
|
@@ -1150,444 +1613,283 @@ function generateSelfSignedCert(options) {
|
|
|
1150
1613
|
modulusLength: 2048,
|
|
1151
1614
|
publicExponent: 65537
|
|
1152
1615
|
});
|
|
1153
|
-
const commonName = options.commonName?.trim() || hosts[0];
|
|
1154
|
-
const serial = randomBytes(16);
|
|
1155
|
-
serial[0] &= 127;
|
|
1156
|
-
const issuer = derSeq(derSet(derSeq(derOid("2.5.4.3"), derUtf8String(commonName))));
|
|
1157
|
-
const subject = issuer;
|
|
1158
|
-
const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
|
|
1159
|
-
const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
|
|
1160
|
-
const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter));
|
|
1161
|
-
const spki = publicKey.export({
|
|
1162
|
-
type: "spki",
|
|
1163
|
-
format: "der"
|
|
1164
|
-
});
|
|
1165
|
-
const extensionsWrapper = derTag(163, derSeq(derSeq(derOid("2.5.29.19"), derBoolean(true), derOctetString(derSeq())), derSeq(derOid("2.5.29.15"), derBoolean(true), derOctetString(derBitString(Buffer.from([160])))), derSeq(derOid("2.5.29.37"), derOctetString(derSeq(derOid("1.3.6.1.5.5.7.3.1")))), derSeq(derOid("2.5.29.17"), derOctetString(derSeq(...hosts.map(sanGeneralName))))));
|
|
1166
|
-
const tbs = derSeq(derTag(160, derInt(Buffer.from([2]))), derInt(serial), sha256WithRsa(), issuer, validity, subject, spki, extensionsWrapper);
|
|
1167
|
-
const signature = createSign("sha256").update(tbs).end().sign(privateKey);
|
|
1168
|
-
const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature));
|
|
1169
|
-
return {
|
|
1170
|
-
certDer,
|
|
1171
|
-
certPem: pemEncode("CERTIFICATE", certDer),
|
|
1172
|
-
keyPem: privateKey.export({
|
|
1173
|
-
type: "pkcs8",
|
|
1174
|
-
format: "pem"
|
|
1175
|
-
}).toString(),
|
|
1176
|
-
publicKey,
|
|
1177
|
-
privateKey
|
|
1178
|
-
};
|
|
1179
|
-
}
|
|
1180
|
-
//#endregion
|
|
1181
|
-
//#region src/tls.ts
|
|
1182
|
-
/**
|
|
1183
|
-
* TLS material management for the gateway: self-signed certificates are
|
|
1184
|
-
* generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
|
|
1185
|
-
* restarts reuse the same certificate instead of minting a new one every
|
|
1186
|
-
* boot; custom certificates are read straight from user-supplied PEM paths.
|
|
1187
|
-
*
|
|
1188
|
-
* @module @riceawa/dsh-lan-gateway/tls
|
|
1189
|
-
*/
|
|
1190
|
-
/** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
|
|
1191
|
-
function tlsDir(home = homedir()) {
|
|
1192
|
-
return join(home, ".dsh", "lan-gateway", "tls");
|
|
1193
|
-
}
|
|
1194
|
-
const SELF_SIGNED_CERT_FILE = "selfsigned.crt";
|
|
1195
|
-
const SELF_SIGNED_KEY_FILE = "selfsigned.key";
|
|
1196
|
-
function privateWrite(path, content) {
|
|
1197
|
-
writeFileSync(path, content, { mode: 384 });
|
|
1198
|
-
try {
|
|
1199
|
-
chmodSync(path, 384);
|
|
1200
|
-
} catch {}
|
|
1201
|
-
}
|
|
1202
|
-
/**
|
|
1203
|
-
* Load the persisted self-signed certificate, generating it on first use.
|
|
1204
|
-
* @param opts - hosts / validity for a fresh certificate.
|
|
1205
|
-
* @param home - dsh home override (tests).
|
|
1206
|
-
* @returns the material and whether it was just created.
|
|
1207
|
-
*/
|
|
1208
|
-
function loadOrCreateSelfSigned(opts, home = homedir()) {
|
|
1209
|
-
const dir = tlsDir(home);
|
|
1210
|
-
const certPath = join(dir, SELF_SIGNED_CERT_FILE);
|
|
1211
|
-
const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
|
|
1212
|
-
if (existsSync(certPath) && existsSync(keyPath)) try {
|
|
1213
|
-
const cert = readFileSync(certPath, "utf8");
|
|
1214
|
-
const key = readFileSync(keyPath, "utf8");
|
|
1215
|
-
new X509Certificate(cert);
|
|
1216
|
-
return {
|
|
1217
|
-
material: {
|
|
1218
|
-
cert,
|
|
1219
|
-
key
|
|
1220
|
-
},
|
|
1221
|
-
created: false
|
|
1222
|
-
};
|
|
1223
|
-
} catch {}
|
|
1224
|
-
const material = generateSelfSignedMaterial(opts);
|
|
1225
|
-
mkdirSync(dir, { recursive: true });
|
|
1226
|
-
privateWrite(keyPath, material.key);
|
|
1227
|
-
privateWrite(certPath, material.cert);
|
|
1228
|
-
return {
|
|
1229
|
-
material,
|
|
1230
|
-
created: true
|
|
1231
|
-
};
|
|
1232
|
-
}
|
|
1233
|
-
/**
|
|
1234
|
-
* Load the self-signed material a listener should serve: generate it on first
|
|
1235
|
-
* use, reuse the persisted pair otherwise, and replace a persisted certificate
|
|
1236
|
-
* whose validity has already lapsed.
|
|
1237
|
-
*
|
|
1238
|
-
* Nothing renews a self-signed certificate in place, and a browser refuses a
|
|
1239
|
-
* lapsed one outright, so without this a certificate that ran out would keep
|
|
1240
|
-
* being served until an operator happened to read the expiry date out of
|
|
1241
|
-
* `status` and act on it. Renewing mints a fresh key, so a client that had
|
|
1242
|
-
* trusted the old certificate has to trust the new one — but that is the case
|
|
1243
|
-
* either way, the old one having lapsed.
|
|
1244
|
-
*/
|
|
1245
|
-
function loadOrRenewSelfSigned(opts, home = homedir()) {
|
|
1246
|
-
const { material, created } = loadOrCreateSelfSigned(opts, home);
|
|
1247
|
-
if (created || !isCertExpired(material.cert)) return {
|
|
1248
|
-
material,
|
|
1249
|
-
renewed: false
|
|
1250
|
-
};
|
|
1251
|
-
return {
|
|
1252
|
-
material: regenerateSelfSigned(opts, home),
|
|
1253
|
-
renewed: true
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
/**
|
|
1257
|
-
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
1258
|
-
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
1259
|
-
*/
|
|
1260
|
-
function regenerateSelfSigned(opts, home = homedir()) {
|
|
1261
|
-
const dir = tlsDir(home);
|
|
1262
|
-
mkdirSync(dir, { recursive: true });
|
|
1263
|
-
const material = generateSelfSignedMaterial(opts);
|
|
1264
|
-
privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key);
|
|
1265
|
-
privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
|
|
1266
|
-
return material;
|
|
1267
|
-
}
|
|
1268
|
-
function generateSelfSignedMaterial(opts) {
|
|
1269
|
-
const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
|
|
1270
|
-
if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
|
|
1271
|
-
const { certPem, keyPem } = generateSelfSignedCert({
|
|
1272
|
-
hosts,
|
|
1273
|
-
days: opts.days,
|
|
1274
|
-
...opts.commonName !== void 0 ? { commonName: opts.commonName } : {}
|
|
1275
|
-
});
|
|
1276
|
-
return {
|
|
1277
|
-
cert: certPem,
|
|
1278
|
-
key: keyPem
|
|
1279
|
-
};
|
|
1280
|
-
}
|
|
1281
|
-
/**
|
|
1282
|
-
* Load a user-supplied certificate + key pair from PEM files.
|
|
1283
|
-
* @param certPath - path to the PEM certificate (or chain).
|
|
1284
|
-
* @param keyPath - path to the PEM private key.
|
|
1285
|
-
* @returns the material.
|
|
1286
|
-
*/
|
|
1287
|
-
function loadCustomCert(certPath, keyPath) {
|
|
1288
|
-
if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
|
|
1289
|
-
if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
|
|
1290
|
-
let cert;
|
|
1291
|
-
try {
|
|
1292
|
-
cert = readFileSync(certPath, "utf8");
|
|
1293
|
-
} catch (error) {
|
|
1294
|
-
throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
|
|
1295
|
-
}
|
|
1296
|
-
let key;
|
|
1297
|
-
try {
|
|
1298
|
-
key = readFileSync(keyPath, "utf8");
|
|
1299
|
-
} catch (error) {
|
|
1300
|
-
throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
|
|
1301
|
-
}
|
|
1302
|
-
try {
|
|
1303
|
-
new X509Certificate(cert);
|
|
1304
|
-
} catch {
|
|
1305
|
-
throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
|
|
1306
|
-
}
|
|
1307
|
-
return {
|
|
1308
|
-
cert,
|
|
1309
|
-
key
|
|
1310
|
-
};
|
|
1311
|
-
}
|
|
1312
|
-
/** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
|
|
1313
|
-
function parseSelfSignedHosts(text) {
|
|
1314
|
-
return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
|
|
1315
|
-
}
|
|
1316
|
-
/**
|
|
1317
|
-
* Whether a PEM certificate's validity window has already closed. A lapsed
|
|
1318
|
-
* certificate is a hard failure browsers will not let the user proceed past,
|
|
1319
|
-
* so the listener replaces one rather than keep serving it.
|
|
1320
|
-
*/
|
|
1321
|
-
function isCertExpired(certPem, now = Date.now()) {
|
|
1322
|
-
const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
|
|
1323
|
-
return Number.isFinite(expiresAt) && expiresAt <= now;
|
|
1324
|
-
}
|
|
1325
|
-
/** Describe a PEM certificate (throws on malformed input). */
|
|
1326
|
-
function describeCert(certPem) {
|
|
1327
|
-
const cert = new X509Certificate(certPem);
|
|
1328
|
-
return {
|
|
1329
|
-
subject: cert.subject,
|
|
1330
|
-
issuer: cert.issuer,
|
|
1331
|
-
validFrom: cert.validFrom,
|
|
1332
|
-
validTo: cert.validTo,
|
|
1333
|
-
fingerprint256: cert.fingerprint256,
|
|
1334
|
-
...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
|
|
1335
|
-
};
|
|
1336
|
-
}
|
|
1337
|
-
function errorMessage(error) {
|
|
1338
|
-
return error instanceof Error ? error.message : String(error);
|
|
1339
|
-
}
|
|
1340
|
-
//#endregion
|
|
1341
|
-
//#region src/tool.ts
|
|
1342
|
-
const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
|
|
1343
|
-
/**
|
|
1344
|
-
* Build the `lan_gateway` tool over a controller interface implemented by the
|
|
1345
|
-
* plugin entry. Split so the tool stays testable and the plugin decides how
|
|
1346
|
-
* the controller mutates state.
|
|
1347
|
-
*/
|
|
1348
|
-
function lanGatewayTool(control) {
|
|
1349
|
-
return defineTool({
|
|
1350
|
-
name: LAN_GATEWAY_TOOL_NAME,
|
|
1351
|
-
description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the ingress/TLS state, and the upstream-session-relay state. `enable` starts listening on 0.0.0.0 — a password is required, and by default every source (loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password; changing it revokes every existing session, and clearing it stops the listener. `rotate-secret` invalidates every issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
|
|
1352
|
-
parameters: {
|
|
1353
|
-
command: {
|
|
1354
|
-
type: "string",
|
|
1355
|
-
enum: [
|
|
1356
|
-
"status",
|
|
1357
|
-
"enable",
|
|
1358
|
-
"disable",
|
|
1359
|
-
"set-password",
|
|
1360
|
-
"rotate-secret",
|
|
1361
|
-
"tls-regenerate"
|
|
1362
|
-
],
|
|
1363
|
-
description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password (setting revokes all sessions; clearing stops the listener). `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
|
|
1364
|
-
},
|
|
1365
|
-
password: {
|
|
1366
|
-
type: "string",
|
|
1367
|
-
description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
|
|
1368
|
-
}
|
|
1369
|
-
},
|
|
1370
|
-
output: {
|
|
1371
|
-
schema: {
|
|
1372
|
-
type: "object",
|
|
1373
|
-
additionalProperties: false,
|
|
1374
|
-
properties: {
|
|
1375
|
-
ok: {
|
|
1376
|
-
type: "boolean",
|
|
1377
|
-
required: true
|
|
1378
|
-
},
|
|
1379
|
-
message: {
|
|
1380
|
-
type: "string",
|
|
1381
|
-
required: true
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
},
|
|
1385
|
-
render: (_args, value) => [{
|
|
1386
|
-
type: "text",
|
|
1387
|
-
text: value.message
|
|
1388
|
-
}]
|
|
1389
|
-
},
|
|
1390
|
-
async execute(args, _exec) {
|
|
1391
|
-
switch (args.command ?? "status") {
|
|
1392
|
-
case "status": return control.status();
|
|
1393
|
-
case "enable": return control.enable();
|
|
1394
|
-
case "disable": return control.disable();
|
|
1395
|
-
case "set-password": {
|
|
1396
|
-
const password = args.password;
|
|
1397
|
-
return control.setPassword(typeof password === "string" ? password : void 0);
|
|
1398
|
-
}
|
|
1399
|
-
case "rotate-secret": return control.rotateSecret();
|
|
1400
|
-
case "tls-regenerate": return control.regenerateTls();
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1616
|
+
const commonName = options.commonName?.trim() || hosts[0];
|
|
1617
|
+
const serial = randomBytes(16);
|
|
1618
|
+
serial[0] &= 127;
|
|
1619
|
+
const issuer = derSeq(derSet(derSeq(derOid("2.5.4.3"), derUtf8String(commonName))));
|
|
1620
|
+
const subject = issuer;
|
|
1621
|
+
const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
|
|
1622
|
+
const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
|
|
1623
|
+
const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter));
|
|
1624
|
+
const spki = publicKey.export({
|
|
1625
|
+
type: "spki",
|
|
1626
|
+
format: "der"
|
|
1403
1627
|
});
|
|
1628
|
+
const extensionsWrapper = derTag(163, derSeq(derSeq(derOid("2.5.29.19"), derBoolean(true), derOctetString(derSeq())), derSeq(derOid("2.5.29.15"), derBoolean(true), derOctetString(derBitString(Buffer.from([160])))), derSeq(derOid("2.5.29.37"), derOctetString(derSeq(derOid("1.3.6.1.5.5.7.3.1")))), derSeq(derOid("2.5.29.17"), derOctetString(derSeq(...hosts.map(sanGeneralName))))));
|
|
1629
|
+
const tbs = derSeq(derTag(160, derInt(Buffer.from([2]))), derInt(serial), sha256WithRsa(), issuer, validity, subject, spki, extensionsWrapper);
|
|
1630
|
+
const signature = createSign("sha256").update(tbs).end().sign(privateKey);
|
|
1631
|
+
const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature));
|
|
1632
|
+
return {
|
|
1633
|
+
certDer,
|
|
1634
|
+
certPem: pemEncode("CERTIFICATE", certDer),
|
|
1635
|
+
keyPem: privateKey.export({
|
|
1636
|
+
type: "pkcs8",
|
|
1637
|
+
format: "pem"
|
|
1638
|
+
}).toString(),
|
|
1639
|
+
publicKey,
|
|
1640
|
+
privateKey
|
|
1641
|
+
};
|
|
1404
1642
|
}
|
|
1405
1643
|
//#endregion
|
|
1406
|
-
//#region src/
|
|
1644
|
+
//#region src/tls.ts
|
|
1407
1645
|
/**
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
* now present a signed cookie bound to the authority it names
|
|
1413
|
-
* (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
|
|
1414
|
-
* process launch token. A reverse proxy that rewrites Host to loopback — which
|
|
1415
|
-
* is what this gateway does — therefore gets a 401 no matter how the Host is
|
|
1416
|
-
* forged. The gateway cannot mint that cookie itself (the signing secret lives
|
|
1417
|
-
* in dsh's credential provider), so it does exactly what a browser does: on the
|
|
1418
|
-
* loopback transport it visits the launch-token URL, keeps the Set-Cookie it
|
|
1419
|
-
* earns, and replays that one shared session on every request it forwards.
|
|
1420
|
-
*
|
|
1421
|
-
* Semantics match the pre-existing "single password = single operator" model:
|
|
1422
|
-
* whoever passes the gateway's own login rides this one upstream session. It is
|
|
1423
|
-
* not multi-user authorization, and upstream (which holds the secret) remains
|
|
1424
|
-
* the actual authority over what the session may do.
|
|
1425
|
-
*
|
|
1426
|
-
* The relay is a no-op on a base without browser sessions: acquisition fails
|
|
1427
|
-
* and `cookie()` returns undefined, so the gateway simply forwards without a
|
|
1428
|
-
* session cookie exactly as it did against an older dsh.
|
|
1646
|
+
* TLS material management for the gateway: self-signed certificates are
|
|
1647
|
+
* generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
|
|
1648
|
+
* restarts reuse the same certificate instead of minting a new one every
|
|
1649
|
+
* boot; custom certificates are read straight from user-supplied PEM paths.
|
|
1429
1650
|
*
|
|
1430
|
-
* @module @riceawa/dsh-lan-gateway/
|
|
1651
|
+
* @module @riceawa/dsh-lan-gateway/tls
|
|
1431
1652
|
*/
|
|
1432
|
-
/** The
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
function nameValueOnly(setCookie) {
|
|
1436
|
-
const semi = setCookie.indexOf(";");
|
|
1437
|
-
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
1653
|
+
/** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
|
|
1654
|
+
function tlsDir(home = homedir()) {
|
|
1655
|
+
return join(home, ".dsh", "lan-gateway", "tls");
|
|
1438
1656
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
*/
|
|
1444
|
-
function pathOf(url) {
|
|
1657
|
+
const SELF_SIGNED_CERT_FILE = "selfsigned.crt";
|
|
1658
|
+
const SELF_SIGNED_KEY_FILE = "selfsigned.key";
|
|
1659
|
+
function privateWrite(path, content) {
|
|
1660
|
+
writeFileSync(path, content, { mode: 384 });
|
|
1445
1661
|
try {
|
|
1446
|
-
|
|
1447
|
-
} catch {
|
|
1448
|
-
return "<unparseable>";
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
1452
|
-
function cookieNameOf(setCookie) {
|
|
1453
|
-
const eq = setCookie.indexOf("=");
|
|
1454
|
-
return eq === -1 ? "" : setCookie.slice(0, eq).trim();
|
|
1662
|
+
chmodSync(path, 384);
|
|
1663
|
+
} catch {}
|
|
1455
1664
|
}
|
|
1456
1665
|
/**
|
|
1457
|
-
*
|
|
1458
|
-
*
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
* and silently relays every request anonymously.
|
|
1666
|
+
* Load the persisted self-signed certificate, generating it on first use.
|
|
1667
|
+
* @param opts - hosts / validity for a fresh certificate.
|
|
1668
|
+
* @param home - dsh home override (tests).
|
|
1669
|
+
* @returns the material and whether it was just created.
|
|
1462
1670
|
*/
|
|
1463
|
-
function
|
|
1464
|
-
const
|
|
1465
|
-
|
|
1671
|
+
function loadOrCreateSelfSigned(opts, home = homedir()) {
|
|
1672
|
+
const dir = tlsDir(home);
|
|
1673
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE);
|
|
1674
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
|
|
1675
|
+
if (existsSync(certPath) && existsSync(keyPath)) try {
|
|
1676
|
+
const cert = readFileSync(certPath, "utf8");
|
|
1677
|
+
const key = readFileSync(keyPath, "utf8");
|
|
1678
|
+
new X509Certificate(cert);
|
|
1679
|
+
return {
|
|
1680
|
+
material: {
|
|
1681
|
+
cert,
|
|
1682
|
+
key
|
|
1683
|
+
},
|
|
1684
|
+
created: false
|
|
1685
|
+
};
|
|
1686
|
+
} catch {}
|
|
1687
|
+
const material = generateSelfSignedMaterial(opts);
|
|
1688
|
+
mkdirSync(dir, { recursive: true });
|
|
1689
|
+
privateWrite(keyPath, material.key);
|
|
1690
|
+
privateWrite(certPath, material.cert);
|
|
1691
|
+
return {
|
|
1692
|
+
material,
|
|
1693
|
+
created: true
|
|
1694
|
+
};
|
|
1466
1695
|
}
|
|
1467
|
-
/**
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1696
|
+
/**
|
|
1697
|
+
* Load the self-signed material a listener should serve: generate it on first
|
|
1698
|
+
* use, reuse the persisted pair otherwise, and replace a persisted certificate
|
|
1699
|
+
* whose validity has already lapsed.
|
|
1700
|
+
*
|
|
1701
|
+
* Nothing renews a self-signed certificate in place, and a browser refuses a
|
|
1702
|
+
* lapsed one outright, so without this a certificate that ran out would keep
|
|
1703
|
+
* being served until an operator happened to read the expiry date out of
|
|
1704
|
+
* `status` and act on it. Renewing mints a fresh key, so a client that had
|
|
1705
|
+
* trusted the old certificate has to trust the new one — but that is the case
|
|
1706
|
+
* either way, the old one having lapsed.
|
|
1707
|
+
*/
|
|
1708
|
+
function loadOrRenewSelfSigned(opts, home = homedir()) {
|
|
1709
|
+
const { material, created } = loadOrCreateSelfSigned(opts, home);
|
|
1710
|
+
if (created || !isCertExpired(material.cert)) return {
|
|
1711
|
+
material,
|
|
1712
|
+
renewed: false
|
|
1713
|
+
};
|
|
1714
|
+
return {
|
|
1715
|
+
material: regenerateSelfSigned(opts, home),
|
|
1716
|
+
renewed: true
|
|
1717
|
+
};
|
|
1471
1718
|
}
|
|
1472
1719
|
/**
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
1476
|
-
* or no session cookie came back — e.g. an older base without browser
|
|
1477
|
-
* sessions).
|
|
1720
|
+
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
1721
|
+
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
1478
1722
|
*/
|
|
1479
|
-
function
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
resolve(void 0);
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
const request = http.request({
|
|
1490
|
-
host: "127.0.0.1",
|
|
1491
|
-
port,
|
|
1492
|
-
method: "GET",
|
|
1493
|
-
path: `${target.pathname}${target.search}`,
|
|
1494
|
-
headers: {
|
|
1495
|
-
host: authority,
|
|
1496
|
-
accept: "text/html"
|
|
1497
|
-
}
|
|
1498
|
-
}, (response) => {
|
|
1499
|
-
const setCookies = response.headers["set-cookie"];
|
|
1500
|
-
response.resume();
|
|
1501
|
-
if (setCookies === void 0) {
|
|
1502
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
|
|
1503
|
-
resolve(void 0);
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
|
-
const all = Array.isArray(setCookies) ? setCookies : [setCookies];
|
|
1507
|
-
const raw = all.find(isUpstreamSessionCookie);
|
|
1508
|
-
if (raw === void 0) {
|
|
1509
|
-
const names = all.map(cookieNameOf).filter((name) => name !== "");
|
|
1510
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
|
|
1511
|
-
resolve(void 0);
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
const header = nameValueOnly(raw);
|
|
1515
|
-
const maxAge = maxAgeSeconds(raw);
|
|
1516
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
|
|
1517
|
-
resolve({
|
|
1518
|
-
header,
|
|
1519
|
-
expiresAt: Date.now() + (maxAge ?? 0) * 1e3
|
|
1520
|
-
});
|
|
1521
|
-
});
|
|
1522
|
-
request.on("error", (error) => {
|
|
1523
|
-
log(`exchange error: ${error.message}`);
|
|
1524
|
-
resolve(void 0);
|
|
1525
|
-
});
|
|
1526
|
-
request.setTimeout(5e3, () => {
|
|
1527
|
-
log("exchange timeout (5s)");
|
|
1528
|
-
request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
|
|
1529
|
-
});
|
|
1530
|
-
request.end();
|
|
1531
|
-
});
|
|
1723
|
+
function regenerateSelfSigned(opts, home = homedir()) {
|
|
1724
|
+
const dir = tlsDir(home);
|
|
1725
|
+
mkdirSync(dir, { recursive: true });
|
|
1726
|
+
const material = generateSelfSignedMaterial(opts);
|
|
1727
|
+
privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key);
|
|
1728
|
+
privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
|
|
1729
|
+
return material;
|
|
1532
1730
|
}
|
|
1533
1731
|
/**
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1732
|
+
* Read the persisted self-signed certificate for a status report, or undefined
|
|
1733
|
+
* when none has been generated yet.
|
|
1734
|
+
*
|
|
1735
|
+
* Nothing is created or written here. The status path answers a question about
|
|
1736
|
+
* a listener that is already running (or was), and minting a key pair — an RSA
|
|
1737
|
+
* generation plus two file writes — to answer a read would both be slow and
|
|
1738
|
+
* leave material on disk for a gateway that never started. Generation belongs
|
|
1739
|
+
* to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
|
|
1740
|
+
* @param home - dsh home override (tests).
|
|
1741
|
+
* @returns the certificate material as persisted, or undefined.
|
|
1537
1742
|
*/
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1743
|
+
function readSelfSignedStatus(home = homedir()) {
|
|
1744
|
+
const dir = tlsDir(home);
|
|
1745
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE);
|
|
1746
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
|
|
1747
|
+
if (!existsSync(certPath) || !existsSync(keyPath)) return void 0;
|
|
1748
|
+
const cert = readFileSync(certPath, "utf8");
|
|
1749
|
+
const key = readFileSync(keyPath, "utf8");
|
|
1750
|
+
new X509Certificate(cert);
|
|
1751
|
+
return {
|
|
1752
|
+
cert,
|
|
1753
|
+
key
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
function generateSelfSignedMaterial(opts) {
|
|
1757
|
+
const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
|
|
1758
|
+
if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
|
|
1759
|
+
const { certPem, keyPem } = generateSelfSignedCert({
|
|
1760
|
+
hosts,
|
|
1761
|
+
days: opts.days,
|
|
1762
|
+
...opts.commonName !== void 0 ? { commonName: opts.commonName } : {}
|
|
1763
|
+
});
|
|
1764
|
+
return {
|
|
1765
|
+
cert: certPem,
|
|
1766
|
+
key: keyPem
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Load a user-supplied certificate + key pair from PEM files.
|
|
1771
|
+
* @param certPath - path to the PEM certificate (or chain).
|
|
1772
|
+
* @param keyPath - path to the PEM private key.
|
|
1773
|
+
* @returns the material.
|
|
1774
|
+
*/
|
|
1775
|
+
function loadCustomCert(certPath, keyPath) {
|
|
1776
|
+
if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
|
|
1777
|
+
if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
|
|
1778
|
+
let cert;
|
|
1779
|
+
try {
|
|
1780
|
+
cert = readFileSync(certPath, "utf8");
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
|
|
1563
1783
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1784
|
+
let key;
|
|
1785
|
+
try {
|
|
1786
|
+
key = readFileSync(keyPath, "utf8");
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
|
|
1567
1789
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
});
|
|
1573
|
-
this.inflight = pending;
|
|
1574
|
-
return pending;
|
|
1790
|
+
try {
|
|
1791
|
+
new X509Certificate(cert);
|
|
1792
|
+
} catch {
|
|
1793
|
+
throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
|
|
1575
1794
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1795
|
+
return {
|
|
1796
|
+
cert,
|
|
1797
|
+
key
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
/** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
|
|
1801
|
+
function parseSelfSignedHosts(text) {
|
|
1802
|
+
return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Whether a PEM certificate's validity window has already closed. A lapsed
|
|
1806
|
+
* certificate is a hard failure browsers will not let the user proceed past,
|
|
1807
|
+
* so the listener replaces one rather than keep serving it.
|
|
1808
|
+
*/
|
|
1809
|
+
function isCertExpired(certPem, now = Date.now()) {
|
|
1810
|
+
const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
|
|
1811
|
+
return Number.isFinite(expiresAt) && expiresAt <= now;
|
|
1812
|
+
}
|
|
1813
|
+
/** Describe a PEM certificate (throws on malformed input). */
|
|
1814
|
+
function describeCert(certPem) {
|
|
1815
|
+
const cert = new X509Certificate(certPem);
|
|
1816
|
+
return {
|
|
1817
|
+
subject: cert.subject,
|
|
1818
|
+
issuer: cert.issuer,
|
|
1819
|
+
validFrom: cert.validFrom,
|
|
1820
|
+
validTo: cert.validTo,
|
|
1821
|
+
fingerprint256: cert.fingerprint256,
|
|
1822
|
+
...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
function errorMessage(error) {
|
|
1826
|
+
return error instanceof Error ? error.message : String(error);
|
|
1827
|
+
}
|
|
1828
|
+
//#endregion
|
|
1829
|
+
//#region src/tool.ts
|
|
1830
|
+
const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
|
|
1831
|
+
/**
|
|
1832
|
+
* Build the `lan_gateway` tool over a controller interface implemented by the
|
|
1833
|
+
* plugin entry. Split so the tool stays testable and the plugin decides how
|
|
1834
|
+
* the controller mutates state.
|
|
1835
|
+
*/
|
|
1836
|
+
function lanGatewayTool(control) {
|
|
1837
|
+
return defineTool({
|
|
1838
|
+
name: LAN_GATEWAY_TOOL_NAME,
|
|
1839
|
+
description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the ingress/TLS state, and the upstream-session-relay state. `enable` starts listening on 0.0.0.0 — a password is required, and by default every source (loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password; changing it revokes every existing session, and clearing it stops the listener. `rotate-secret` invalidates every issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
|
|
1840
|
+
parameters: {
|
|
1841
|
+
command: {
|
|
1842
|
+
type: "string",
|
|
1843
|
+
enum: [
|
|
1844
|
+
"status",
|
|
1845
|
+
"enable",
|
|
1846
|
+
"disable",
|
|
1847
|
+
"set-password",
|
|
1848
|
+
"rotate-secret",
|
|
1849
|
+
"tls-regenerate"
|
|
1850
|
+
],
|
|
1851
|
+
description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password (setting revokes all sessions; clearing stops the listener). `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
|
|
1852
|
+
},
|
|
1853
|
+
password: {
|
|
1854
|
+
type: "string",
|
|
1855
|
+
description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
|
|
1856
|
+
}
|
|
1857
|
+
},
|
|
1858
|
+
output: {
|
|
1859
|
+
schema: {
|
|
1860
|
+
type: "object",
|
|
1861
|
+
additionalProperties: false,
|
|
1862
|
+
properties: {
|
|
1863
|
+
ok: {
|
|
1864
|
+
type: "boolean",
|
|
1865
|
+
required: true
|
|
1866
|
+
},
|
|
1867
|
+
message: {
|
|
1868
|
+
type: "string",
|
|
1869
|
+
required: true
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
},
|
|
1873
|
+
render: (_args, value) => [{
|
|
1874
|
+
type: "text",
|
|
1875
|
+
text: value.message
|
|
1876
|
+
}]
|
|
1877
|
+
},
|
|
1878
|
+
async execute(args, _exec) {
|
|
1879
|
+
switch (args.command ?? "status") {
|
|
1880
|
+
case "status": return control.status();
|
|
1881
|
+
case "enable": return control.enable();
|
|
1882
|
+
case "disable": return control.disable();
|
|
1883
|
+
case "set-password": {
|
|
1884
|
+
const password = args.password;
|
|
1885
|
+
return control.setPassword(typeof password === "string" ? password : void 0);
|
|
1886
|
+
}
|
|
1887
|
+
case "rotate-secret": return control.rotateSecret();
|
|
1888
|
+
case "tls-regenerate": return control.regenerateTls();
|
|
1889
|
+
}
|
|
1581
1890
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
if (result !== void 0) {
|
|
1585
|
-
this.held = result;
|
|
1586
|
-
this.log("session acquired and cached");
|
|
1587
|
-
} else this.log("exchange failed; keeping current session");
|
|
1588
|
-
return this.held?.header;
|
|
1589
|
-
}
|
|
1590
|
-
};
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1591
1893
|
//#endregion
|
|
1592
1894
|
//#region src/index.ts
|
|
1593
1895
|
/** Stable Cordis plugin name. */
|
|
@@ -1595,40 +1897,91 @@ const name = "dsh-lan-gateway";
|
|
|
1595
1897
|
/** Requires the web server service (binds before this row's apply runs) and the tool registry. */
|
|
1596
1898
|
const inject = ["webServer", "tools"];
|
|
1597
1899
|
/**
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
*
|
|
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`.
|
|
1602
1907
|
*/
|
|
1603
|
-
const NS = "lan-gateway";
|
|
1604
|
-
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
1605
|
-
const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
1606
|
-
"dshTargetPort",
|
|
1607
|
-
"tlsCertPath",
|
|
1608
|
-
"tlsKeyPath",
|
|
1609
|
-
"trustedTerminator"
|
|
1610
|
-
]);
|
|
1611
|
-
/** Schemastery configuration validated by the Loader. */
|
|
1612
1908
|
const Config = z.object({
|
|
1613
|
-
enabled: z.boolean().default(false),
|
|
1614
|
-
gatewayPort: z.natural().min(1).max(65535).default(3081),
|
|
1615
|
-
dshTargetPort: z.natural().min(1).max(65535),
|
|
1616
|
-
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
|
|
1617
|
-
lanPasswordless: z.boolean().default(false),
|
|
1618
|
-
authRequired: z.boolean().default(true),
|
|
1619
|
-
cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
|
|
1620
|
-
cookieName: z.string().default("dsh_gw_auth"),
|
|
1621
|
-
tlsEnabled: z.boolean().default(false),
|
|
1622
|
-
tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
|
|
1623
|
-
tlsCertPath: z.string(),
|
|
1624
|
-
tlsKeyPath: z.string(),
|
|
1625
|
-
tlsSelfSignedHosts: z.string().default("localhost"),
|
|
1626
|
-
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
|
|
1627
|
-
allowInsecurePlaintext: z.boolean().default(false),
|
|
1628
|
-
trustedTerminator: z.string(),
|
|
1629
|
-
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()
|
|
1630
1926
|
});
|
|
1631
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
|
+
/**
|
|
1632
1985
|
* The fail-closed problems that prevent a config from enabling the listener.
|
|
1633
1986
|
* Returns every problem (not just the first) so the operator sees the full
|
|
1634
1987
|
* migration at once. Exported for tests.
|
|
@@ -1697,32 +2050,26 @@ function listenerKey(cfg, relayAvailable) {
|
|
|
1697
2050
|
relayAvailable
|
|
1698
2051
|
]);
|
|
1699
2052
|
}
|
|
1700
|
-
/**
|
|
2053
|
+
/**
|
|
2054
|
+
* One-line TLS description for status output.
|
|
2055
|
+
*
|
|
2056
|
+
* Never generates: this is the read path behind `GET /lan-gateway/config` and
|
|
2057
|
+
* `lan_gateway status`, and a status query that mints an RSA key and writes a
|
|
2058
|
+
* certificate to disk is not a read. The material is created when the listener
|
|
2059
|
+
* starts, or by `lan_gateway tls-regenerate`.
|
|
2060
|
+
*/
|
|
1701
2061
|
function tlsStatusLine(cfg) {
|
|
1702
2062
|
if (!cfg.tlsEnabled) return "off";
|
|
1703
2063
|
if (cfg.tlsMode === "custom") return `custom (${cfg.tlsCertPath ?? "?"}, ${cfg.tlsKeyPath ?? "?"})`;
|
|
1704
2064
|
try {
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
});
|
|
1709
|
-
const info = describeCert(material.cert);
|
|
2065
|
+
const status = readSelfSignedStatus();
|
|
2066
|
+
if (status === void 0) return "self-signed (not generated yet — created when the listener starts)";
|
|
2067
|
+
const info = describeCert(status.cert);
|
|
1710
2068
|
return `self-signed [${info.subject}] exp ${info.validTo}`;
|
|
1711
2069
|
} catch (error) {
|
|
1712
2070
|
return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`;
|
|
1713
2071
|
}
|
|
1714
2072
|
}
|
|
1715
|
-
/** Whether `hostname` is loopback (127/8, localhost, ::1). */
|
|
1716
|
-
function isLoopbackHost(hostname) {
|
|
1717
|
-
if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
|
|
1718
|
-
const parts = hostname.split(".");
|
|
1719
|
-
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1720
|
-
}
|
|
1721
|
-
const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1722
|
-
"GET",
|
|
1723
|
-
"HEAD",
|
|
1724
|
-
"OPTIONS"
|
|
1725
|
-
]);
|
|
1726
2073
|
/**
|
|
1727
2074
|
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
1728
2075
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
@@ -1748,21 +2095,101 @@ function isTrustedConfigRequest(req) {
|
|
|
1748
2095
|
if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
|
|
1749
2096
|
return true;
|
|
1750
2097
|
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Turn a submitted config patch into the next user section: only keys the card
|
|
2100
|
+
* can edit, only real values, and `null` (or an emptied optional) removes the
|
|
2101
|
+
* key rather than storing it.
|
|
2102
|
+
*
|
|
2103
|
+
* The patch is built from the *submitted* object, never from a schema call's
|
|
2104
|
+
* output. Schemastery fills defaults into whatever it validates and passes
|
|
2105
|
+
* unknown keys through, so deriving the section from `Config(submitted)` wrote
|
|
2106
|
+
* `authRequired: true` (a capability that exists only to be refused) and any
|
|
2107
|
+
* stray key into the user's settings on every save — and, because it also
|
|
2108
|
+
* materialized `cookieName`, reset an operator's custom cookie name to the
|
|
2109
|
+
* schema default.
|
|
2110
|
+
*
|
|
2111
|
+
* A `null` value is the card's clear: the key is dropped from the patch, which
|
|
2112
|
+
* leaves it absent from the section, so it re-inherits the composition layer.
|
|
2113
|
+
*/
|
|
2114
|
+
function buildConfigPatch(submitted) {
|
|
2115
|
+
const patch = {};
|
|
2116
|
+
const clear = [];
|
|
2117
|
+
const unknown = [];
|
|
2118
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
2119
|
+
if (!CONFIG_FIELD_KEYS.has(key)) {
|
|
2120
|
+
unknown.push(key);
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
if (value === null || value === void 0) {
|
|
2124
|
+
clear.push(key);
|
|
2125
|
+
continue;
|
|
2126
|
+
}
|
|
2127
|
+
if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) {
|
|
2128
|
+
clear.push(key);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
patch[key] = value;
|
|
2132
|
+
}
|
|
2133
|
+
return {
|
|
2134
|
+
patch,
|
|
2135
|
+
clear,
|
|
2136
|
+
unknown
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
1751
2139
|
function apply(ctx, config) {
|
|
1752
2140
|
let state = loadState();
|
|
1753
2141
|
let gateway;
|
|
1754
2142
|
let startedWith;
|
|
1755
2143
|
let lastError;
|
|
2144
|
+
/**
|
|
2145
|
+
* The operator's run intent, used only while no settings service is attached.
|
|
2146
|
+
* With settings present, `enabled` in the settings section *is* the intent —
|
|
2147
|
+
* the card and the tool write the same field, so there is one truth rather
|
|
2148
|
+
* than two that disagree.
|
|
2149
|
+
*/
|
|
1756
2150
|
let manualOverride;
|
|
1757
2151
|
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
1758
2152
|
let upstreamSessionAvailable = false;
|
|
2153
|
+
/**
|
|
2154
|
+
* Identifies the current `connection` handler. A provider that detaches and a
|
|
2155
|
+
* new one that attaches run their disposers in an order the plugin does not
|
|
2156
|
+
* control, and a stale disposer clearing `makeRelay` would strand the live
|
|
2157
|
+
* provider — so a disposer only acts if it is still the latest generation.
|
|
2158
|
+
*/
|
|
2159
|
+
let connectionGeneration = 0;
|
|
1759
2160
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
1760
2161
|
let makeRelay;
|
|
1761
|
-
/**
|
|
1762
|
-
let
|
|
1763
|
-
/**
|
|
1764
|
-
|
|
1765
|
-
|
|
2162
|
+
/** Whether the settings service is attached, so writes reach the profile entry. */
|
|
2163
|
+
let settingsAttached = false;
|
|
2164
|
+
/**
|
|
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.
|
|
2174
|
+
*/
|
|
2175
|
+
let settingsProvider;
|
|
2176
|
+
/**
|
|
2177
|
+
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
2178
|
+
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
2179
|
+
* two of them can never interleave a stop with a start.
|
|
2180
|
+
*/
|
|
2181
|
+
let lifecycle = Promise.resolve();
|
|
2182
|
+
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
2183
|
+
let disposed = false;
|
|
2184
|
+
const effective = () => readConfig(config);
|
|
2185
|
+
/** Queue one lifecycle action behind every action already running. */
|
|
2186
|
+
const enqueue = (reason, action) => {
|
|
2187
|
+
lifecycle = lifecycle.then(action).catch((error) => {
|
|
2188
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
2189
|
+
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
|
|
2190
|
+
});
|
|
2191
|
+
return lifecycle;
|
|
2192
|
+
};
|
|
1766
2193
|
const startGateway = async (cfg) => {
|
|
1767
2194
|
if (gateway !== void 0) return;
|
|
1768
2195
|
const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
|
|
@@ -1789,6 +2216,10 @@ function apply(ctx, config) {
|
|
|
1789
2216
|
}
|
|
1790
2217
|
}, state);
|
|
1791
2218
|
await next.listen();
|
|
2219
|
+
if (disposed) {
|
|
2220
|
+
await next.close();
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
1792
2223
|
gateway = next;
|
|
1793
2224
|
startedWith = listenerKey(cfg, makeRelay !== void 0);
|
|
1794
2225
|
ctx.logger.info(`dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}${encryptedIngress ? "" : " (plaintext, explicit allowInsecurePlaintext)"}${makeRelay !== void 0 ? " [shared upstream session relay]" : " [no upstream session relay: base has no browser-session auth]"}`);
|
|
@@ -1803,42 +2234,58 @@ function apply(ctx, config) {
|
|
|
1803
2234
|
ctx.logger.info("dsh-lan-gateway: stopped");
|
|
1804
2235
|
}
|
|
1805
2236
|
};
|
|
2237
|
+
/** The config the listener should be running under, intent included. */
|
|
2238
|
+
const desiredConfig = () => {
|
|
2239
|
+
const cfg = effective();
|
|
2240
|
+
if (settingsAttached) return cfg;
|
|
2241
|
+
return manualOverride === void 0 ? cfg : {
|
|
2242
|
+
...cfg,
|
|
2243
|
+
enabled: manualOverride
|
|
2244
|
+
};
|
|
2245
|
+
};
|
|
1806
2246
|
/** Reconcile the listener with the effective config (start/stop/restart). */
|
|
1807
2247
|
const syncGateway = (reason) => {
|
|
1808
|
-
|
|
2248
|
+
return enqueue(reason, async () => {
|
|
1809
2249
|
lastError = void 0;
|
|
1810
|
-
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
await startGateway(cfg);
|
|
1819
|
-
}
|
|
1820
|
-
} catch (error) {
|
|
1821
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
1822
|
-
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
|
|
2250
|
+
if (disposed) return;
|
|
2251
|
+
const cfg = desiredConfig();
|
|
2252
|
+
if (gateway === void 0) {
|
|
2253
|
+
if (cfg.enabled) await startGateway(cfg);
|
|
2254
|
+
} else if (!cfg.enabled) await stopGateway();
|
|
2255
|
+
else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
|
|
2256
|
+
await stopGateway();
|
|
2257
|
+
await startGateway(cfg);
|
|
1823
2258
|
}
|
|
1824
2259
|
});
|
|
1825
|
-
return syncing;
|
|
1826
2260
|
};
|
|
1827
|
-
|
|
2261
|
+
/** Record the run intent where it will survive: the profile entry, or memory. */
|
|
2262
|
+
const setRunIntent = async (enabled) => {
|
|
2263
|
+
if (settingsAttached && settingsProvider !== void 0 && settingsEntryId !== void 0) {
|
|
2264
|
+
await settingsProvider.update(settingsEntryId, { enabled });
|
|
2265
|
+
return;
|
|
2266
|
+
}
|
|
2267
|
+
manualOverride = enabled;
|
|
2268
|
+
};
|
|
1828
2269
|
ctx.inject(["settings"], (sctx) => {
|
|
1829
|
-
const
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
2270
|
+
const entryId = ctx.fiber.entry?.options.id;
|
|
2271
|
+
if (entryId === void 0) return;
|
|
2272
|
+
settingsEntryId = entryId;
|
|
2273
|
+
settingsProvider = sctx.settings;
|
|
2274
|
+
settingsAttached = true;
|
|
2275
|
+
sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
|
|
2276
|
+
sctx.effect(() => ctx.on("loader/volatile-update", () => {
|
|
1833
2277
|
syncGateway("settings change");
|
|
1834
2278
|
}));
|
|
1835
2279
|
sctx.effect(() => () => {
|
|
1836
|
-
|
|
1837
|
-
|
|
2280
|
+
settingsEntryId = void 0;
|
|
2281
|
+
settingsProvider = void 0;
|
|
2282
|
+
settingsAttached = false;
|
|
2283
|
+
syncGateway("settings detach");
|
|
1838
2284
|
});
|
|
1839
2285
|
syncGateway("settings attach");
|
|
1840
2286
|
});
|
|
1841
2287
|
ctx.inject(["connection"], (ccx) => {
|
|
2288
|
+
const generation = ++connectionGeneration;
|
|
1842
2289
|
upstreamSessionAvailable = true;
|
|
1843
2290
|
ctx.logger.info("dsh-lan-gateway: connection service attached; upstream session relay enabled");
|
|
1844
2291
|
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
@@ -1846,9 +2293,26 @@ function apply(ctx, config) {
|
|
|
1846
2293
|
authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
|
|
1847
2294
|
log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`)
|
|
1848
2295
|
});
|
|
2296
|
+
ccx.effect(() => () => {
|
|
2297
|
+
if (generation !== connectionGeneration) return;
|
|
2298
|
+
makeRelay = void 0;
|
|
2299
|
+
upstreamSessionAvailable = false;
|
|
2300
|
+
syncGateway("connection detach");
|
|
2301
|
+
});
|
|
1849
2302
|
syncGateway("connection attach");
|
|
1850
2303
|
});
|
|
1851
2304
|
const configRouteHandler = async (req, res) => {
|
|
2305
|
+
const snapshot = () => {
|
|
2306
|
+
const cfg = effective();
|
|
2307
|
+
return {
|
|
2308
|
+
config: cfg,
|
|
2309
|
+
running: gateway !== void 0,
|
|
2310
|
+
port: cfg.gatewayPort,
|
|
2311
|
+
tls: tlsStatusLine(cfg),
|
|
2312
|
+
upstreamSessionAvailable,
|
|
2313
|
+
lastError: lastError ?? null
|
|
2314
|
+
};
|
|
2315
|
+
};
|
|
1852
2316
|
const send = (status, body) => {
|
|
1853
2317
|
res.writeHead(status, { "content-type": "application/json" });
|
|
1854
2318
|
res.end(JSON.stringify(body));
|
|
@@ -1858,15 +2322,7 @@ function apply(ctx, config) {
|
|
|
1858
2322
|
return;
|
|
1859
2323
|
}
|
|
1860
2324
|
if (req.method === "GET") {
|
|
1861
|
-
|
|
1862
|
-
send(200, {
|
|
1863
|
-
config: cfg,
|
|
1864
|
-
running: gateway !== void 0,
|
|
1865
|
-
port: cfg.gatewayPort,
|
|
1866
|
-
tls: tlsStatusLine(cfg),
|
|
1867
|
-
upstreamSessionAvailable,
|
|
1868
|
-
lastError: lastError ?? null
|
|
1869
|
-
});
|
|
2325
|
+
send(200, snapshot());
|
|
1870
2326
|
return;
|
|
1871
2327
|
}
|
|
1872
2328
|
if (req.method !== "POST") {
|
|
@@ -1886,41 +2342,37 @@ function apply(ctx, config) {
|
|
|
1886
2342
|
send(400, { error: "body must be a config object" });
|
|
1887
2343
|
return;
|
|
1888
2344
|
}
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
} catch (error) {
|
|
1893
|
-
send(400, { error: error instanceof Error ? error.message : String(error) });
|
|
1894
|
-
return;
|
|
1895
|
-
}
|
|
1896
|
-
if (settingsScope === void 0) {
|
|
2345
|
+
const settings = settingsProvider;
|
|
2346
|
+
const entryId = settingsEntryId;
|
|
2347
|
+
if (settings === void 0 || entryId === void 0) {
|
|
1897
2348
|
send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
|
|
1898
2349
|
return;
|
|
1899
2350
|
}
|
|
2351
|
+
const { patch, clear, unknown } = buildConfigPatch(submitted);
|
|
2352
|
+
const candidate = validateConfig({
|
|
2353
|
+
...effective(),
|
|
2354
|
+
...patch
|
|
2355
|
+
});
|
|
1900
2356
|
const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
|
|
1901
2357
|
const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
|
|
1902
2358
|
if (structural || candidate.enabled && problems.length > 0) {
|
|
1903
2359
|
send(409, { error: `config cannot start: ${problems.join(" ")}` });
|
|
1904
2360
|
return;
|
|
1905
2361
|
}
|
|
1906
|
-
const section = {};
|
|
1907
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
1908
|
-
if (value === null || value === void 0) continue;
|
|
1909
|
-
if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) continue;
|
|
1910
|
-
section[key] = value;
|
|
1911
|
-
}
|
|
1912
2362
|
try {
|
|
1913
|
-
|
|
2363
|
+
const ops = [...Object.entries(patch).map(([key, value]) => ({
|
|
2364
|
+
op: "set",
|
|
2365
|
+
path: [key],
|
|
2366
|
+
value
|
|
2367
|
+
})), ...clear.map((key) => ({
|
|
2368
|
+
op: "unset",
|
|
2369
|
+
path: [key]
|
|
2370
|
+
}))];
|
|
2371
|
+
if (ops.length > 0) await settings.mutate(entryId, ops);
|
|
1914
2372
|
await syncGateway("config route save");
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
running: gateway !== void 0,
|
|
1919
|
-
port: cfg.gatewayPort,
|
|
1920
|
-
tls: tlsStatusLine(cfg),
|
|
1921
|
-
upstreamSessionAvailable,
|
|
1922
|
-
lastError: lastError ?? null
|
|
1923
|
-
});
|
|
2373
|
+
const next = { ...snapshot() };
|
|
2374
|
+
if (unknown.length > 0) next["ignored"] = unknown;
|
|
2375
|
+
send(200, next);
|
|
1924
2376
|
} catch (error) {
|
|
1925
2377
|
send(409, { error: error instanceof Error ? error.message : String(error) });
|
|
1926
2378
|
}
|
|
@@ -1932,16 +2384,16 @@ function apply(ctx, config) {
|
|
|
1932
2384
|
}), "dsh-lan-gateway: config route");
|
|
1933
2385
|
ctx.tools.register(lanGatewayTool({
|
|
1934
2386
|
status() {
|
|
1935
|
-
const cfg =
|
|
2387
|
+
const cfg = desiredConfig();
|
|
1936
2388
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1937
2389
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1938
2390
|
return {
|
|
1939
2391
|
ok: true,
|
|
1940
|
-
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
|
|
2392
|
+
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 && !settingsAttached ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
|
|
1941
2393
|
};
|
|
1942
2394
|
},
|
|
1943
2395
|
async enable() {
|
|
1944
|
-
|
|
2396
|
+
await setRunIntent(true);
|
|
1945
2397
|
await syncGateway("tool enable");
|
|
1946
2398
|
return gateway !== void 0 ? {
|
|
1947
2399
|
ok: true,
|
|
@@ -1952,7 +2404,7 @@ function apply(ctx, config) {
|
|
|
1952
2404
|
};
|
|
1953
2405
|
},
|
|
1954
2406
|
async disable() {
|
|
1955
|
-
|
|
2407
|
+
await setRunIntent(false);
|
|
1956
2408
|
await syncGateway("tool disable");
|
|
1957
2409
|
return {
|
|
1958
2410
|
ok: true,
|
|
@@ -1965,23 +2417,21 @@ function apply(ctx, config) {
|
|
|
1965
2417
|
message: "Password must be at least 8 characters."
|
|
1966
2418
|
};
|
|
1967
2419
|
const setting = password !== void 0 && password.length > 0;
|
|
1968
|
-
const
|
|
1969
|
-
state = setPassword(state, setting ? password : void 0);
|
|
2420
|
+
const hadPassword = state.password !== void 0;
|
|
2421
|
+
state = await setPassword(state, setting ? password : void 0);
|
|
1970
2422
|
saveState(state);
|
|
1971
2423
|
gateway?.setState(state);
|
|
1972
2424
|
if (!setting) {
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
await stopGateway();
|
|
2425
|
+
await setRunIntent(false);
|
|
2426
|
+
return enqueue("password cleared", async () => {
|
|
2427
|
+
if (gateway !== void 0) await stopGateway();
|
|
1976
2428
|
lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
|
|
1977
|
-
|
|
1978
|
-
}
|
|
1979
|
-
return {
|
|
2429
|
+
}).then(() => ({
|
|
1980
2430
|
ok: true,
|
|
1981
2431
|
message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
|
|
1982
|
-
};
|
|
2432
|
+
}));
|
|
1983
2433
|
}
|
|
1984
|
-
|
|
2434
|
+
if (!hadPassword) await syncGateway("password set");
|
|
1985
2435
|
return {
|
|
1986
2436
|
ok: true,
|
|
1987
2437
|
message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
|
|
@@ -2012,32 +2462,38 @@ function apply(ctx, config) {
|
|
|
2012
2462
|
ok: false,
|
|
2013
2463
|
message: "tlsSelfSignedHosts must name at least one host (DNS name or IP)."
|
|
2014
2464
|
};
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2465
|
+
let failure;
|
|
2466
|
+
await enqueue("tls regenerate", async () => {
|
|
2467
|
+
try {
|
|
2468
|
+
regenerateSelfSigned({
|
|
2469
|
+
hosts,
|
|
2470
|
+
days: cfg.tlsCertMaxAgeDays
|
|
2471
|
+
});
|
|
2472
|
+
if (gateway !== void 0) {
|
|
2473
|
+
await stopGateway();
|
|
2474
|
+
await startGateway(effective());
|
|
2475
|
+
}
|
|
2023
2476
|
lastError = void 0;
|
|
2477
|
+
} catch (error) {
|
|
2478
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
2024
2479
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
}
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
};
|
|
2034
|
-
}
|
|
2480
|
+
});
|
|
2481
|
+
return failure === void 0 ? {
|
|
2482
|
+
ok: true,
|
|
2483
|
+
message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
|
|
2484
|
+
} : {
|
|
2485
|
+
ok: false,
|
|
2486
|
+
message: `Failed to regenerate TLS certificate: ${failure}`
|
|
2487
|
+
};
|
|
2035
2488
|
}
|
|
2036
2489
|
}));
|
|
2037
2490
|
ctx.effect(() => {
|
|
2038
2491
|
syncGateway("boot");
|
|
2039
|
-
return
|
|
2492
|
+
return async () => {
|
|
2493
|
+
disposed = true;
|
|
2494
|
+
await enqueue("dispose", stopGateway);
|
|
2495
|
+
};
|
|
2040
2496
|
}, "dsh-lan-gateway: listener lifecycle");
|
|
2041
2497
|
}
|
|
2042
2498
|
//#endregion
|
|
2043
|
-
export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
|
|
2499
|
+
export { Config, apply, buildConfigPatch, configRefs, gatewayStartProblems, inject, isTrustedConfigRequest, name, readConfig, resolveSecureCookies, validateConfig };
|