@riceawa/dsh-lan-gateway 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -46
- package/cordis.patch.yml +5 -2
- package/lib/client.js +22 -5
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +477 -111
- package/package.json +24 -21
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/lan-gateway-card.tsx +18 -6
- package/src/gateway.ts +230 -86
- package/src/index.ts +204 -51
- package/src/login.ts +3 -0
- package/src/state.ts +27 -6
- package/src/tool.ts +12 -8
- package/src/upstream-session.ts +187 -0
package/lib/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
3
|
import http from "node:http";
|
|
5
4
|
import https from "node:https";
|
|
6
5
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -85,14 +84,25 @@ function base64url(input) {
|
|
|
85
84
|
* Issue a signed session cookie value.
|
|
86
85
|
* @param secret - the HMAC signing secret (base64 string).
|
|
87
86
|
* @param expiresMs - epoch millis at which the session expires.
|
|
87
|
+
* @param epoch - the session revocation epoch the cookie is minted under; a
|
|
88
|
+
* cookie whose epoch no longer matches the live state is rejected by
|
|
89
|
+
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
90
|
+
* do not participate in revocation.
|
|
88
91
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
89
92
|
*/
|
|
90
|
-
function signCookie(secret, expiresMs) {
|
|
91
|
-
const payload = base64url(Buffer.from(JSON.stringify({
|
|
93
|
+
function signCookie(secret, expiresMs, epoch = 0) {
|
|
94
|
+
const payload = base64url(Buffer.from(JSON.stringify({
|
|
95
|
+
exp: expiresMs,
|
|
96
|
+
epoch
|
|
97
|
+
})));
|
|
92
98
|
return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
|
|
93
99
|
}
|
|
94
|
-
/**
|
|
95
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Whether a cookie value is a valid, unexpired session signed with `secret`
|
|
102
|
+
* and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
|
|
103
|
+
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
104
|
+
*/
|
|
105
|
+
function verifyCookie(secret, value, now, epoch = 0) {
|
|
96
106
|
if (value === void 0) return false;
|
|
97
107
|
const dot = value.indexOf(".");
|
|
98
108
|
if (dot === -1) return false;
|
|
@@ -109,7 +119,25 @@ function verifyCookie(secret, value, now) {
|
|
|
109
119
|
if (!timingSafeEqual(expected, actual)) return false;
|
|
110
120
|
try {
|
|
111
121
|
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
112
|
-
|
|
122
|
+
if (typeof decoded.exp !== "number" || decoded.exp <= now) return false;
|
|
123
|
+
return (typeof decoded.epoch === "number" ? decoded.epoch : 0) === epoch;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Whether a browser Origin header names the same authority (hostname:port) as
|
|
130
|
+
* a request Host header. Both sides run through WHATWG URL parsing so case and
|
|
131
|
+
* an implicit scheme-default port never decide the match — the comparison the
|
|
132
|
+
* gateway uses to tell same-origin browser requests from cross-site ones.
|
|
133
|
+
* @param origin - the `Origin` header value, or undefined.
|
|
134
|
+
* @param host - the `Host` header value, or undefined.
|
|
135
|
+
* @returns true only when both parse and name the same host[:port].
|
|
136
|
+
*/
|
|
137
|
+
function originMatchesHost(origin, host) {
|
|
138
|
+
if (origin === void 0 || host === void 0) return false;
|
|
139
|
+
try {
|
|
140
|
+
return new URL(origin).host === new URL(`http://${host}`).host;
|
|
113
141
|
} catch {
|
|
114
142
|
return false;
|
|
115
143
|
}
|
|
@@ -266,13 +294,24 @@ function verifyPassword(state, password) {
|
|
|
266
294
|
return false;
|
|
267
295
|
}
|
|
268
296
|
}
|
|
269
|
-
/**
|
|
297
|
+
/**
|
|
298
|
+
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
299
|
+
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
300
|
+
* password change must invalidate sessions the old password authorized.
|
|
301
|
+
*/
|
|
270
302
|
function setPassword(state, password) {
|
|
271
|
-
|
|
303
|
+
const base = {
|
|
304
|
+
...state,
|
|
305
|
+
sessionEpoch: state.sessionEpoch + 1
|
|
306
|
+
};
|
|
307
|
+
if (password === void 0) return {
|
|
308
|
+
cookieSecret: base.cookieSecret,
|
|
309
|
+
sessionEpoch: base.sessionEpoch
|
|
310
|
+
};
|
|
272
311
|
const salt = randomBytes(16);
|
|
273
312
|
const hash = scryptSync(password, salt, 64);
|
|
274
313
|
return {
|
|
275
|
-
...
|
|
314
|
+
...base,
|
|
276
315
|
password: {
|
|
277
316
|
hash: hash.toString("hex"),
|
|
278
317
|
salt: salt.toString("hex")
|
|
@@ -280,7 +319,10 @@ function setPassword(state, password) {
|
|
|
280
319
|
};
|
|
281
320
|
}
|
|
282
321
|
function defaultState() {
|
|
283
|
-
return {
|
|
322
|
+
return {
|
|
323
|
+
cookieSecret: randomBytes(32).toString("base64"),
|
|
324
|
+
sessionEpoch: 0
|
|
325
|
+
};
|
|
284
326
|
}
|
|
285
327
|
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
286
328
|
function loadState(home = homedir()) {
|
|
@@ -288,7 +330,15 @@ function loadState(home = homedir()) {
|
|
|
288
330
|
try {
|
|
289
331
|
const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
|
|
290
332
|
const parsed = JSON.parse(raw);
|
|
291
|
-
if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16)
|
|
333
|
+
if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) {
|
|
334
|
+
const sessionEpoch = typeof parsed.sessionEpoch === "number" && Number.isSafeInteger(parsed.sessionEpoch) ? parsed.sessionEpoch : 0;
|
|
335
|
+
const base = {
|
|
336
|
+
cookieSecret: parsed.cookieSecret,
|
|
337
|
+
sessionEpoch
|
|
338
|
+
};
|
|
339
|
+
if (parsed.password !== void 0) base.password = parsed.password;
|
|
340
|
+
return base;
|
|
341
|
+
}
|
|
292
342
|
return defaultState();
|
|
293
343
|
} catch {
|
|
294
344
|
return defaultState();
|
|
@@ -309,24 +359,54 @@ function saveState(state, home = homedir()) {
|
|
|
309
359
|
//#endregion
|
|
310
360
|
//#region src/gateway.ts
|
|
311
361
|
/**
|
|
312
|
-
* The reverse-proxy gateway: a `node:http` server bound to `0.0.0.0` that
|
|
313
|
-
* forwards every request to the loopback dsh web server
|
|
314
|
-
* Origin so the dsh `/api` trust fence (which only trusts loopback) passes.
|
|
362
|
+
* The reverse-proxy gateway: a `node:http(s)` server bound to `0.0.0.0` that
|
|
363
|
+
* forwards every request to the loopback dsh web server.
|
|
315
364
|
*
|
|
316
|
-
* Security model:
|
|
365
|
+
* Security model (post-QVD / session-base):
|
|
317
366
|
* - Source is classified from `socket.remoteAddress` only (never
|
|
318
|
-
* `X-Forwarded-For`).
|
|
319
|
-
*
|
|
367
|
+
* `X-Forwarded-For`). Classification alone grants nothing: by default every
|
|
368
|
+
* source — loopback, LAN, internet — must present a valid gateway session.
|
|
369
|
+
* `lanPasswordless` (an explicit opt-in, false by default) is the one way a
|
|
370
|
+
* LAN/loopback source skips the gateway login, and it is only ever allowed
|
|
371
|
+
* against a session-capable dsh base (enforced by the plugin, which owns the
|
|
372
|
+
* fail-closed guard).
|
|
373
|
+
* - The gateway never forwards its own management surface (`/lan-gateway/*`)
|
|
374
|
+
* or its login/logout paths; those are handled locally or refused.
|
|
320
375
|
* - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
|
|
321
|
-
* blinded — so the gateway runs its own origin check on
|
|
322
|
-
*
|
|
323
|
-
*
|
|
376
|
+
* blinded — so the gateway runs its own origin check on every relayed
|
|
377
|
+
* request (HTTP and WebSocket upgrade) BEFORE rewriting: reject
|
|
378
|
+
* `sec-fetch-site: cross-site`, reject any Origin that does not name the
|
|
379
|
+
* gateway authority the browser actually used, and require an Origin on
|
|
380
|
+
* state-changing methods and on every WebSocket upgrade.
|
|
381
|
+
* - Against a session-capable dsh base the Host/Origin rewrite alone would
|
|
382
|
+
* still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
|
|
383
|
+
* authority-bound session cookie). The gateway therefore relays one shared
|
|
384
|
+
* upstream session acquired through the launch-token exchange and replays it
|
|
385
|
+
* on every forwarded request. See `upstream-session.ts`.
|
|
386
|
+
* - Sessions carry a revocation epoch: a password change or secret rotation
|
|
387
|
+
* bumps the epoch, every previously issued cookie dies, and established
|
|
388
|
+
* WebSockets are torn down so the client re-authenticates.
|
|
324
389
|
*
|
|
325
390
|
* @module @riceawa/dsh-lan-gateway/gateway
|
|
326
391
|
*/
|
|
327
392
|
const DEFAULT_BODY_LIMIT_BYTES = 65536;
|
|
328
393
|
const LOGIN_ATTEMPTS_LIMIT = 5;
|
|
329
394
|
const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
|
|
395
|
+
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
396
|
+
const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
|
|
397
|
+
"GET",
|
|
398
|
+
"HEAD",
|
|
399
|
+
"OPTIONS"
|
|
400
|
+
]);
|
|
401
|
+
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
402
|
+
function isOwnedPath(pathname) {
|
|
403
|
+
return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
|
|
404
|
+
}
|
|
405
|
+
/** The pathname of a request URL (query string stripped, not decoded). */
|
|
406
|
+
function pathOf(url) {
|
|
407
|
+
const query = url.indexOf("?");
|
|
408
|
+
return query === -1 ? url : url.slice(0, query);
|
|
409
|
+
}
|
|
330
410
|
/**
|
|
331
411
|
* The running gateway: owns the HTTP server and the auth state needed per
|
|
332
412
|
* request. Created by the plugin on enable; torn down by the plugin on
|
|
@@ -338,6 +418,8 @@ var LanGateway = class {
|
|
|
338
418
|
loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
|
|
339
419
|
state;
|
|
340
420
|
disposed = false;
|
|
421
|
+
/** Established WebSockets (upgraded client sockets), torn down on session-epoch change. */
|
|
422
|
+
activeDuplexes = /* @__PURE__ */ new Set();
|
|
341
423
|
constructor(config, state) {
|
|
342
424
|
this.config = config;
|
|
343
425
|
this.state = state;
|
|
@@ -352,8 +434,9 @@ var LanGateway = class {
|
|
|
352
434
|
this.handleUpgrade(req, socket, head);
|
|
353
435
|
});
|
|
354
436
|
}
|
|
355
|
-
/** Replace the in-memory state
|
|
437
|
+
/** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
|
|
356
438
|
setState(state) {
|
|
439
|
+
if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
|
|
357
440
|
this.state = state;
|
|
358
441
|
}
|
|
359
442
|
/** Start listening; rejects if the port is already in use. */
|
|
@@ -372,17 +455,28 @@ var LanGateway = class {
|
|
|
372
455
|
this.server.listen(this.config.gatewayPort, "0.0.0.0");
|
|
373
456
|
});
|
|
374
457
|
}
|
|
375
|
-
/** Close the server and stop accepting connections. */
|
|
458
|
+
/** Close the server, drop upgraded sockets, and stop accepting connections. */
|
|
376
459
|
async close() {
|
|
377
460
|
if (this.disposed) return;
|
|
378
461
|
this.disposed = true;
|
|
462
|
+
this.destroyActiveDuplexes();
|
|
379
463
|
return new Promise((resolve) => {
|
|
380
464
|
this.server.close(() => resolve());
|
|
381
465
|
this.server.closeAllConnections();
|
|
382
466
|
});
|
|
383
467
|
}
|
|
384
|
-
|
|
385
|
-
|
|
468
|
+
destroyActiveDuplexes() {
|
|
469
|
+
for (const socket of this.activeDuplexes) socket.destroy();
|
|
470
|
+
this.activeDuplexes.clear();
|
|
471
|
+
}
|
|
472
|
+
trackDuplex(socket) {
|
|
473
|
+
this.activeDuplexes.add(socket);
|
|
474
|
+
socket.on("close", () => {
|
|
475
|
+
this.activeDuplexes.delete(socket);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
sourceOf(req) {
|
|
479
|
+
return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
|
|
386
480
|
}
|
|
387
481
|
/** Parse the session cookie out of a Cookie header. */
|
|
388
482
|
sessionCookie(req) {
|
|
@@ -393,10 +487,14 @@ var LanGateway = class {
|
|
|
393
487
|
if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
|
|
394
488
|
}
|
|
395
489
|
}
|
|
396
|
-
/** Whether a request carries a valid
|
|
490
|
+
/** Whether a request carries a session valid under the current epoch. */
|
|
397
491
|
authorized(req) {
|
|
398
492
|
const cookie = this.sessionCookie(req);
|
|
399
|
-
return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now());
|
|
493
|
+
return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
|
|
494
|
+
}
|
|
495
|
+
/** Whether this source must present a gateway session (default: everyone). */
|
|
496
|
+
requiresLogin(source) {
|
|
497
|
+
return !(this.config.lanPasswordless && source !== "internet");
|
|
400
498
|
}
|
|
401
499
|
serveUnauthorized(res, limited) {
|
|
402
500
|
res.writeHead(302, {
|
|
@@ -414,47 +512,58 @@ var LanGateway = class {
|
|
|
414
512
|
});
|
|
415
513
|
res.end(renderLoginPage(opts));
|
|
416
514
|
}
|
|
417
|
-
/** HSTS when the listener is HTTPS (never sent on plain HTTP). */
|
|
515
|
+
/** HSTS when the listener itself is HTTPS (never sent on plain HTTP). */
|
|
418
516
|
securityHeaders() {
|
|
419
517
|
return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
|
|
420
518
|
}
|
|
421
|
-
/**
|
|
519
|
+
/**
|
|
520
|
+
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades
|
|
521
|
+
* and applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
522
|
+
* state-changing requests and to every WebSocket handshake; reads without an
|
|
523
|
+
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
524
|
+
*/
|
|
525
|
+
sameSiteAllowed(req, upgrade) {
|
|
526
|
+
const headers = req.headers;
|
|
527
|
+
if (headers["sec-fetch-site"] === "cross-site") return false;
|
|
528
|
+
const origin = headers.origin;
|
|
529
|
+
const host = headers.host;
|
|
530
|
+
if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
|
|
531
|
+
if (upgrade) return origin !== void 0;
|
|
532
|
+
if (!READ_ONLY_METHODS$1.has(req.method ?? "GET")) return origin !== void 0;
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
sessionSetCookie(value, maxAgeSeconds) {
|
|
536
|
+
const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
|
|
537
|
+
return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
|
|
538
|
+
}
|
|
539
|
+
/** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
|
|
422
540
|
async handleHttp(req, res) {
|
|
423
|
-
const source = this.sourceClass(req);
|
|
424
541
|
const url = req.url ?? "/";
|
|
425
|
-
const pathname = url
|
|
542
|
+
const pathname = pathOf(url);
|
|
543
|
+
const source = this.sourceOf(req);
|
|
426
544
|
if (pathname === "/__login") {
|
|
427
545
|
this.handleLogin(req, res);
|
|
428
546
|
return;
|
|
429
547
|
}
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
548
|
+
if (pathname === "/__logout") {
|
|
549
|
+
this.handleLogout(req, res);
|
|
550
|
+
return;
|
|
435
551
|
}
|
|
436
|
-
if (pathname
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
552
|
+
if (isOwnedPath(pathname)) {
|
|
553
|
+
res.writeHead(403, this.securityHeaders());
|
|
554
|
+
res.end("forbidden");
|
|
555
|
+
return;
|
|
442
556
|
}
|
|
443
|
-
this.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
try {
|
|
452
|
-
const originHost = new URL(origin).host;
|
|
453
|
-
const requestHost = typeof headers.host === "string" ? headers.host : "";
|
|
454
|
-
return originHost === requestHost || originHost === stripDefaultPort(requestHost);
|
|
455
|
-
} catch {
|
|
456
|
-
return false;
|
|
557
|
+
if (this.requiresLogin(source) && !this.authorized(req)) {
|
|
558
|
+
this.serveUnauthorized(res, false);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (!this.sameSiteAllowed(req, false)) {
|
|
562
|
+
res.writeHead(403, this.securityHeaders());
|
|
563
|
+
res.end("forbidden");
|
|
564
|
+
return;
|
|
457
565
|
}
|
|
566
|
+
await this.relayHttp(req, res, url);
|
|
458
567
|
}
|
|
459
568
|
/** Handle the login GET form / POST submission. */
|
|
460
569
|
handleLogin(req, res) {
|
|
@@ -464,7 +573,7 @@ var LanGateway = class {
|
|
|
464
573
|
return;
|
|
465
574
|
}
|
|
466
575
|
if (req.method !== "POST") {
|
|
467
|
-
res.writeHead(405, { allow: "GET, POST" });
|
|
576
|
+
res.writeHead(405, { allow: "GET, HEAD, POST" });
|
|
468
577
|
res.end();
|
|
469
578
|
return;
|
|
470
579
|
}
|
|
@@ -485,24 +594,64 @@ var LanGateway = class {
|
|
|
485
594
|
this.serveLoginError(res, "Incorrect password.");
|
|
486
595
|
return;
|
|
487
596
|
}
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
const
|
|
597
|
+
const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
|
|
598
|
+
const expiresMs = Date.now() + maxAgeSeconds * 1e3;
|
|
599
|
+
const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
|
|
491
600
|
res.writeHead(302, {
|
|
492
601
|
location: "/",
|
|
493
602
|
...this.securityHeaders(),
|
|
494
|
-
"set-cookie": [
|
|
603
|
+
"set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
|
|
495
604
|
});
|
|
496
605
|
res.end();
|
|
497
606
|
});
|
|
498
607
|
}
|
|
499
|
-
/**
|
|
500
|
-
|
|
608
|
+
/** POST /__logout: sign an immediately-expired cookie and bounce to / . */
|
|
609
|
+
handleLogout(req, res) {
|
|
610
|
+
if (req.method !== "POST") {
|
|
611
|
+
res.writeHead(405, { allow: "POST" });
|
|
612
|
+
res.end();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!this.sameSiteAllowed(req, false)) {
|
|
616
|
+
res.writeHead(403, this.securityHeaders());
|
|
617
|
+
res.end("forbidden");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
res.writeHead(302, {
|
|
621
|
+
location: "/",
|
|
622
|
+
...this.securityHeaders(),
|
|
623
|
+
"set-cookie": [this.sessionSetCookie("", 0)]
|
|
624
|
+
});
|
|
625
|
+
res.end();
|
|
626
|
+
}
|
|
627
|
+
/** Build the outbound headers: rewrite Host/Origin to the loopback upstream. */
|
|
628
|
+
upstreamHeaders(req, keepUpgrade) {
|
|
501
629
|
const headers = { ...req.headers };
|
|
502
630
|
headers.host = `127.0.0.1:${this.config.dshPort}`;
|
|
503
631
|
if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
|
|
504
632
|
delete headers["proxy-connection"];
|
|
505
|
-
|
|
633
|
+
if (!keepUpgrade) {
|
|
634
|
+
delete headers.connection;
|
|
635
|
+
delete headers.upgrade;
|
|
636
|
+
}
|
|
637
|
+
return headers;
|
|
638
|
+
}
|
|
639
|
+
/** Attach the shared upstream session cookie to the outbound headers, if any. */
|
|
640
|
+
attachUpstreamSession(headers) {
|
|
641
|
+
const session = this.config.upstreamSession;
|
|
642
|
+
if (session === void 0) return false;
|
|
643
|
+
const cookie = session.peek();
|
|
644
|
+
if (cookie === void 0) return false;
|
|
645
|
+
const existing = headers.cookie;
|
|
646
|
+
headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
|
|
647
|
+
return true;
|
|
648
|
+
}
|
|
649
|
+
/** Forward an HTTP request to dsh, replaying the shared upstream session. */
|
|
650
|
+
async relayHttp(req, res, url) {
|
|
651
|
+
const session = this.config.upstreamSession;
|
|
652
|
+
if (session !== void 0) await session.cookie();
|
|
653
|
+
const headers = this.upstreamHeaders(req, false);
|
|
654
|
+
const attached = this.attachUpstreamSession(headers);
|
|
506
655
|
const proxyReq = http.request({
|
|
507
656
|
host: "127.0.0.1",
|
|
508
657
|
port: this.config.dshPort,
|
|
@@ -510,6 +659,7 @@ var LanGateway = class {
|
|
|
510
659
|
path: url,
|
|
511
660
|
headers
|
|
512
661
|
}, (proxyRes) => {
|
|
662
|
+
if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
|
|
513
663
|
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
514
664
|
proxyRes.pipe(res);
|
|
515
665
|
});
|
|
@@ -519,25 +669,40 @@ var LanGateway = class {
|
|
|
519
669
|
});
|
|
520
670
|
req.pipe(proxyReq);
|
|
521
671
|
}
|
|
522
|
-
/** Forward a WebSocket upgrade, splicing the
|
|
523
|
-
handleUpgrade(req, socket, head) {
|
|
524
|
-
|
|
525
|
-
|
|
672
|
+
/** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
|
|
673
|
+
async handleUpgrade(req, socket, head) {
|
|
674
|
+
const url = req.url ?? "/";
|
|
675
|
+
const pathname = pathOf(url);
|
|
676
|
+
const source = this.sourceOf(req);
|
|
677
|
+
const refuse = (status) => {
|
|
678
|
+
socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
|
|
526
679
|
socket.destroy();
|
|
680
|
+
};
|
|
681
|
+
if (pathname === "/__login" || pathname === "/__logout" || isOwnedPath(pathname)) {
|
|
682
|
+
refuse(403);
|
|
527
683
|
return;
|
|
528
684
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
685
|
+
if (this.requiresLogin(source) && !this.authorized(req)) {
|
|
686
|
+
refuse(401);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (!this.sameSiteAllowed(req, true)) {
|
|
690
|
+
refuse(403);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const session = this.config.upstreamSession;
|
|
694
|
+
if (session !== void 0) await session.cookie();
|
|
695
|
+
const headers = this.upstreamHeaders(req, true);
|
|
696
|
+
this.attachUpstreamSession(headers);
|
|
533
697
|
const proxyReq = http.request({
|
|
534
698
|
host: "127.0.0.1",
|
|
535
699
|
port: this.config.dshPort,
|
|
536
700
|
method: "GET",
|
|
537
|
-
path:
|
|
701
|
+
path: url,
|
|
538
702
|
headers
|
|
539
703
|
});
|
|
540
704
|
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
705
|
+
this.trackDuplex(socket);
|
|
541
706
|
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
|
|
542
707
|
const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
|
|
543
708
|
socket.write(`${statusLine}${headerLines}\r\n`);
|
|
@@ -551,12 +716,6 @@ var LanGateway = class {
|
|
|
551
716
|
proxyReq.end();
|
|
552
717
|
}
|
|
553
718
|
};
|
|
554
|
-
/** Strip an explicit default port from a Host authority, if present. */
|
|
555
|
-
function stripDefaultPort(host) {
|
|
556
|
-
const parsed = /^(.+?)(?::(\d+))?$/.exec(host);
|
|
557
|
-
if (parsed?.[2] === "80" || parsed?.[2] === "443") return parsed[1];
|
|
558
|
-
return host;
|
|
559
|
-
}
|
|
560
719
|
//#endregion
|
|
561
720
|
//#region src/x509.ts
|
|
562
721
|
/**
|
|
@@ -908,7 +1067,7 @@ const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
|
|
|
908
1067
|
function lanGatewayTool(control) {
|
|
909
1068
|
return defineTool({
|
|
910
1069
|
name: LAN_GATEWAY_TOOL_NAME,
|
|
911
|
-
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
|
|
1070
|
+
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.",
|
|
912
1071
|
parameters: {
|
|
913
1072
|
command: {
|
|
914
1073
|
type: "string",
|
|
@@ -920,7 +1079,7 @@ function lanGatewayTool(control) {
|
|
|
920
1079
|
"rotate-secret",
|
|
921
1080
|
"tls-regenerate"
|
|
922
1081
|
],
|
|
923
|
-
description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password. `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
|
|
1082
|
+
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."
|
|
924
1083
|
},
|
|
925
1084
|
password: {
|
|
926
1085
|
type: "string",
|
|
@@ -963,18 +1122,160 @@ function lanGatewayTool(control) {
|
|
|
963
1122
|
});
|
|
964
1123
|
}
|
|
965
1124
|
//#endregion
|
|
1125
|
+
//#region src/upstream-session.ts
|
|
1126
|
+
/**
|
|
1127
|
+
* Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
|
|
1128
|
+
*
|
|
1129
|
+
* When dsh added browser-session authentication it stopped trusting a loopback
|
|
1130
|
+
* Host header alone: every `/api` request (and the remote WebSocket mux) must
|
|
1131
|
+
* now present a signed cookie bound to the authority it names
|
|
1132
|
+
* (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
|
|
1133
|
+
* process launch token. A reverse proxy that rewrites Host to loopback — which
|
|
1134
|
+
* is what this gateway does — therefore gets a 401 no matter how the Host is
|
|
1135
|
+
* forged. The gateway cannot mint that cookie itself (the signing secret lives
|
|
1136
|
+
* in dsh's credential provider), so it does exactly what a browser does: on the
|
|
1137
|
+
* loopback transport it visits the launch-token URL, keeps the Set-Cookie it
|
|
1138
|
+
* earns, and replays that one shared session on every request it forwards.
|
|
1139
|
+
*
|
|
1140
|
+
* Semantics match the pre-existing "single password = single operator" model:
|
|
1141
|
+
* whoever passes the gateway's own login rides this one upstream session. It is
|
|
1142
|
+
* not multi-user authorization, and upstream (which holds the secret) remains
|
|
1143
|
+
* the actual authority over what the session may do.
|
|
1144
|
+
*
|
|
1145
|
+
* The relay is a no-op on a base without browser sessions: acquisition fails
|
|
1146
|
+
* and `cookie()` returns undefined, so the gateway simply forwards without a
|
|
1147
|
+
* session cookie exactly as it did against an older dsh.
|
|
1148
|
+
*
|
|
1149
|
+
* @module @riceawa/dsh-lan-gateway/upstream-session
|
|
1150
|
+
*/
|
|
1151
|
+
/** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
|
|
1152
|
+
const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
|
|
1153
|
+
/** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
|
|
1154
|
+
function nameValueOnly(setCookie) {
|
|
1155
|
+
const semi = setCookie.indexOf(";");
|
|
1156
|
+
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
1157
|
+
}
|
|
1158
|
+
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
1159
|
+
function maxAgeSeconds(setCookie) {
|
|
1160
|
+
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
1161
|
+
return match === null ? void 0 : Number(match[1]);
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Perform the token exchange over loopback: GET the launch-token URL with the
|
|
1165
|
+
* upstream authority as Host, read the Set-Cookie the index route mints, and
|
|
1166
|
+
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
1167
|
+
* or no session cookie came back — e.g. an older base without browser
|
|
1168
|
+
* sessions).
|
|
1169
|
+
*/
|
|
1170
|
+
function exchange(url, authority, port) {
|
|
1171
|
+
return new Promise((resolve) => {
|
|
1172
|
+
let target;
|
|
1173
|
+
try {
|
|
1174
|
+
target = new URL(url);
|
|
1175
|
+
} catch {
|
|
1176
|
+
resolve(void 0);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const request = http.request({
|
|
1180
|
+
host: "127.0.0.1",
|
|
1181
|
+
port,
|
|
1182
|
+
method: "GET",
|
|
1183
|
+
path: `${target.pathname}${target.search}`,
|
|
1184
|
+
headers: {
|
|
1185
|
+
host: authority,
|
|
1186
|
+
accept: "text/html"
|
|
1187
|
+
}
|
|
1188
|
+
}, (response) => {
|
|
1189
|
+
const setCookies = response.headers["set-cookie"];
|
|
1190
|
+
response.resume();
|
|
1191
|
+
if (setCookies === void 0) {
|
|
1192
|
+
resolve(void 0);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`));
|
|
1196
|
+
if (raw === void 0) {
|
|
1197
|
+
resolve(void 0);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const header = nameValueOnly(raw);
|
|
1201
|
+
const maxAge = maxAgeSeconds(raw);
|
|
1202
|
+
resolve({
|
|
1203
|
+
header,
|
|
1204
|
+
expiresAt: Date.now() + (maxAge ?? 0) * 1e3
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
request.on("error", () => resolve(void 0));
|
|
1208
|
+
request.setTimeout(5e3, () => request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout")));
|
|
1209
|
+
request.end();
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* A cached {@link UpstreamSession} acquired through the launch-token exchange.
|
|
1214
|
+
* Acquisition runs at most once concurrently and the result is cached until it
|
|
1215
|
+
* nears expiry or {@link invalidate} is called.
|
|
1216
|
+
*/
|
|
1217
|
+
var UpstreamSessionRelay = class {
|
|
1218
|
+
port;
|
|
1219
|
+
authority;
|
|
1220
|
+
authenticatedUrl;
|
|
1221
|
+
held;
|
|
1222
|
+
inflight;
|
|
1223
|
+
constructor(options) {
|
|
1224
|
+
this.port = options.port;
|
|
1225
|
+
this.authority = options.authority ?? `127.0.0.1:${options.port}`;
|
|
1226
|
+
this.authenticatedUrl = options.authenticatedUrl;
|
|
1227
|
+
}
|
|
1228
|
+
/** Whether the held session is still comfortably inside its lifetime. */
|
|
1229
|
+
fresh() {
|
|
1230
|
+
const held = this.held;
|
|
1231
|
+
if (held === void 0) return false;
|
|
1232
|
+
return Date.now() < held.expiresAt - 6e4;
|
|
1233
|
+
}
|
|
1234
|
+
peek() {
|
|
1235
|
+
return this.held?.header;
|
|
1236
|
+
}
|
|
1237
|
+
invalidate() {
|
|
1238
|
+
this.held = void 0;
|
|
1239
|
+
}
|
|
1240
|
+
async cookie() {
|
|
1241
|
+
if (this.fresh()) return this.held?.header;
|
|
1242
|
+
return this.acquire();
|
|
1243
|
+
}
|
|
1244
|
+
acquire() {
|
|
1245
|
+
if (this.inflight !== void 0) return this.inflight;
|
|
1246
|
+
const pending = this.doExchange().finally(() => {
|
|
1247
|
+
this.inflight = void 0;
|
|
1248
|
+
});
|
|
1249
|
+
this.inflight = pending;
|
|
1250
|
+
return pending;
|
|
1251
|
+
}
|
|
1252
|
+
async doExchange() {
|
|
1253
|
+
const url = this.authenticatedUrl();
|
|
1254
|
+
if (url === void 0) return void 0;
|
|
1255
|
+
const result = await exchange(url, this.authority, this.port);
|
|
1256
|
+
if (result !== void 0) this.held = result;
|
|
1257
|
+
return this.held?.header;
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
//#endregion
|
|
966
1261
|
//#region src/index.ts
|
|
967
1262
|
/** Stable Cordis plugin name. */
|
|
968
1263
|
const name = "dsh-lan-gateway";
|
|
969
1264
|
/** Requires the web server service (binds before this row's apply runs) and the tool registry. */
|
|
970
1265
|
const inject = ["webServer", "tools"];
|
|
971
|
-
/**
|
|
972
|
-
|
|
1266
|
+
/**
|
|
1267
|
+
* The `lan-gateway` user-settings namespace, mirroring the composition schema.
|
|
1268
|
+
* A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
|
|
1269
|
+
* helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
|
|
1270
|
+
* shape works against both that release line and the older branded one.
|
|
1271
|
+
*/
|
|
1272
|
+
const NS = "lan-gateway";
|
|
973
1273
|
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
974
1274
|
const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
975
1275
|
"dshTargetPort",
|
|
976
1276
|
"tlsCertPath",
|
|
977
|
-
"tlsKeyPath"
|
|
1277
|
+
"tlsKeyPath",
|
|
1278
|
+
"trustedTerminator"
|
|
978
1279
|
]);
|
|
979
1280
|
/** Schemastery configuration validated by the Loader. */
|
|
980
1281
|
const Config = z.object({
|
|
@@ -982,6 +1283,7 @@ const Config = z.object({
|
|
|
982
1283
|
gatewayPort: z.natural().min(1).max(65535).default(3081),
|
|
983
1284
|
dshTargetPort: z.natural().min(1).max(65535),
|
|
984
1285
|
lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
|
|
1286
|
+
lanPasswordless: z.boolean().default(false),
|
|
985
1287
|
authRequired: z.boolean().default(true),
|
|
986
1288
|
cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
|
|
987
1289
|
cookieName: z.string().default("dsh_gw_auth"),
|
|
@@ -990,8 +1292,22 @@ const Config = z.object({
|
|
|
990
1292
|
tlsCertPath: z.string(),
|
|
991
1293
|
tlsKeyPath: z.string(),
|
|
992
1294
|
tlsSelfSignedHosts: z.string().default("localhost"),
|
|
993
|
-
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825)
|
|
1295
|
+
tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
|
|
1296
|
+
allowInsecurePlaintext: z.boolean().default(false),
|
|
1297
|
+
trustedTerminator: z.string()
|
|
994
1298
|
});
|
|
1299
|
+
/**
|
|
1300
|
+
* The fail-closed problems that prevent a config from enabling the listener.
|
|
1301
|
+
* Returns every problem (not just the first) so the operator sees the full
|
|
1302
|
+
* migration at once. Exported for tests.
|
|
1303
|
+
*/
|
|
1304
|
+
function gatewayStartProblems(cfg, facts) {
|
|
1305
|
+
const problems = [];
|
|
1306
|
+
if (cfg.authRequired === false) problems.push("authRequired=false is no longer supported — authentication is always required. Remove `authRequired` (or set it true); for password-free LAN access set `lanPasswordless: true`.");
|
|
1307
|
+
if (cfg.lanPasswordless && !facts.upstreamSessionAvailable) problems.push("lanPasswordless requires a dsh base with browser-session auth (>= 0.1.2-rc.1): the gateway relaxes only its own login, never dsh authorization. Upgrade dsh, or set lanPasswordless: false.");
|
|
1308
|
+
if (!(cfg.tlsEnabled || cfg.trustedTerminator !== void 0) && !cfg.allowInsecurePlaintext) problems.push("Refusing to serve over plaintext HTTP: enable TLS (tlsEnabled: true), declare a trusted TLS-terminating proxy (trustedTerminator), or set allowInsecurePlaintext: true to accept the plaintext exposure (passwords and sessions would travel in clear).");
|
|
1309
|
+
return problems;
|
|
1310
|
+
}
|
|
995
1311
|
/** Resolve the TLS material for a config, or undefined when TLS is off. */
|
|
996
1312
|
function resolveTls(cfg) {
|
|
997
1313
|
if (!cfg.tlsEnabled) return void 0;
|
|
@@ -1010,7 +1326,7 @@ function listenerKey(cfg) {
|
|
|
1010
1326
|
cfg.gatewayPort,
|
|
1011
1327
|
cfg.dshTargetPort,
|
|
1012
1328
|
cfg.lanCidrs,
|
|
1013
|
-
cfg.
|
|
1329
|
+
cfg.lanPasswordless,
|
|
1014
1330
|
cfg.cookieMaxAgeDays,
|
|
1015
1331
|
cfg.cookieName,
|
|
1016
1332
|
cfg.tlsEnabled,
|
|
@@ -1018,7 +1334,9 @@ function listenerKey(cfg) {
|
|
|
1018
1334
|
cfg.tlsCertPath,
|
|
1019
1335
|
cfg.tlsKeyPath,
|
|
1020
1336
|
cfg.tlsSelfSignedHosts,
|
|
1021
|
-
cfg.tlsCertMaxAgeDays
|
|
1337
|
+
cfg.tlsCertMaxAgeDays,
|
|
1338
|
+
cfg.allowInsecurePlaintext,
|
|
1339
|
+
cfg.trustedTerminator
|
|
1022
1340
|
]);
|
|
1023
1341
|
}
|
|
1024
1342
|
/** One-line TLS description for status output. */
|
|
@@ -1042,13 +1360,20 @@ function isLoopbackHost(hostname) {
|
|
|
1042
1360
|
const parts = hostname.split(".");
|
|
1043
1361
|
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1044
1362
|
}
|
|
1363
|
+
const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1364
|
+
"GET",
|
|
1365
|
+
"HEAD",
|
|
1366
|
+
"OPTIONS"
|
|
1367
|
+
]);
|
|
1045
1368
|
/**
|
|
1046
|
-
* Same-origin loopback fence for the config route
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1369
|
+
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
1370
|
+
* gateway refuses to relay this prefix, so the only way in is the native
|
|
1371
|
+
* loopback listener itself (a genuine local user, or a local process that could
|
|
1372
|
+
* already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
|
|
1373
|
+
* cross-site fetches are refused, an Origin must match the Host the browser
|
|
1374
|
+
* used, and a state-changing method must carry that Origin. Exported for tests.
|
|
1050
1375
|
*/
|
|
1051
|
-
function
|
|
1376
|
+
function isTrustedConfigRequest(req) {
|
|
1052
1377
|
const host = req.headers?.host;
|
|
1053
1378
|
if (typeof host !== "string" || host === "") return false;
|
|
1054
1379
|
let hostUrl;
|
|
@@ -1060,12 +1385,10 @@ function isTrustedRequest(req) {
|
|
|
1060
1385
|
if (!isLoopbackHost(hostUrl.hostname)) return false;
|
|
1061
1386
|
if (req.headers?.["sec-fetch-site"] === "cross-site") return false;
|
|
1062
1387
|
const origin = req.headers?.origin;
|
|
1063
|
-
if (origin
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
return false;
|
|
1068
|
-
}
|
|
1388
|
+
if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
|
|
1389
|
+
const method = req.method ?? "GET";
|
|
1390
|
+
if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
|
|
1391
|
+
return true;
|
|
1069
1392
|
}
|
|
1070
1393
|
function apply(ctx, config) {
|
|
1071
1394
|
let state = loadState();
|
|
@@ -1073,6 +1396,10 @@ function apply(ctx, config) {
|
|
|
1073
1396
|
let startedWith;
|
|
1074
1397
|
let lastError;
|
|
1075
1398
|
let manualOverride;
|
|
1399
|
+
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
1400
|
+
let upstreamSessionAvailable = false;
|
|
1401
|
+
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
1402
|
+
let makeRelay;
|
|
1076
1403
|
/** The authoritative config: settings section when attached, else composition. */
|
|
1077
1404
|
let configSource = () => config;
|
|
1078
1405
|
/** Serializes listener start/stop/restart so settings changes cannot race. */
|
|
@@ -1080,22 +1407,27 @@ function apply(ctx, config) {
|
|
|
1080
1407
|
const effective = () => configSource();
|
|
1081
1408
|
const startGateway = async (cfg) => {
|
|
1082
1409
|
if (gateway !== void 0) return;
|
|
1083
|
-
|
|
1410
|
+
const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
|
|
1411
|
+
if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
|
|
1412
|
+
if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
|
|
1084
1413
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1085
1414
|
const tls = resolveTls(cfg);
|
|
1415
|
+
const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1086
1416
|
const next = new LanGateway({
|
|
1087
1417
|
gatewayPort: cfg.gatewayPort,
|
|
1088
1418
|
dshPort,
|
|
1089
1419
|
lanCidrs: cfg.lanCidrs,
|
|
1090
|
-
|
|
1420
|
+
lanPasswordless: cfg.lanPasswordless,
|
|
1091
1421
|
cookieMaxAgeDays: cfg.cookieMaxAgeDays,
|
|
1092
1422
|
cookieName: cfg.cookieName,
|
|
1093
|
-
|
|
1423
|
+
secureCookies: encryptedIngress,
|
|
1424
|
+
...tls !== void 0 ? { tls } : {},
|
|
1425
|
+
...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
|
|
1094
1426
|
}, state);
|
|
1095
1427
|
await next.listen();
|
|
1096
1428
|
gateway = next;
|
|
1097
1429
|
startedWith = listenerKey(cfg);
|
|
1098
|
-
ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}`);
|
|
1430
|
+
ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${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]"}`);
|
|
1099
1431
|
};
|
|
1100
1432
|
const stopGateway = async () => {
|
|
1101
1433
|
const current = gateway;
|
|
@@ -1141,13 +1473,21 @@ function apply(ctx, config) {
|
|
|
1141
1473
|
});
|
|
1142
1474
|
syncGateway("settings attach");
|
|
1143
1475
|
});
|
|
1476
|
+
ctx.inject(["connection"], (ccx) => {
|
|
1477
|
+
upstreamSessionAvailable = true;
|
|
1478
|
+
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
1479
|
+
port: dshPort,
|
|
1480
|
+
authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`)
|
|
1481
|
+
});
|
|
1482
|
+
syncGateway("connection attach");
|
|
1483
|
+
});
|
|
1144
1484
|
const configRouteHandler = async (req, res) => {
|
|
1145
1485
|
const send = (status, body) => {
|
|
1146
1486
|
res.writeHead(status, { "content-type": "application/json" });
|
|
1147
1487
|
res.end(JSON.stringify(body));
|
|
1148
1488
|
};
|
|
1149
|
-
if (!
|
|
1150
|
-
send(403, { error: "request refused: this route answers
|
|
1489
|
+
if (!isTrustedConfigRequest(req)) {
|
|
1490
|
+
send(403, { error: "request refused: this route answers same-origin loopback requests only" });
|
|
1151
1491
|
return;
|
|
1152
1492
|
}
|
|
1153
1493
|
if (req.method === "GET") {
|
|
@@ -1157,6 +1497,7 @@ function apply(ctx, config) {
|
|
|
1157
1497
|
running: gateway !== void 0,
|
|
1158
1498
|
port: cfg.gatewayPort,
|
|
1159
1499
|
tls: tlsStatusLine(cfg),
|
|
1500
|
+
upstreamSessionAvailable,
|
|
1160
1501
|
lastError: lastError ?? null
|
|
1161
1502
|
});
|
|
1162
1503
|
return;
|
|
@@ -1189,6 +1530,12 @@ function apply(ctx, config) {
|
|
|
1189
1530
|
send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
|
|
1190
1531
|
return;
|
|
1191
1532
|
}
|
|
1533
|
+
const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
|
|
1534
|
+
const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
|
|
1535
|
+
if (structural || candidate.enabled && problems.length > 0) {
|
|
1536
|
+
send(409, { error: `config cannot start: ${problems.join(" ")}` });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1192
1539
|
const section = {};
|
|
1193
1540
|
for (const [key, value] of Object.entries(candidate)) {
|
|
1194
1541
|
if (value === null || value === void 0) continue;
|
|
@@ -1204,6 +1551,7 @@ function apply(ctx, config) {
|
|
|
1204
1551
|
running: gateway !== void 0,
|
|
1205
1552
|
port: cfg.gatewayPort,
|
|
1206
1553
|
tls: tlsStatusLine(cfg),
|
|
1554
|
+
upstreamSessionAvailable,
|
|
1207
1555
|
lastError: lastError ?? null
|
|
1208
1556
|
});
|
|
1209
1557
|
} catch (error) {
|
|
@@ -1219,9 +1567,10 @@ function apply(ctx, config) {
|
|
|
1219
1567
|
status() {
|
|
1220
1568
|
const cfg = effective();
|
|
1221
1569
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1570
|
+
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1222
1571
|
return {
|
|
1223
1572
|
ok: true,
|
|
1224
|
-
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n-
|
|
1573
|
+
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : "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- 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 ? `TLS terminated by trusted proxy (${cfg.trustedTerminator})` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
|
|
1225
1574
|
};
|
|
1226
1575
|
},
|
|
1227
1576
|
async enable() {
|
|
@@ -1243,29 +1592,46 @@ function apply(ctx, config) {
|
|
|
1243
1592
|
message: "Gateway disabled."
|
|
1244
1593
|
};
|
|
1245
1594
|
},
|
|
1246
|
-
setPassword(password) {
|
|
1595
|
+
async setPassword(password) {
|
|
1247
1596
|
if (password !== void 0 && password.length > 0 && password.length < 8) return {
|
|
1248
1597
|
ok: false,
|
|
1249
1598
|
message: "Password must be at least 8 characters."
|
|
1250
1599
|
};
|
|
1251
1600
|
const setting = password !== void 0 && password.length > 0;
|
|
1601
|
+
const previous = state;
|
|
1252
1602
|
state = setPassword(state, setting ? password : void 0);
|
|
1253
1603
|
saveState(state);
|
|
1254
1604
|
gateway?.setState(state);
|
|
1605
|
+
if (!setting) {
|
|
1606
|
+
manualOverride = false;
|
|
1607
|
+
if (gateway !== void 0) {
|
|
1608
|
+
await stopGateway();
|
|
1609
|
+
lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
|
|
1610
|
+
syncGateway("password cleared");
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
ok: true,
|
|
1614
|
+
message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
previous === void 0 ? syncGateway("password set") : Promise.resolve();
|
|
1255
1618
|
return {
|
|
1256
1619
|
ok: true,
|
|
1257
|
-
message:
|
|
1620
|
+
message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
|
|
1258
1621
|
};
|
|
1259
1622
|
},
|
|
1260
1623
|
rotateSecret() {
|
|
1261
|
-
const next = {
|
|
1624
|
+
const next = {
|
|
1625
|
+
cookieSecret: randomBytes(32).toString("base64"),
|
|
1626
|
+
sessionEpoch: state.sessionEpoch + 1
|
|
1627
|
+
};
|
|
1262
1628
|
if (state.password !== void 0) next.password = state.password;
|
|
1263
1629
|
state = next;
|
|
1264
1630
|
saveState(state);
|
|
1265
1631
|
gateway?.setState(state);
|
|
1266
1632
|
return {
|
|
1267
1633
|
ok: true,
|
|
1268
|
-
message: "Session secret rotated. All existing login cookies are now invalid."
|
|
1634
|
+
message: "Session secret rotated and epoch advanced. All existing login cookies and live WebSockets are now invalid."
|
|
1269
1635
|
};
|
|
1270
1636
|
},
|
|
1271
1637
|
async regenerateTls() {
|
|
@@ -1301,10 +1667,10 @@ function apply(ctx, config) {
|
|
|
1301
1667
|
}
|
|
1302
1668
|
}
|
|
1303
1669
|
}));
|
|
1304
|
-
ctx.effect(
|
|
1305
|
-
|
|
1670
|
+
ctx.effect(() => {
|
|
1671
|
+
syncGateway("boot");
|
|
1306
1672
|
return stopGateway;
|
|
1307
1673
|
}, "dsh-lan-gateway: listener lifecycle");
|
|
1308
1674
|
}
|
|
1309
1675
|
//#endregion
|
|
1310
|
-
export { Config, apply, inject, name };
|
|
1676
|
+
export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name };
|