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