@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5
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 +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes,
|
|
1
|
+
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import http from "node:http";
|
|
4
4
|
import https from "node:https";
|
|
@@ -56,6 +56,13 @@ function normalizeAddress(raw) {
|
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Classify a source address string into one of the three trust tiers.
|
|
59
|
+
*
|
|
60
|
+
* The input is a *socket* address — `req.socket.remoteAddress`, unwrapped from
|
|
61
|
+
* its `::ffff:` mapping — which is a different domain from the URL hostname
|
|
62
|
+
* `isLoopbackHost` in `request-policy.ts` judges. The two agree on the common
|
|
63
|
+
* inputs but are not interchangeable: this one never sees `[::1]`, and that one
|
|
64
|
+
* never sees a mapped form. Both spans are documented where each lives.
|
|
65
|
+
*
|
|
59
66
|
* @param remoteAddress - the raw value of `req.socket.remoteAddress`.
|
|
60
67
|
* @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
|
|
61
68
|
* @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
|
|
@@ -73,9 +80,19 @@ function classifySource(remoteAddress, lanCidrs = DEFAULT_LAN_CIDR_STRINGS) {
|
|
|
73
80
|
return "internet";
|
|
74
81
|
}
|
|
75
82
|
if (address === "::1") return "loopback";
|
|
76
|
-
if (address
|
|
83
|
+
if (inIpv6LinkLocal(address)) return "lan";
|
|
77
84
|
return "internet";
|
|
78
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
|
|
88
|
+
* `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
|
|
89
|
+
* test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
|
|
90
|
+
*/
|
|
91
|
+
function inIpv6LinkLocal(address) {
|
|
92
|
+
const match = /^([0-9a-fA-F]{1,4}):/.exec(address);
|
|
93
|
+
if (match === null) return false;
|
|
94
|
+
return (Number.parseInt(match[1], 16) & 65472) === 65152;
|
|
95
|
+
}
|
|
79
96
|
/** Encode a byte buffer as URL-safe base64 without padding. */
|
|
80
97
|
function base64url(input) {
|
|
81
98
|
return input.toString("base64url");
|
|
@@ -88,24 +105,29 @@ function base64url(input) {
|
|
|
88
105
|
* cookie whose epoch no longer matches the live state is rejected by
|
|
89
106
|
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
90
107
|
* do not participate in revocation.
|
|
108
|
+
* @param sid - optional per-session id (see {@link SessionClaims.sid}).
|
|
91
109
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
92
110
|
*/
|
|
93
|
-
function signCookie(secret, expiresMs, epoch = 0) {
|
|
94
|
-
const
|
|
111
|
+
function signCookie(secret, expiresMs, epoch = 0, sid) {
|
|
112
|
+
const claims = sid === void 0 ? {
|
|
95
113
|
exp: expiresMs,
|
|
96
114
|
epoch
|
|
97
|
-
}
|
|
115
|
+
} : {
|
|
116
|
+
exp: expiresMs,
|
|
117
|
+
epoch,
|
|
118
|
+
sid
|
|
119
|
+
};
|
|
120
|
+
const payload = base64url(Buffer.from(JSON.stringify(claims)));
|
|
98
121
|
return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
|
|
99
122
|
}
|
|
100
123
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
124
|
+
* Verify a cookie's signature, expiry and epoch.
|
|
125
|
+
* @returns the claims it carries, or undefined when it is not a valid session.
|
|
104
126
|
*/
|
|
105
|
-
function
|
|
106
|
-
if (value === void 0) return
|
|
127
|
+
function verifySession(secret, value, now, epoch = 0) {
|
|
128
|
+
if (value === void 0) return void 0;
|
|
107
129
|
const dot = value.indexOf(".");
|
|
108
|
-
if (dot === -1) return
|
|
130
|
+
if (dot === -1) return void 0;
|
|
109
131
|
const payload = value.slice(0, dot);
|
|
110
132
|
const sig = value.slice(dot + 1);
|
|
111
133
|
const expected = createHmac("sha256", secret).update(payload).digest();
|
|
@@ -113,16 +135,22 @@ function verifyCookie(secret, value, now, epoch = 0) {
|
|
|
113
135
|
try {
|
|
114
136
|
actual = Buffer.from(sig, "base64url");
|
|
115
137
|
} catch {
|
|
116
|
-
return
|
|
138
|
+
return;
|
|
117
139
|
}
|
|
118
|
-
if (expected.length !== actual.length) return
|
|
119
|
-
if (!timingSafeEqual(expected, actual)) return
|
|
140
|
+
if (expected.length !== actual.length) return void 0;
|
|
141
|
+
if (!timingSafeEqual(expected, actual)) return void 0;
|
|
120
142
|
try {
|
|
121
143
|
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
122
|
-
if (typeof decoded.exp !== "number" || decoded.exp <= now) return
|
|
123
|
-
|
|
144
|
+
if (typeof decoded.exp !== "number" || decoded.exp <= now) return void 0;
|
|
145
|
+
const cookieEpoch = typeof decoded.epoch === "number" ? decoded.epoch : 0;
|
|
146
|
+
if (cookieEpoch !== epoch) return void 0;
|
|
147
|
+
return {
|
|
148
|
+
exp: decoded.exp,
|
|
149
|
+
epoch: cookieEpoch,
|
|
150
|
+
...typeof decoded.sid === "string" ? { sid: decoded.sid } : {}
|
|
151
|
+
};
|
|
124
152
|
} catch {
|
|
125
|
-
return
|
|
153
|
+
return;
|
|
126
154
|
}
|
|
127
155
|
}
|
|
128
156
|
/**
|
|
@@ -143,10 +171,18 @@ function originMatchesHost(origin, host) {
|
|
|
143
171
|
}
|
|
144
172
|
}
|
|
145
173
|
/** A token bucket limiter keyed by source address. */
|
|
146
|
-
var RateLimiter = class {
|
|
174
|
+
var RateLimiter = class RateLimiter {
|
|
147
175
|
maxTokens;
|
|
148
176
|
windowMs;
|
|
177
|
+
/**
|
|
178
|
+
* Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
|
|
179
|
+
* `prune` runs, and a spray from many distinct addresses inside one window
|
|
180
|
+
* outruns it, so the map also sheds its soonest-expiring entries past this.
|
|
181
|
+
*/
|
|
182
|
+
static MAX_BUCKETS = 1e4;
|
|
149
183
|
buckets = /* @__PURE__ */ new Map();
|
|
184
|
+
/** Epoch millis at which the next opportunistic sweep is due. */
|
|
185
|
+
nextPruneAt = 0;
|
|
150
186
|
constructor(maxTokens, windowMs) {
|
|
151
187
|
this.maxTokens = maxTokens;
|
|
152
188
|
this.windowMs = windowMs;
|
|
@@ -158,26 +194,114 @@ var RateLimiter = class {
|
|
|
158
194
|
*/
|
|
159
195
|
allow(key) {
|
|
160
196
|
const now = Date.now();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.
|
|
164
|
-
tokens: this.maxTokens - 1,
|
|
165
|
-
resetAt: now + this.windowMs
|
|
166
|
-
});
|
|
167
|
-
return true;
|
|
197
|
+
if (now >= this.nextPruneAt) {
|
|
198
|
+
this.prune(now);
|
|
199
|
+
this.nextPruneAt = now + this.windowMs;
|
|
168
200
|
}
|
|
169
|
-
|
|
170
|
-
|
|
201
|
+
const existing = this.buckets.get(key);
|
|
202
|
+
if (existing !== void 0 && existing.resetAt > now) {
|
|
203
|
+
if (existing.tokens <= 0) return false;
|
|
204
|
+
existing.tokens -= 1;
|
|
171
205
|
return true;
|
|
172
206
|
}
|
|
173
|
-
|
|
207
|
+
if (existing === void 0 && this.buckets.size >= RateLimiter.MAX_BUCKETS) this.evictSoonestToExpire();
|
|
208
|
+
this.buckets.set(key, {
|
|
209
|
+
tokens: this.maxTokens - 1,
|
|
210
|
+
resetAt: now + this.windowMs
|
|
211
|
+
});
|
|
212
|
+
return true;
|
|
174
213
|
}
|
|
175
|
-
/** Drop expired buckets to bound memory. */
|
|
214
|
+
/** Drop expired buckets to bound memory. Called from {@link allow}. */
|
|
176
215
|
prune(now = Date.now()) {
|
|
177
216
|
for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
|
|
178
217
|
}
|
|
218
|
+
/** Trim back to 90% of the ceiling, oldest expiry first. */
|
|
219
|
+
evictSoonestToExpire() {
|
|
220
|
+
const target = Math.floor(RateLimiter.MAX_BUCKETS * .9);
|
|
221
|
+
const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
222
|
+
for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) this.buckets.delete(key);
|
|
223
|
+
}
|
|
179
224
|
};
|
|
180
225
|
//#endregion
|
|
226
|
+
//#region src/config-fields.ts
|
|
227
|
+
/**
|
|
228
|
+
* The editable settings, in display order. Adding a config key means adding it
|
|
229
|
+
* here (the host whitelist and the card's controls both follow), to the
|
|
230
|
+
* `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
|
|
231
|
+
* behavior.
|
|
232
|
+
*/
|
|
233
|
+
const FIELDS = [
|
|
234
|
+
{
|
|
235
|
+
field: "enabled",
|
|
236
|
+
kind: "boolean"
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
field: "gatewayPort",
|
|
240
|
+
kind: "number"
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
field: "dshTargetPort",
|
|
244
|
+
kind: "number",
|
|
245
|
+
optional: true
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
field: "lanCidrs",
|
|
249
|
+
kind: "cidrs"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
field: "lanPasswordless",
|
|
253
|
+
kind: "boolean"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
field: "cookieMaxAgeDays",
|
|
257
|
+
kind: "number"
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
field: "tlsEnabled",
|
|
261
|
+
kind: "boolean"
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
field: "tlsMode",
|
|
265
|
+
kind: "select",
|
|
266
|
+
options: ["self-signed", "custom"]
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
field: "tlsSelfSignedHosts",
|
|
270
|
+
kind: "text"
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
field: "tlsCertPath",
|
|
274
|
+
kind: "text",
|
|
275
|
+
optional: true
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
field: "tlsKeyPath",
|
|
279
|
+
kind: "text",
|
|
280
|
+
optional: true
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
field: "tlsCertMaxAgeDays",
|
|
284
|
+
kind: "number"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
field: "allowInsecurePlaintext",
|
|
288
|
+
kind: "boolean"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
field: "trustedTerminator",
|
|
292
|
+
kind: "text",
|
|
293
|
+
optional: true
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
field: "secureCookies",
|
|
297
|
+
kind: "tristate"
|
|
298
|
+
}
|
|
299
|
+
];
|
|
300
|
+
/** Every settings key the config route accepts; anything else is ignored. */
|
|
301
|
+
const CONFIG_FIELD_KEYS = new Set(FIELDS.map((def) => def.field));
|
|
302
|
+
/** Keys an empty submitted value clears back to the composition layer. */
|
|
303
|
+
const OPTIONAL_CONFIG_KEYS = new Set(FIELDS.filter((def) => def.optional === true).map((def) => def.field));
|
|
304
|
+
//#endregion
|
|
181
305
|
//#region src/login.ts
|
|
182
306
|
/** Path the gateway owns and never forwards. */
|
|
183
307
|
const LOGIN_PATH = "/__login";
|
|
@@ -268,6 +392,450 @@ function readBody(req, maxBytes, res) {
|
|
|
268
392
|
});
|
|
269
393
|
}
|
|
270
394
|
//#endregion
|
|
395
|
+
//#region src/upstream-session.ts
|
|
396
|
+
/**
|
|
397
|
+
* Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
|
|
398
|
+
*
|
|
399
|
+
* When dsh added browser-session authentication it stopped trusting a loopback
|
|
400
|
+
* Host header alone: every `/api` request (and the remote WebSocket mux) must
|
|
401
|
+
* now present a signed cookie bound to the authority it names
|
|
402
|
+
* (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
|
|
403
|
+
* process launch token. A reverse proxy that rewrites Host to loopback — which
|
|
404
|
+
* is what this gateway does — therefore gets a 401 no matter how the Host is
|
|
405
|
+
* forged. The gateway cannot mint that cookie itself (the signing secret lives
|
|
406
|
+
* in dsh's credential provider), so it does exactly what a browser does: on the
|
|
407
|
+
* loopback transport it visits the launch-token URL, keeps the Set-Cookie it
|
|
408
|
+
* earns, and replays that one shared session on every request it forwards.
|
|
409
|
+
*
|
|
410
|
+
* Semantics match the pre-existing "single password = single operator" model:
|
|
411
|
+
* whoever passes the gateway's own login rides this one upstream session. It is
|
|
412
|
+
* not multi-user authorization, and upstream (which holds the secret) remains
|
|
413
|
+
* the actual authority over what the session may do.
|
|
414
|
+
*
|
|
415
|
+
* The relay is a no-op on a base without browser sessions: acquisition fails
|
|
416
|
+
* and `cookie()` returns undefined, so the gateway simply forwards without a
|
|
417
|
+
* session cookie exactly as it did against an older dsh.
|
|
418
|
+
*
|
|
419
|
+
* @module @riceawa/dsh-lan-gateway/upstream-session
|
|
420
|
+
*/
|
|
421
|
+
/** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
|
|
422
|
+
const UPSTREAM_COOKIE_PREFIX = "dsh-auth-";
|
|
423
|
+
/** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
|
|
424
|
+
function nameValueOnly(setCookie) {
|
|
425
|
+
const semi = setCookie.indexOf(";");
|
|
426
|
+
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The pathname of a URL, for logging. Never the whole URL: the authenticated
|
|
430
|
+
* URL carries the launch token as a query parameter, and that token is a
|
|
431
|
+
* bearer credential for the upstream harness.
|
|
432
|
+
*/
|
|
433
|
+
function pathOf$1(url) {
|
|
434
|
+
try {
|
|
435
|
+
return new URL(url).pathname;
|
|
436
|
+
} catch {
|
|
437
|
+
return "<unparseable>";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
441
|
+
function cookieNameOf(setCookie) {
|
|
442
|
+
const eq = setCookie.indexOf("=");
|
|
443
|
+
return eq === -1 ? "" : setCookie.slice(0, eq).trim();
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Whether one `name=value` fragment of a request `Cookie` header names the
|
|
447
|
+
* upstream session namespace, and so must be dropped before the relay's own
|
|
448
|
+
* copy is appended. This is the *filter* rule: it matches the whole reserved
|
|
449
|
+
* namespace, name only, whether or not the pair is a well-formed session.
|
|
450
|
+
*/
|
|
451
|
+
function isUpstreamCookiePair(pair) {
|
|
452
|
+
return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
456
|
+
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
457
|
+
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
458
|
+
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
459
|
+
* and silently relays every request anonymously.
|
|
460
|
+
*
|
|
461
|
+
* This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
|
|
462
|
+
* on purpose: filtering drops a whole namespace the gateway owns, whereas
|
|
463
|
+
* accepting a session has to recognize the one cookie upstream actually mints.
|
|
464
|
+
* Both live here because this module owns the protocol fact; a consumer that
|
|
465
|
+
* re-derives it is how the two rules drifted apart before.
|
|
466
|
+
*/
|
|
467
|
+
function isUpstreamSessionCookie(setCookie) {
|
|
468
|
+
const name = cookieNameOf(setCookie);
|
|
469
|
+
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
|
|
470
|
+
}
|
|
471
|
+
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
472
|
+
function maxAgeSeconds(setCookie) {
|
|
473
|
+
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
474
|
+
return match === null ? void 0 : Number(match[1]);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Perform the token exchange over loopback: GET the launch-token URL with the
|
|
478
|
+
* upstream authority as Host, read the Set-Cookie the index route mints, and
|
|
479
|
+
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
480
|
+
* or no session cookie came back — e.g. an older base without browser
|
|
481
|
+
* sessions).
|
|
482
|
+
*/
|
|
483
|
+
function exchange(url, authority, port, log) {
|
|
484
|
+
return new Promise((resolve) => {
|
|
485
|
+
let target;
|
|
486
|
+
try {
|
|
487
|
+
target = new URL(url);
|
|
488
|
+
} catch {
|
|
489
|
+
log("exchange: authenticatedUrl is not parseable");
|
|
490
|
+
resolve(void 0);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const request = http.request({
|
|
494
|
+
host: "127.0.0.1",
|
|
495
|
+
port,
|
|
496
|
+
method: "GET",
|
|
497
|
+
path: `${target.pathname}${target.search}`,
|
|
498
|
+
headers: {
|
|
499
|
+
host: authority,
|
|
500
|
+
accept: "text/html"
|
|
501
|
+
}
|
|
502
|
+
}, (response) => {
|
|
503
|
+
const setCookies = response.headers["set-cookie"];
|
|
504
|
+
response.resume();
|
|
505
|
+
if (setCookies === void 0) {
|
|
506
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
|
|
507
|
+
resolve(void 0);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const all = Array.isArray(setCookies) ? setCookies : [setCookies];
|
|
511
|
+
const raw = all.find(isUpstreamSessionCookie);
|
|
512
|
+
if (raw === void 0) {
|
|
513
|
+
const names = all.map(cookieNameOf).filter((name) => name !== "");
|
|
514
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
|
|
515
|
+
resolve(void 0);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const header = nameValueOnly(raw);
|
|
519
|
+
const maxAge = maxAgeSeconds(raw);
|
|
520
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
|
|
521
|
+
resolve({
|
|
522
|
+
header,
|
|
523
|
+
expiresAt: Date.now() + (maxAge ?? 0) * 1e3
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
request.on("error", (error) => {
|
|
527
|
+
log(`exchange error: ${error.message}`);
|
|
528
|
+
resolve(void 0);
|
|
529
|
+
});
|
|
530
|
+
request.setTimeout(5e3, () => {
|
|
531
|
+
log("exchange timeout (5s)");
|
|
532
|
+
request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
|
|
533
|
+
});
|
|
534
|
+
request.end();
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* A cached {@link UpstreamSession} acquired through the launch-token exchange.
|
|
539
|
+
* Acquisition runs at most once concurrently and the result is cached until it
|
|
540
|
+
* nears expiry or {@link invalidate} is called.
|
|
541
|
+
*/
|
|
542
|
+
var UpstreamSessionRelay = class {
|
|
543
|
+
port;
|
|
544
|
+
authority;
|
|
545
|
+
authenticatedUrl;
|
|
546
|
+
log;
|
|
547
|
+
held;
|
|
548
|
+
inflight;
|
|
549
|
+
constructor(options) {
|
|
550
|
+
this.port = options.port;
|
|
551
|
+
this.authority = options.authority ?? `127.0.0.1:${options.port}`;
|
|
552
|
+
this.authenticatedUrl = options.authenticatedUrl;
|
|
553
|
+
this.log = options.log ?? (() => {});
|
|
554
|
+
}
|
|
555
|
+
/** Whether the held session is still comfortably inside its lifetime. */
|
|
556
|
+
fresh() {
|
|
557
|
+
const held = this.held;
|
|
558
|
+
if (held === void 0) return false;
|
|
559
|
+
return Date.now() < held.expiresAt - 6e4;
|
|
560
|
+
}
|
|
561
|
+
invalidate() {
|
|
562
|
+
if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
|
|
563
|
+
this.held = void 0;
|
|
564
|
+
}
|
|
565
|
+
async cookie() {
|
|
566
|
+
if (this.fresh()) return this.held?.header;
|
|
567
|
+
return this.acquire();
|
|
568
|
+
}
|
|
569
|
+
acquire() {
|
|
570
|
+
if (this.inflight !== void 0) return this.inflight;
|
|
571
|
+
const pending = this.doExchange().finally(() => {
|
|
572
|
+
this.inflight = void 0;
|
|
573
|
+
});
|
|
574
|
+
this.inflight = pending;
|
|
575
|
+
return pending;
|
|
576
|
+
}
|
|
577
|
+
async doExchange() {
|
|
578
|
+
const url = this.authenticatedUrl();
|
|
579
|
+
if (url === void 0) {
|
|
580
|
+
this.log("authenticatedUrl() returned undefined; keeping current session");
|
|
581
|
+
return this.held?.header;
|
|
582
|
+
}
|
|
583
|
+
this.log(`acquiring session from ${pathOf$1(url)}`);
|
|
584
|
+
const result = await exchange(url, this.authority, this.port, this.log);
|
|
585
|
+
if (result !== void 0) {
|
|
586
|
+
this.held = result;
|
|
587
|
+
this.log("session acquired and cached");
|
|
588
|
+
} else this.log("exchange failed; keeping current session");
|
|
589
|
+
return this.held?.header;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
//#endregion
|
|
593
|
+
//#region src/request-policy.ts
|
|
594
|
+
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
595
|
+
const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
596
|
+
"GET",
|
|
597
|
+
"HEAD",
|
|
598
|
+
"OPTIONS"
|
|
599
|
+
]);
|
|
600
|
+
/**
|
|
601
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
602
|
+
* the non-standard proxy-connection.
|
|
603
|
+
*/
|
|
604
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
605
|
+
"connection",
|
|
606
|
+
"keep-alive",
|
|
607
|
+
"proxy-authenticate",
|
|
608
|
+
"proxy-authorization",
|
|
609
|
+
"proxy-connection",
|
|
610
|
+
"te",
|
|
611
|
+
"trailer",
|
|
612
|
+
"transfer-encoding",
|
|
613
|
+
"upgrade"
|
|
614
|
+
]);
|
|
615
|
+
/**
|
|
616
|
+
* Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
|
|
617
|
+
* exchange that negotiates Connection/Upgrade, so they survive there and
|
|
618
|
+
* nowhere else.
|
|
619
|
+
*/
|
|
620
|
+
const UPGRADE_HANDSHAKE_HEADERS = /* @__PURE__ */ new Set(["connection", "upgrade"]);
|
|
621
|
+
/**
|
|
622
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
623
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
624
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
625
|
+
*/
|
|
626
|
+
const FORWARDING_HEADERS = [
|
|
627
|
+
"forwarded",
|
|
628
|
+
"x-forwarded-for",
|
|
629
|
+
"x-forwarded-host",
|
|
630
|
+
"x-forwarded-port",
|
|
631
|
+
"x-forwarded-proto",
|
|
632
|
+
"x-real-ip"
|
|
633
|
+
];
|
|
634
|
+
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
635
|
+
function isOwnedPath(pathname) {
|
|
636
|
+
return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
640
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
641
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
642
|
+
*
|
|
643
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
644
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
645
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
646
|
+
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
647
|
+
* config route once Host has been rewritten to loopback. Forwarding still
|
|
648
|
+
* relays the raw target: dsh applies the same normalization itself.
|
|
649
|
+
*
|
|
650
|
+
* WHATWG parsing does not drop a trailing slash, and neither does dsh's
|
|
651
|
+
* router, so `/__login/` is not the login page to either of them. The gateway
|
|
652
|
+
* recognizes its own surfaces there anyway: `/__logout/` must still sign out,
|
|
653
|
+
* and `/lan-gateway/config/` must be refused rather than relayed into dsh's
|
|
654
|
+
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
655
|
+
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
656
|
+
*/
|
|
657
|
+
function pathOf(url) {
|
|
658
|
+
try {
|
|
659
|
+
return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
|
|
660
|
+
} catch {
|
|
661
|
+
return url;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Whether `hostname` is loopback (127/8, localhost, ::1).
|
|
666
|
+
*
|
|
667
|
+
* This validates a URL *hostname* — the loopback fence on the gateway's own
|
|
668
|
+
* config route, where the input is the browser's Host header — so it accepts
|
|
669
|
+
* the spellings a URL parser produces, `[::1]` included. `classifySource` in
|
|
670
|
+
* `auth.ts` answers a different question about a different input (a socket
|
|
671
|
+
* address, unwrapped from its `::ffff:` mapping, and including LAN space); the
|
|
672
|
+
* two are related but not interchangeable, and neither should be rewritten in
|
|
673
|
+
* terms of the other without moving its input domain too.
|
|
674
|
+
*/
|
|
675
|
+
function isLoopbackHost(hostname) {
|
|
676
|
+
if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
|
|
677
|
+
const parts = hostname.split(".");
|
|
678
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
679
|
+
}
|
|
680
|
+
/** Whether this source must present a gateway session (default: everyone). */
|
|
681
|
+
function requiresLogin(source, lanPasswordless) {
|
|
682
|
+
return !(lanPasswordless && source !== "internet");
|
|
683
|
+
}
|
|
684
|
+
/** Parse the session cookie out of a Cookie header. */
|
|
685
|
+
function sessionCookie(headers, cookieName) {
|
|
686
|
+
const header = headers.cookie;
|
|
687
|
+
if (typeof header !== "string") return void 0;
|
|
688
|
+
for (const part of header.split(";")) {
|
|
689
|
+
const trimmed = part.trim();
|
|
690
|
+
if (trimmed.startsWith(`${cookieName}=`)) return trimmed.slice(cookieName.length + 1);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* The cross-site test shared by every gateway-owned entry point, applied before
|
|
695
|
+
* any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
|
|
696
|
+
* does not name the authority the browser actually used.
|
|
697
|
+
*
|
|
698
|
+
* Only claims a cross-site page cannot suppress are read, which is what makes
|
|
699
|
+
* this usable on the login POST too (see {@link loginOriginAllowed}).
|
|
700
|
+
*/
|
|
701
|
+
function isCrossSiteRequest(headers) {
|
|
702
|
+
if (headers["sec-fetch-site"] === "cross-site") return true;
|
|
703
|
+
const origin = headers.origin;
|
|
704
|
+
if (origin !== void 0 && !originMatchesHost(origin, headers.host)) return true;
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
|
|
709
|
+
* applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
710
|
+
* state-changing requests and to every WebSocket handshake; reads without an
|
|
711
|
+
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
712
|
+
*/
|
|
713
|
+
function sameSiteAllowed(req, upgrade) {
|
|
714
|
+
if (isCrossSiteRequest(req.headers)) return false;
|
|
715
|
+
const origin = req.headers.origin;
|
|
716
|
+
if (upgrade) return origin !== void 0;
|
|
717
|
+
if (!READ_ONLY_METHODS.has(req.method ?? "GET")) return origin !== void 0;
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The fence on the login POST. Issuing a session is as much a state change as
|
|
722
|
+
* retiring one — and a cross-site form post burns the victim's source address
|
|
723
|
+
* through the login rate limiter — so the login route runs the same cross-site
|
|
724
|
+
* test as everything else.
|
|
725
|
+
*
|
|
726
|
+
* It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
|
|
727
|
+
* request must carry an Origin" rule: a browser always sends an Origin on a
|
|
728
|
+
* form POST, but curl, the dsh CLI and other non-browser clients legitimately
|
|
729
|
+
* do not, and requiring one would lock them out of signing in. What remains is
|
|
730
|
+
* what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
|
|
731
|
+
* that disagrees with the Host the request names.
|
|
732
|
+
*/
|
|
733
|
+
function loginOriginAllowed(headers) {
|
|
734
|
+
return !isCrossSiteRequest(headers);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
738
|
+
* (possibly '').
|
|
739
|
+
*
|
|
740
|
+
* The relay's session is appended to the client's own cookie, and upstream
|
|
741
|
+
* reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
|
|
742
|
+
* typically one minted before dsh's signing secret was reset, so still present
|
|
743
|
+
* but no longer verifying — would therefore shadow the relay's session on every
|
|
744
|
+
* request. That draws a 401, the gateway reads the 401 as "upstream revoked our
|
|
745
|
+
* session" and discards it, the next request re-acquires, and the client's
|
|
746
|
+
* stale cookie shadows that one too: a loop that never converges. Stripping the
|
|
747
|
+
* namespace makes the relay's copy the only one.
|
|
748
|
+
*
|
|
749
|
+
* This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
|
|
750
|
+
* be *accepted* from upstream. The two are deliberately different rules.
|
|
751
|
+
*/
|
|
752
|
+
function withoutUpstreamSessionPairs(cookie) {
|
|
753
|
+
return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamCookiePair(pair)).join("; ");
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Build the outbound headers for one relayed request: rewrite Host/Origin to
|
|
757
|
+
* the loopback upstream, drop hop-by-hop and caller-supplied forwarding
|
|
758
|
+
* headers, clear the upstream cookie namespace the relay owns, and attach the
|
|
759
|
+
* relayed session.
|
|
760
|
+
*/
|
|
761
|
+
function upstreamRequestHeaders(headers, options) {
|
|
762
|
+
const out = { ...headers };
|
|
763
|
+
out.host = `127.0.0.1:${options.dshPort}`;
|
|
764
|
+
if (typeof out.origin === "string") out.origin = `http://127.0.0.1:${options.dshPort}`;
|
|
765
|
+
delete out["proxy-connection"];
|
|
766
|
+
if (!options.keepUpgrade) {
|
|
767
|
+
delete out.connection;
|
|
768
|
+
delete out.upgrade;
|
|
769
|
+
}
|
|
770
|
+
for (const name of FORWARDING_HEADERS) delete out[name];
|
|
771
|
+
if (typeof out.cookie === "string") {
|
|
772
|
+
const kept = withoutUpstreamSessionPairs(out.cookie);
|
|
773
|
+
if (kept === "") delete out.cookie;
|
|
774
|
+
else out.cookie = kept;
|
|
775
|
+
}
|
|
776
|
+
const relayed = options.upstreamCookie;
|
|
777
|
+
if (relayed !== void 0 && relayed !== "") {
|
|
778
|
+
const existing = out.cookie;
|
|
779
|
+
out.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${relayed}` : relayed;
|
|
780
|
+
}
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Filter one direction's worth of headers through the same cookie rule, so the
|
|
785
|
+
* HTTP and WebSocket branches cannot drift apart on it.
|
|
786
|
+
*/
|
|
787
|
+
function stripUpstreamCookies(entry) {
|
|
788
|
+
return !isUpstreamSessionCookie(entry.trim());
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and the
|
|
792
|
+
* upstream session cookie withheld. Upstream's one cookie-minting route is the
|
|
793
|
+
* launch-token exchange at `/`, so a client that already holds a gateway session
|
|
794
|
+
* could otherwise post the token through the gateway and walk away with a
|
|
795
|
+
* durable upstream credential the relay exists to keep on this side. Cookies
|
|
796
|
+
* from other routes (plugins) still pass through.
|
|
797
|
+
*/
|
|
798
|
+
function downstreamResponseHeaders(upstream) {
|
|
799
|
+
const headers = {};
|
|
800
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
801
|
+
if (value === void 0) continue;
|
|
802
|
+
const lower = key.toLowerCase();
|
|
803
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
804
|
+
if (lower === "set-cookie") {
|
|
805
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
|
|
806
|
+
if (list.length > 0) headers[key] = list;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
headers[key] = value;
|
|
810
|
+
}
|
|
811
|
+
return headers;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* The headers of a 101 Switching Protocols response, replayed to the client on
|
|
815
|
+
* the socket the gateway just spliced.
|
|
816
|
+
*
|
|
817
|
+
* {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
|
|
818
|
+
* upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
|
|
819
|
+
* response. The cookie rule is not relaxed with them — the relay's session is
|
|
820
|
+
* withheld on this path too, so upstream cannot hand a client a durable
|
|
821
|
+
* credential by attaching it to the handshake.
|
|
822
|
+
*/
|
|
823
|
+
function upgradeResponseHeaders(upstream) {
|
|
824
|
+
const headers = {};
|
|
825
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
826
|
+
if (value === void 0) continue;
|
|
827
|
+
const lower = key.toLowerCase();
|
|
828
|
+
if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue;
|
|
829
|
+
if (lower === "set-cookie") {
|
|
830
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies);
|
|
831
|
+
if (list.length > 0) headers[key] = list;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
headers[key] = value;
|
|
835
|
+
}
|
|
836
|
+
return headers;
|
|
837
|
+
}
|
|
838
|
+
//#endregion
|
|
271
839
|
//#region src/state.ts
|
|
272
840
|
/**
|
|
273
841
|
* Persistent runtime state for the LAN gateway: the cookie-signing secret and
|
|
@@ -282,13 +850,27 @@ function stateDir(home = homedir()) {
|
|
|
282
850
|
return join(home, ".dsh", "lan-gateway");
|
|
283
851
|
}
|
|
284
852
|
const STATE_FILENAME = "state.json";
|
|
285
|
-
/**
|
|
286
|
-
function
|
|
853
|
+
/** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
|
|
854
|
+
function deriveKey(password, salt, keylen) {
|
|
855
|
+
return new Promise((resolve, reject) => {
|
|
856
|
+
scrypt(password, salt, keylen, (error, derived) => {
|
|
857
|
+
if (error !== null) reject(error);
|
|
858
|
+
else resolve(derived);
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Whether a password is present and passes scrypt verification. Asynchronous
|
|
864
|
+
* on purpose: `scryptSync` occupies the event loop for tens of milliseconds
|
|
865
|
+
* per attempt, and that loop is shared with the dsh process the gateway is
|
|
866
|
+
* forwarding to.
|
|
867
|
+
*/
|
|
868
|
+
async function verifyPassword(state, password) {
|
|
287
869
|
if (state.password === void 0) return false;
|
|
288
870
|
const { hash, salt } = state.password;
|
|
289
871
|
try {
|
|
290
872
|
const expected = Buffer.from(hash, "hex");
|
|
291
|
-
const actual =
|
|
873
|
+
const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
|
|
292
874
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
293
875
|
} catch {
|
|
294
876
|
return false;
|
|
@@ -298,18 +880,20 @@ function verifyPassword(state, password) {
|
|
|
298
880
|
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
299
881
|
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
300
882
|
* password change must invalidate sessions the old password authorized.
|
|
883
|
+
*
|
|
884
|
+
* Deriving the key is asynchronous for the same reason
|
|
885
|
+
* {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
|
|
886
|
+
* milliseconds, and that loop is shared with the dsh process the gateway
|
|
887
|
+
* forwards to. Every caller is already async.
|
|
301
888
|
*/
|
|
302
|
-
function setPassword(state, password) {
|
|
889
|
+
async function setPassword(state, password) {
|
|
303
890
|
const base = {
|
|
304
|
-
|
|
891
|
+
cookieSecret: state.cookieSecret,
|
|
305
892
|
sessionEpoch: state.sessionEpoch + 1
|
|
306
893
|
};
|
|
307
|
-
if (password === void 0) return
|
|
308
|
-
cookieSecret: base.cookieSecret,
|
|
309
|
-
sessionEpoch: base.sessionEpoch
|
|
310
|
-
};
|
|
894
|
+
if (password === void 0) return base;
|
|
311
895
|
const salt = randomBytes(16);
|
|
312
|
-
const hash =
|
|
896
|
+
const hash = await deriveKey(password, salt, 64);
|
|
313
897
|
return {
|
|
314
898
|
...base,
|
|
315
899
|
password: {
|
|
@@ -318,12 +902,46 @@ function setPassword(state, password) {
|
|
|
318
902
|
}
|
|
319
903
|
};
|
|
320
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* Record a session id as revoked.
|
|
907
|
+
* @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
|
|
908
|
+
* rejected on its own account, so the entry is no longer needed; dropping
|
|
909
|
+
* expired entries here is what keeps the list bounded.
|
|
910
|
+
* @param now - epoch millis to judge the existing entries against, injected so
|
|
911
|
+
* a test can age the list without fake timers.
|
|
912
|
+
*/
|
|
913
|
+
function revokeSession(state, sid, expiresMs, now = Date.now()) {
|
|
914
|
+
const revoked = {};
|
|
915
|
+
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
|
|
916
|
+
revoked[sid] = expiresMs;
|
|
917
|
+
return {
|
|
918
|
+
...state,
|
|
919
|
+
revokedSessions: revoked
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
/** Whether `sid` names a session that has been signed out. */
|
|
923
|
+
function isSessionRevoked(state, sid) {
|
|
924
|
+
if (sid === void 0) return false;
|
|
925
|
+
return Object.hasOwn(state.revokedSessions ?? {}, sid);
|
|
926
|
+
}
|
|
321
927
|
function defaultState() {
|
|
322
928
|
return {
|
|
323
929
|
cookieSecret: randomBytes(32).toString("base64"),
|
|
324
930
|
sessionEpoch: 0
|
|
325
931
|
};
|
|
326
932
|
}
|
|
933
|
+
/** Keep the still-live entries of a persisted revocation list, or undefined. */
|
|
934
|
+
function parseRevokedSessions(raw) {
|
|
935
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
936
|
+
const now = Date.now();
|
|
937
|
+
const out = {};
|
|
938
|
+
let anyLive = false;
|
|
939
|
+
for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
|
|
940
|
+
out[sid] = exp;
|
|
941
|
+
anyLive = true;
|
|
942
|
+
}
|
|
943
|
+
return anyLive ? out : void 0;
|
|
944
|
+
}
|
|
327
945
|
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
328
946
|
function loadState(home = homedir()) {
|
|
329
947
|
const dir = stateDir(home);
|
|
@@ -337,6 +955,8 @@ function loadState(home = homedir()) {
|
|
|
337
955
|
sessionEpoch
|
|
338
956
|
};
|
|
339
957
|
if (parsed.password !== void 0) base.password = parsed.password;
|
|
958
|
+
const revoked = parseRevokedSessions(parsed.revokedSessions);
|
|
959
|
+
if (revoked !== void 0) base.revokedSessions = revoked;
|
|
340
960
|
return base;
|
|
341
961
|
}
|
|
342
962
|
return defaultState();
|
|
@@ -359,8 +979,9 @@ function saveState(state, home = homedir()) {
|
|
|
359
979
|
//#endregion
|
|
360
980
|
//#region src/gateway.ts
|
|
361
981
|
/**
|
|
362
|
-
* The reverse-proxy gateway: a `node:http(s)` server bound to
|
|
363
|
-
*
|
|
982
|
+
* The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
|
|
983
|
+
* address (dual-stack, so IPv6 clients reach it too) that forwards every
|
|
984
|
+
* request to the loopback dsh web server.
|
|
364
985
|
*
|
|
365
986
|
* Security model (post-QVD / session-base):
|
|
366
987
|
* - Source is classified from `socket.remoteAddress` only (never
|
|
@@ -374,38 +995,34 @@ function saveState(state, home = homedir()) {
|
|
|
374
995
|
* or its login/logout paths; those are handled locally or refused.
|
|
375
996
|
* - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
|
|
376
997
|
* blinded — so the gateway runs its own origin check on every relayed
|
|
377
|
-
* request (HTTP and WebSocket upgrade) BEFORE rewriting
|
|
378
|
-
* `
|
|
379
|
-
*
|
|
380
|
-
* state-changing methods and on every WebSocket upgrade.
|
|
998
|
+
* request (HTTP and WebSocket upgrade) BEFORE rewriting. See
|
|
999
|
+
* `request-policy.ts`, which owns that decision along with every other
|
|
1000
|
+
* header/path/server decision; this module owns the transport.
|
|
381
1001
|
* - Against a session-capable dsh base the Host/Origin rewrite alone would
|
|
382
1002
|
* still earn a 401 (dsh no longer trusts a loopback Host; it demands its own
|
|
383
1003
|
* authority-bound session cookie). The gateway therefore relays one shared
|
|
384
1004
|
* upstream session acquired through the launch-token exchange and replays it
|
|
385
1005
|
* on every forwarded request. See `upstream-session.ts`.
|
|
386
|
-
* - Sessions
|
|
387
|
-
*
|
|
388
|
-
*
|
|
1006
|
+
* - Sessions are revocable two ways. Each carries a random id, so signing out
|
|
1007
|
+
* retires exactly that session and the WebSockets it opened; and each
|
|
1008
|
+
* carries a revocation epoch, so a password change or secret rotation kills
|
|
1009
|
+
* every session at once — cookie, socket, and all.
|
|
389
1010
|
*
|
|
390
1011
|
* @module @riceawa/dsh-lan-gateway/gateway
|
|
391
1012
|
*/
|
|
392
1013
|
const DEFAULT_BODY_LIMIT_BYTES = 65536;
|
|
393
1014
|
const LOGIN_ATTEMPTS_LIMIT = 5;
|
|
394
1015
|
const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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);
|
|
1016
|
+
/**
|
|
1017
|
+
* How long a half-open upstream WebSocket handshake may hang before the
|
|
1018
|
+
* gateway gives up on it. Without a deadline the client socket sits in the
|
|
1019
|
+
* pending table forever and never learns the upgrade failed — node's http
|
|
1020
|
+
* client would wait out its own socket timeout, which is measured in minutes.
|
|
1021
|
+
*/
|
|
1022
|
+
const UPGRADE_HANDSHAKE_TIMEOUT_MS = 15e3;
|
|
1023
|
+
/** A fresh per-session id: 128 random bits, URL-safe. */
|
|
1024
|
+
function newSessionId() {
|
|
1025
|
+
return randomBytes(16).toString("base64url");
|
|
409
1026
|
}
|
|
410
1027
|
/**
|
|
411
1028
|
* The running gateway: owns the HTTP server and the auth state needed per
|
|
@@ -418,8 +1035,26 @@ var LanGateway = class {
|
|
|
418
1035
|
loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
|
|
419
1036
|
state;
|
|
420
1037
|
disposed = false;
|
|
421
|
-
/**
|
|
422
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* Every WebSocket this gateway is responsible for, keyed by the client
|
|
1040
|
+
* socket: pending handshakes as well as established ones.
|
|
1041
|
+
*
|
|
1042
|
+
* A socket outlives the request that authenticated it, so it has to be
|
|
1043
|
+
* closable by session: on an epoch bump every socket dies, and on sign-out
|
|
1044
|
+
* only that session's. A handshake that is still waiting on the relay or on
|
|
1045
|
+
* upstream's 101 is tracked from the moment it passes the gates, not from the
|
|
1046
|
+
* moment it is spliced — otherwise a revocation that lands mid-handshake
|
|
1047
|
+
* closes the map's contents and then watches the abandoned handshake finish
|
|
1048
|
+
* and register itself as live.
|
|
1049
|
+
*/
|
|
1050
|
+
sockets = /* @__PURE__ */ new Map();
|
|
1051
|
+
/**
|
|
1052
|
+
* Bumped by every revocation (epoch change, per-session sign-out) and by
|
|
1053
|
+
* disposal. A socket is retired when the generation moves past the one it was
|
|
1054
|
+
* admitted under, which is what lets a pending handshake be judged by the
|
|
1055
|
+
* rules in force when it *completes* rather than when it started.
|
|
1056
|
+
*/
|
|
1057
|
+
gate = 0;
|
|
423
1058
|
constructor(config, state) {
|
|
424
1059
|
this.config = config;
|
|
425
1060
|
this.state = state;
|
|
@@ -436,10 +1071,20 @@ var LanGateway = class {
|
|
|
436
1071
|
}
|
|
437
1072
|
/** Replace the in-memory state; bumps of `sessionEpoch` revoke live sessions and sockets. */
|
|
438
1073
|
setState(state) {
|
|
439
|
-
if (state.sessionEpoch !== this.state.sessionEpoch)
|
|
1074
|
+
if (state.sessionEpoch !== this.state.sessionEpoch) {
|
|
1075
|
+
this.gate += 1;
|
|
1076
|
+
this.destroyAllSockets();
|
|
1077
|
+
}
|
|
440
1078
|
this.state = state;
|
|
441
1079
|
}
|
|
442
|
-
/**
|
|
1080
|
+
/**
|
|
1081
|
+
* Start listening on the configured port. The listener is dual-stack: with
|
|
1082
|
+
* no host given, node binds the unspecified IPv6 address `::` — which also
|
|
1083
|
+
* accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
|
|
1084
|
+
* unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
|
|
1085
|
+
* not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
|
|
1086
|
+
* unable to reach a gateway that classifies them.
|
|
1087
|
+
*/
|
|
443
1088
|
async listen() {
|
|
444
1089
|
return new Promise((resolve, reject) => {
|
|
445
1090
|
const onError = (err) => {
|
|
@@ -452,59 +1097,87 @@ var LanGateway = class {
|
|
|
452
1097
|
};
|
|
453
1098
|
this.server.once("error", onError);
|
|
454
1099
|
this.server.once("listening", onListening);
|
|
455
|
-
this.server.listen(this.config.gatewayPort
|
|
1100
|
+
this.server.listen(this.config.gatewayPort);
|
|
456
1101
|
});
|
|
457
1102
|
}
|
|
458
|
-
/**
|
|
1103
|
+
/** The address actually bound, for logs and status (never a claim about it). */
|
|
1104
|
+
boundAddress() {
|
|
1105
|
+
const address = this.server.address();
|
|
1106
|
+
if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
|
|
1107
|
+
return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
|
|
1108
|
+
}
|
|
1109
|
+
/** Close the server, drop every socket, and stop accepting connections. */
|
|
459
1110
|
async close() {
|
|
460
1111
|
if (this.disposed) return;
|
|
461
1112
|
this.disposed = true;
|
|
462
|
-
this.
|
|
1113
|
+
this.gate += 1;
|
|
1114
|
+
this.destroyAllSockets();
|
|
463
1115
|
return new Promise((resolve) => {
|
|
464
1116
|
this.server.close(() => resolve());
|
|
465
1117
|
this.server.closeAllConnections();
|
|
466
1118
|
});
|
|
467
1119
|
}
|
|
468
|
-
|
|
469
|
-
for (const socket of this.
|
|
470
|
-
this.
|
|
1120
|
+
destroyAllSockets() {
|
|
1121
|
+
for (const socket of this.sockets.keys()) socket.destroy();
|
|
1122
|
+
this.sockets.clear();
|
|
1123
|
+
}
|
|
1124
|
+
/** Close the sockets one session opened, so signing out ends its live streams too. */
|
|
1125
|
+
destroySocketsFor(sid) {
|
|
1126
|
+
for (const [socket, tracked] of this.sockets) {
|
|
1127
|
+
if (tracked.sid !== sid) continue;
|
|
1128
|
+
this.sockets.delete(socket);
|
|
1129
|
+
socket.destroy();
|
|
1130
|
+
}
|
|
471
1131
|
}
|
|
472
|
-
|
|
473
|
-
|
|
1132
|
+
/** Track a socket from admission to close. */
|
|
1133
|
+
trackSocket(socket, sid) {
|
|
1134
|
+
this.sockets.set(socket, {
|
|
1135
|
+
sid,
|
|
1136
|
+
gate: this.gate
|
|
1137
|
+
});
|
|
474
1138
|
socket.on("close", () => {
|
|
475
|
-
this.
|
|
1139
|
+
this.sockets.delete(socket);
|
|
476
1140
|
});
|
|
477
1141
|
}
|
|
1142
|
+
/** Whether a socket is still tracked, undisposed, and admitted under the current gate. */
|
|
1143
|
+
stillAdmitted(socket) {
|
|
1144
|
+
if (this.disposed) return false;
|
|
1145
|
+
const tracked = this.sockets.get(socket);
|
|
1146
|
+
return tracked !== void 0 && tracked.gate === this.gate;
|
|
1147
|
+
}
|
|
478
1148
|
sourceOf(req) {
|
|
479
1149
|
return this.config.classifySource !== void 0 ? this.config.classifySource(req) : classifySource(req.socket.remoteAddress, this.config.lanCidrs);
|
|
480
1150
|
}
|
|
481
|
-
/**
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* The session a request carries, or undefined when it presents none, presents
|
|
1153
|
+
* one that no longer verifies under the current epoch, or presents one whose
|
|
1154
|
+
* id has been signed out.
|
|
1155
|
+
*/
|
|
1156
|
+
session(req) {
|
|
1157
|
+
const cookie = sessionCookie(req.headers, this.config.cookieName);
|
|
1158
|
+
if (cookie === void 0) return void 0;
|
|
1159
|
+
const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
|
|
1160
|
+
if (claims === void 0) return void 0;
|
|
1161
|
+
return isSessionRevoked(this.state, claims.sid) ? void 0 : claims;
|
|
489
1162
|
}
|
|
490
1163
|
/** Whether a request carries a session valid under the current epoch. */
|
|
491
1164
|
authorized(req) {
|
|
492
|
-
|
|
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");
|
|
1165
|
+
return this.session(req) !== void 0;
|
|
498
1166
|
}
|
|
499
|
-
|
|
1167
|
+
/**
|
|
1168
|
+
* Send an unauthorized caller to the login form. The rate limiter's refusal
|
|
1169
|
+
* does not come through here: it is answered on the POST itself, where the
|
|
1170
|
+
* banner can be rendered without a round trip.
|
|
1171
|
+
*/
|
|
1172
|
+
serveUnauthorized(res) {
|
|
500
1173
|
res.writeHead(302, {
|
|
501
|
-
location:
|
|
1174
|
+
location: LOGIN_PATH,
|
|
502
1175
|
...this.securityHeaders()
|
|
503
1176
|
});
|
|
504
1177
|
res.end();
|
|
505
1178
|
}
|
|
506
|
-
serveLoginError(res, message) {
|
|
507
|
-
const opts = { error: message };
|
|
1179
|
+
serveLoginError(res, message, limited = false) {
|
|
1180
|
+
const opts = limited ? { limited: true } : { error: message };
|
|
508
1181
|
res.writeHead(401, {
|
|
509
1182
|
"content-type": "text/html; charset=utf-8",
|
|
510
1183
|
"cache-control": "no-store",
|
|
@@ -516,27 +1189,11 @@ var LanGateway = class {
|
|
|
516
1189
|
securityHeaders() {
|
|
517
1190
|
return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
|
|
518
1191
|
}
|
|
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
1192
|
sessionSetCookie(value, maxAgeSeconds) {
|
|
536
1193
|
const attributes = `Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}`;
|
|
537
1194
|
return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? "; Secure" : ""}`;
|
|
538
1195
|
}
|
|
539
|
-
/** Handle one HTTP request:
|
|
1196
|
+
/** Handle one HTTP request: login surface → owned-path refuse → session gate → same-site gate → relay. */
|
|
540
1197
|
async handleHttp(req, res) {
|
|
541
1198
|
const url = req.url ?? "/";
|
|
542
1199
|
const pathname = pathOf(url);
|
|
@@ -554,11 +1211,11 @@ var LanGateway = class {
|
|
|
554
1211
|
res.end("forbidden");
|
|
555
1212
|
return;
|
|
556
1213
|
}
|
|
557
|
-
if (
|
|
558
|
-
this.serveUnauthorized(res
|
|
1214
|
+
if (requiresLogin(source, this.config.lanPasswordless) && !this.authorized(req)) {
|
|
1215
|
+
this.serveUnauthorized(res);
|
|
559
1216
|
return;
|
|
560
1217
|
}
|
|
561
|
-
if (!
|
|
1218
|
+
if (!sameSiteAllowed(req, false)) {
|
|
562
1219
|
res.writeHead(403, this.securityHeaders());
|
|
563
1220
|
res.end("forbidden");
|
|
564
1221
|
return;
|
|
@@ -567,7 +1224,6 @@ var LanGateway = class {
|
|
|
567
1224
|
}
|
|
568
1225
|
/** Handle the login GET form / POST submission. */
|
|
569
1226
|
handleLogin(req, res) {
|
|
570
|
-
req.url?.includes("limited=1");
|
|
571
1227
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
572
1228
|
serveLoginGet(res, this.securityHeaders());
|
|
573
1229
|
return;
|
|
@@ -577,12 +1233,17 @@ var LanGateway = class {
|
|
|
577
1233
|
res.end();
|
|
578
1234
|
return;
|
|
579
1235
|
}
|
|
1236
|
+
if (!loginOriginAllowed(req.headers)) {
|
|
1237
|
+
res.writeHead(403, this.securityHeaders());
|
|
1238
|
+
res.end("forbidden");
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
580
1241
|
const key = req.socket.remoteAddress ?? "unknown";
|
|
581
1242
|
if (!this.loginLimiter.allow(key)) {
|
|
582
|
-
this.serveLoginError(res, "Too many attempts — please wait a minute.");
|
|
1243
|
+
this.serveLoginError(res, "Too many attempts — please wait a minute.", true);
|
|
583
1244
|
return;
|
|
584
1245
|
}
|
|
585
|
-
readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
|
|
1246
|
+
readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
|
|
586
1247
|
if (body === void 0) return;
|
|
587
1248
|
let password;
|
|
588
1249
|
try {
|
|
@@ -590,33 +1251,59 @@ var LanGateway = class {
|
|
|
590
1251
|
} catch {
|
|
591
1252
|
password = void 0;
|
|
592
1253
|
}
|
|
593
|
-
|
|
1254
|
+
const checked = this.state;
|
|
1255
|
+
const accepted = password !== void 0 && await verifyPassword(checked, password);
|
|
1256
|
+
if (this.state !== checked || this.disposed) {
|
|
1257
|
+
this.serveLoginError(res, "Sign-in was interrupted — please try again.");
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (!accepted) {
|
|
594
1261
|
this.serveLoginError(res, "Incorrect password.");
|
|
595
1262
|
return;
|
|
596
1263
|
}
|
|
597
1264
|
const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
|
|
598
1265
|
const expiresMs = Date.now() + maxAgeSeconds * 1e3;
|
|
599
|
-
const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
|
|
1266
|
+
const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch, newSessionId());
|
|
600
1267
|
res.writeHead(302, {
|
|
601
1268
|
location: "/",
|
|
602
1269
|
...this.securityHeaders(),
|
|
603
1270
|
"set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
|
|
604
1271
|
});
|
|
605
1272
|
res.end();
|
|
1273
|
+
}).catch(() => {
|
|
1274
|
+
if (!res.headersSent) {
|
|
1275
|
+
res.writeHead(500, this.securityHeaders());
|
|
1276
|
+
res.end("login failed");
|
|
1277
|
+
}
|
|
606
1278
|
});
|
|
607
1279
|
}
|
|
608
|
-
/**
|
|
1280
|
+
/**
|
|
1281
|
+
* POST /__logout: revoke this session and clear the cookie.
|
|
1282
|
+
*
|
|
1283
|
+
* The session is stateless, so clearing the cookie only stops the browser
|
|
1284
|
+
* that ran the sign-out; a copy of the same value held anywhere else would
|
|
1285
|
+
* keep working until it expired. Revoking the id in the cookie retires that
|
|
1286
|
+
* one session for good, and leaves the account's other sessions — other
|
|
1287
|
+
* devices, other browsers — alone. Bumping the session epoch here would be
|
|
1288
|
+
* the blunter instrument: it signs out every session there is.
|
|
1289
|
+
*/
|
|
609
1290
|
handleLogout(req, res) {
|
|
610
1291
|
if (req.method !== "POST") {
|
|
611
1292
|
res.writeHead(405, { allow: "POST" });
|
|
612
1293
|
res.end();
|
|
613
1294
|
return;
|
|
614
1295
|
}
|
|
615
|
-
if (!
|
|
1296
|
+
if (!sameSiteAllowed(req, false)) {
|
|
616
1297
|
res.writeHead(403, this.securityHeaders());
|
|
617
1298
|
res.end("forbidden");
|
|
618
1299
|
return;
|
|
619
1300
|
}
|
|
1301
|
+
const claims = this.session(req);
|
|
1302
|
+
if (claims?.sid !== void 0) {
|
|
1303
|
+
this.state = revokeSession(this.state, claims.sid, claims.exp);
|
|
1304
|
+
this.config.onStateChange?.(this.state);
|
|
1305
|
+
this.destroySocketsFor(claims.sid);
|
|
1306
|
+
}
|
|
620
1307
|
res.writeHead(302, {
|
|
621
1308
|
location: "/",
|
|
622
1309
|
...this.securityHeaders(),
|
|
@@ -624,34 +1311,20 @@ var LanGateway = class {
|
|
|
624
1311
|
});
|
|
625
1312
|
res.end();
|
|
626
1313
|
}
|
|
627
|
-
/**
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
headers.host = `127.0.0.1:${this.config.dshPort}`;
|
|
631
|
-
if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
|
|
632
|
-
delete headers["proxy-connection"];
|
|
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;
|
|
1314
|
+
/** The shared upstream session's cookie value, if the relay holds one. */
|
|
1315
|
+
async upstreamCookie() {
|
|
1316
|
+
return this.config.upstreamSession === void 0 ? void 0 : this.config.upstreamSession.cookie();
|
|
648
1317
|
}
|
|
649
1318
|
/** Forward an HTTP request to dsh, replaying the shared upstream session. */
|
|
650
1319
|
async relayHttp(req, res, url) {
|
|
651
1320
|
const session = this.config.upstreamSession;
|
|
652
|
-
|
|
653
|
-
const headers =
|
|
654
|
-
|
|
1321
|
+
const relayed = await this.upstreamCookie();
|
|
1322
|
+
const headers = upstreamRequestHeaders(req.headers, {
|
|
1323
|
+
dshPort: this.config.dshPort,
|
|
1324
|
+
keepUpgrade: false,
|
|
1325
|
+
upstreamCookie: relayed
|
|
1326
|
+
});
|
|
1327
|
+
const attached = relayed !== void 0;
|
|
655
1328
|
const proxyReq = http.request({
|
|
656
1329
|
host: "127.0.0.1",
|
|
657
1330
|
port: this.config.dshPort,
|
|
@@ -660,7 +1333,7 @@ var LanGateway = class {
|
|
|
660
1333
|
headers
|
|
661
1334
|
}, (proxyRes) => {
|
|
662
1335
|
if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
|
|
663
|
-
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
1336
|
+
res.writeHead(proxyRes.statusCode ?? 502, downstreamResponseHeaders(proxyRes.headers));
|
|
664
1337
|
proxyRes.pipe(res);
|
|
665
1338
|
});
|
|
666
1339
|
proxyReq.on("error", () => {
|
|
@@ -682,18 +1355,30 @@ var LanGateway = class {
|
|
|
682
1355
|
refuse(403);
|
|
683
1356
|
return;
|
|
684
1357
|
}
|
|
685
|
-
|
|
1358
|
+
const claims = this.session(req);
|
|
1359
|
+
if (requiresLogin(source, this.config.lanPasswordless) && claims === void 0) {
|
|
686
1360
|
refuse(401);
|
|
687
1361
|
return;
|
|
688
1362
|
}
|
|
689
|
-
if (!
|
|
1363
|
+
if (!sameSiteAllowed(req, true)) {
|
|
690
1364
|
refuse(403);
|
|
691
1365
|
return;
|
|
692
1366
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1367
|
+
this.trackSocket(socket, claims?.sid);
|
|
1368
|
+
const retire = () => {
|
|
1369
|
+
if (!this.sockets.delete(socket)) return;
|
|
1370
|
+
socket.destroy();
|
|
1371
|
+
};
|
|
1372
|
+
const relayed = await this.upstreamCookie();
|
|
1373
|
+
if (!this.stillAdmitted(socket)) {
|
|
1374
|
+
retire();
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const headers = upstreamRequestHeaders(req.headers, {
|
|
1378
|
+
dshPort: this.config.dshPort,
|
|
1379
|
+
keepUpgrade: true,
|
|
1380
|
+
upstreamCookie: relayed
|
|
1381
|
+
});
|
|
697
1382
|
const proxyReq = http.request({
|
|
698
1383
|
host: "127.0.0.1",
|
|
699
1384
|
port: this.config.dshPort,
|
|
@@ -701,10 +1386,22 @@ var LanGateway = class {
|
|
|
701
1386
|
path: url,
|
|
702
1387
|
headers
|
|
703
1388
|
});
|
|
1389
|
+
const timer = setTimeout(() => {
|
|
1390
|
+
proxyReq.destroy();
|
|
1391
|
+
retire();
|
|
1392
|
+
}, UPGRADE_HANDSHAKE_TIMEOUT_MS);
|
|
1393
|
+
const settle = () => {
|
|
1394
|
+
clearTimeout(timer);
|
|
1395
|
+
};
|
|
704
1396
|
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
705
|
-
|
|
1397
|
+
settle();
|
|
1398
|
+
if (!this.stillAdmitted(socket)) {
|
|
1399
|
+
proxySocket.destroy();
|
|
1400
|
+
retire();
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
706
1403
|
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
|
|
707
|
-
const headerLines = Object.entries(proxyRes.headers).
|
|
1404
|
+
const headerLines = Object.entries(upgradeResponseHeaders(proxyRes.headers)).flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((entry) => `${key}: ${entry}\r\n`)).join("");
|
|
708
1405
|
socket.write(`${statusLine}${headerLines}\r\n`);
|
|
709
1406
|
if (head !== void 0 && head.length > 0) proxySocket.write(head);
|
|
710
1407
|
proxySocket.pipe(socket).pipe(proxySocket);
|
|
@@ -712,7 +1409,22 @@ var LanGateway = class {
|
|
|
712
1409
|
socket.on("error", () => proxySocket.destroy());
|
|
713
1410
|
proxySocket.on("error", () => socket.destroy());
|
|
714
1411
|
});
|
|
715
|
-
proxyReq.on("
|
|
1412
|
+
proxyReq.on("response", (proxyRes) => {
|
|
1413
|
+
settle();
|
|
1414
|
+
proxyRes.resume();
|
|
1415
|
+
if (!this.stillAdmitted(socket)) {
|
|
1416
|
+
retire();
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
if (proxyRes.statusCode === 401 && relayed !== void 0) this.config.upstreamSession?.invalidate();
|
|
1420
|
+
const body = `upstream refused the WebSocket upgrade (HTTP ${proxyRes.statusCode ?? 502})`;
|
|
1421
|
+
socket.write(`HTTP/1.1 ${proxyRes.statusCode ?? 502} ${proxyRes.statusMessage ?? "Upstream Refused"}\r\nConnection: close\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
1422
|
+
retire();
|
|
1423
|
+
});
|
|
1424
|
+
proxyReq.on("error", () => {
|
|
1425
|
+
settle();
|
|
1426
|
+
retire();
|
|
1427
|
+
});
|
|
716
1428
|
proxyReq.end();
|
|
717
1429
|
}
|
|
718
1430
|
};
|
|
@@ -982,6 +1694,29 @@ function loadOrCreateSelfSigned(opts, home = homedir()) {
|
|
|
982
1694
|
};
|
|
983
1695
|
}
|
|
984
1696
|
/**
|
|
1697
|
+
* Load the self-signed material a listener should serve: generate it on first
|
|
1698
|
+
* use, reuse the persisted pair otherwise, and replace a persisted certificate
|
|
1699
|
+
* whose validity has already lapsed.
|
|
1700
|
+
*
|
|
1701
|
+
* Nothing renews a self-signed certificate in place, and a browser refuses a
|
|
1702
|
+
* lapsed one outright, so without this a certificate that ran out would keep
|
|
1703
|
+
* being served until an operator happened to read the expiry date out of
|
|
1704
|
+
* `status` and act on it. Renewing mints a fresh key, so a client that had
|
|
1705
|
+
* trusted the old certificate has to trust the new one — but that is the case
|
|
1706
|
+
* either way, the old one having lapsed.
|
|
1707
|
+
*/
|
|
1708
|
+
function loadOrRenewSelfSigned(opts, home = homedir()) {
|
|
1709
|
+
const { material, created } = loadOrCreateSelfSigned(opts, home);
|
|
1710
|
+
if (created || !isCertExpired(material.cert)) return {
|
|
1711
|
+
material,
|
|
1712
|
+
renewed: false
|
|
1713
|
+
};
|
|
1714
|
+
return {
|
|
1715
|
+
material: regenerateSelfSigned(opts, home),
|
|
1716
|
+
renewed: true
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
985
1720
|
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
986
1721
|
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
987
1722
|
*/
|
|
@@ -993,6 +1728,31 @@ function regenerateSelfSigned(opts, home = homedir()) {
|
|
|
993
1728
|
privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
|
|
994
1729
|
return material;
|
|
995
1730
|
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Read the persisted self-signed certificate for a status report, or undefined
|
|
1733
|
+
* when none has been generated yet.
|
|
1734
|
+
*
|
|
1735
|
+
* Nothing is created or written here. The status path answers a question about
|
|
1736
|
+
* a listener that is already running (or was), and minting a key pair — an RSA
|
|
1737
|
+
* generation plus two file writes — to answer a read would both be slow and
|
|
1738
|
+
* leave material on disk for a gateway that never started. Generation belongs
|
|
1739
|
+
* to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
|
|
1740
|
+
* @param home - dsh home override (tests).
|
|
1741
|
+
* @returns the certificate material as persisted, or undefined.
|
|
1742
|
+
*/
|
|
1743
|
+
function readSelfSignedStatus(home = homedir()) {
|
|
1744
|
+
const dir = tlsDir(home);
|
|
1745
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE);
|
|
1746
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
|
|
1747
|
+
if (!existsSync(certPath) || !existsSync(keyPath)) return void 0;
|
|
1748
|
+
const cert = readFileSync(certPath, "utf8");
|
|
1749
|
+
const key = readFileSync(keyPath, "utf8");
|
|
1750
|
+
new X509Certificate(cert);
|
|
1751
|
+
return {
|
|
1752
|
+
cert,
|
|
1753
|
+
key
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
996
1756
|
function generateSelfSignedMaterial(opts) {
|
|
997
1757
|
const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
|
|
998
1758
|
if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
|
|
@@ -1006,295 +1766,130 @@ function generateSelfSignedMaterial(opts) {
|
|
|
1006
1766
|
key: keyPem
|
|
1007
1767
|
};
|
|
1008
1768
|
}
|
|
1009
|
-
/**
|
|
1010
|
-
* Load a user-supplied certificate + key pair from PEM files.
|
|
1011
|
-
* @param certPath - path to the PEM certificate (or chain).
|
|
1012
|
-
* @param keyPath - path to the PEM private key.
|
|
1013
|
-
* @returns the material.
|
|
1014
|
-
*/
|
|
1015
|
-
function loadCustomCert(certPath, keyPath) {
|
|
1016
|
-
if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
|
|
1017
|
-
if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
|
|
1018
|
-
let cert;
|
|
1019
|
-
try {
|
|
1020
|
-
cert = readFileSync(certPath, "utf8");
|
|
1021
|
-
} catch (error) {
|
|
1022
|
-
throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
|
|
1023
|
-
}
|
|
1024
|
-
let key;
|
|
1025
|
-
try {
|
|
1026
|
-
key = readFileSync(keyPath, "utf8");
|
|
1027
|
-
} catch (error) {
|
|
1028
|
-
throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
|
|
1029
|
-
}
|
|
1030
|
-
try {
|
|
1031
|
-
new X509Certificate(cert);
|
|
1032
|
-
} catch {
|
|
1033
|
-
throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
|
|
1034
|
-
}
|
|
1035
|
-
return {
|
|
1036
|
-
cert,
|
|
1037
|
-
key
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
/** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
|
|
1041
|
-
function parseSelfSignedHosts(text) {
|
|
1042
|
-
return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
|
|
1043
|
-
}
|
|
1044
|
-
/** Describe a PEM certificate (throws on malformed input). */
|
|
1045
|
-
function describeCert(certPem) {
|
|
1046
|
-
const cert = new X509Certificate(certPem);
|
|
1047
|
-
return {
|
|
1048
|
-
subject: cert.subject,
|
|
1049
|
-
issuer: cert.issuer,
|
|
1050
|
-
validFrom: cert.validFrom,
|
|
1051
|
-
validTo: cert.validTo,
|
|
1052
|
-
fingerprint256: cert.fingerprint256,
|
|
1053
|
-
...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
function errorMessage(error) {
|
|
1057
|
-
return error instanceof Error ? error.message : String(error);
|
|
1058
|
-
}
|
|
1059
|
-
//#endregion
|
|
1060
|
-
//#region src/tool.ts
|
|
1061
|
-
const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
|
|
1062
|
-
/**
|
|
1063
|
-
* Build the `lan_gateway` tool over a controller interface implemented by the
|
|
1064
|
-
* plugin entry. Split so the tool stays testable and the plugin decides how
|
|
1065
|
-
* the controller mutates state.
|
|
1066
|
-
*/
|
|
1067
|
-
function lanGatewayTool(control) {
|
|
1068
|
-
return defineTool({
|
|
1069
|
-
name: LAN_GATEWAY_TOOL_NAME,
|
|
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.",
|
|
1071
|
-
parameters: {
|
|
1072
|
-
command: {
|
|
1073
|
-
type: "string",
|
|
1074
|
-
enum: [
|
|
1075
|
-
"status",
|
|
1076
|
-
"enable",
|
|
1077
|
-
"disable",
|
|
1078
|
-
"set-password",
|
|
1079
|
-
"rotate-secret",
|
|
1080
|
-
"tls-regenerate"
|
|
1081
|
-
],
|
|
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."
|
|
1083
|
-
},
|
|
1084
|
-
password: {
|
|
1085
|
-
type: "string",
|
|
1086
|
-
description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
|
|
1087
|
-
}
|
|
1088
|
-
},
|
|
1089
|
-
output: {
|
|
1090
|
-
schema: {
|
|
1091
|
-
type: "object",
|
|
1092
|
-
additionalProperties: false,
|
|
1093
|
-
properties: {
|
|
1094
|
-
ok: {
|
|
1095
|
-
type: "boolean",
|
|
1096
|
-
required: true
|
|
1097
|
-
},
|
|
1098
|
-
message: {
|
|
1099
|
-
type: "string",
|
|
1100
|
-
required: true
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
},
|
|
1104
|
-
render: (_args, value) => [{
|
|
1105
|
-
type: "text",
|
|
1106
|
-
text: value.message
|
|
1107
|
-
}]
|
|
1108
|
-
},
|
|
1109
|
-
async execute(args, _exec) {
|
|
1110
|
-
switch (args.command ?? "status") {
|
|
1111
|
-
case "status": return control.status();
|
|
1112
|
-
case "enable": return control.enable();
|
|
1113
|
-
case "disable": return control.disable();
|
|
1114
|
-
case "set-password": {
|
|
1115
|
-
const password = args.password;
|
|
1116
|
-
return control.setPassword(typeof password === "string" ? password : void 0);
|
|
1117
|
-
}
|
|
1118
|
-
case "rotate-secret": return control.rotateSecret();
|
|
1119
|
-
case "tls-regenerate": return control.regenerateTls();
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
});
|
|
1123
|
-
}
|
|
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
|
-
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
1159
|
-
function cookieNameOf(setCookie) {
|
|
1160
|
-
const eq = setCookie.indexOf("=");
|
|
1161
|
-
return eq === -1 ? "" : setCookie.slice(0, eq).trim();
|
|
1162
|
-
}
|
|
1163
|
-
/**
|
|
1164
|
-
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
1165
|
-
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
1166
|
-
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
1167
|
-
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
1168
|
-
* and silently relays every request anonymously.
|
|
1169
|
-
*/
|
|
1170
|
-
function isUpstreamSessionCookie(setCookie) {
|
|
1171
|
-
const name = cookieNameOf(setCookie);
|
|
1172
|
-
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
|
|
1173
|
-
}
|
|
1174
|
-
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
1175
|
-
function maxAgeSeconds(setCookie) {
|
|
1176
|
-
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
1177
|
-
return match === null ? void 0 : Number(match[1]);
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Perform the token exchange over loopback: GET the launch-token URL with the
|
|
1181
|
-
* upstream authority as Host, read the Set-Cookie the index route mints, and
|
|
1182
|
-
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
1183
|
-
* or no session cookie came back — e.g. an older base without browser
|
|
1184
|
-
* sessions).
|
|
1185
|
-
*/
|
|
1186
|
-
function exchange(url, authority, port, log) {
|
|
1187
|
-
return new Promise((resolve) => {
|
|
1188
|
-
let target;
|
|
1189
|
-
try {
|
|
1190
|
-
target = new URL(url);
|
|
1191
|
-
} catch {
|
|
1192
|
-
log(`exchange: unparseable authenticatedUrl ${url}`);
|
|
1193
|
-
resolve(void 0);
|
|
1194
|
-
return;
|
|
1195
|
-
}
|
|
1196
|
-
const request = http.request({
|
|
1197
|
-
host: "127.0.0.1",
|
|
1198
|
-
port,
|
|
1199
|
-
method: "GET",
|
|
1200
|
-
path: `${target.pathname}${target.search}`,
|
|
1201
|
-
headers: {
|
|
1202
|
-
host: authority,
|
|
1203
|
-
accept: "text/html"
|
|
1204
|
-
}
|
|
1205
|
-
}, (response) => {
|
|
1206
|
-
const setCookies = response.headers["set-cookie"];
|
|
1207
|
-
response.resume();
|
|
1208
|
-
if (setCookies === void 0) {
|
|
1209
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`);
|
|
1210
|
-
resolve(void 0);
|
|
1211
|
-
return;
|
|
1212
|
-
}
|
|
1213
|
-
const all = Array.isArray(setCookies) ? setCookies : [setCookies];
|
|
1214
|
-
const raw = all.find(isUpstreamSessionCookie);
|
|
1215
|
-
if (raw === void 0) {
|
|
1216
|
-
const names = all.map(cookieNameOf).filter((name) => name !== "");
|
|
1217
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(", ") || "none"})`);
|
|
1218
|
-
resolve(void 0);
|
|
1219
|
-
return;
|
|
1220
|
-
}
|
|
1221
|
-
const header = nameValueOnly(raw);
|
|
1222
|
-
const maxAge = maxAgeSeconds(raw);
|
|
1223
|
-
log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? "n/a"})`);
|
|
1224
|
-
resolve({
|
|
1225
|
-
header,
|
|
1226
|
-
expiresAt: Date.now() + (maxAge ?? 0) * 1e3
|
|
1227
|
-
});
|
|
1228
|
-
});
|
|
1229
|
-
request.on("error", (error) => {
|
|
1230
|
-
log(`exchange error: ${error.message}`);
|
|
1231
|
-
resolve(void 0);
|
|
1232
|
-
});
|
|
1233
|
-
request.setTimeout(5e3, () => {
|
|
1234
|
-
log("exchange timeout (5s)");
|
|
1235
|
-
request.destroy(/* @__PURE__ */ new Error("upstream-session exchange timeout"));
|
|
1236
|
-
});
|
|
1237
|
-
request.end();
|
|
1238
|
-
});
|
|
1239
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* A cached {@link UpstreamSession} acquired through the launch-token exchange.
|
|
1242
|
-
* Acquisition runs at most once concurrently and the result is cached until it
|
|
1243
|
-
* nears expiry or {@link invalidate} is called.
|
|
1769
|
+
/**
|
|
1770
|
+
* Load a user-supplied certificate + key pair from PEM files.
|
|
1771
|
+
* @param certPath - path to the PEM certificate (or chain).
|
|
1772
|
+
* @param keyPath - path to the PEM private key.
|
|
1773
|
+
* @returns the material.
|
|
1244
1774
|
*/
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
this.port = options.port;
|
|
1254
|
-
this.authority = options.authority ?? `127.0.0.1:${options.port}`;
|
|
1255
|
-
this.authenticatedUrl = options.authenticatedUrl;
|
|
1256
|
-
this.log = options.log ?? (() => {});
|
|
1257
|
-
}
|
|
1258
|
-
/** Whether the held session is still comfortably inside its lifetime. */
|
|
1259
|
-
fresh() {
|
|
1260
|
-
const held = this.held;
|
|
1261
|
-
if (held === void 0) return false;
|
|
1262
|
-
return Date.now() < held.expiresAt - 6e4;
|
|
1263
|
-
}
|
|
1264
|
-
peek() {
|
|
1265
|
-
return this.held?.header;
|
|
1266
|
-
}
|
|
1267
|
-
invalidate() {
|
|
1268
|
-
if (this.held !== void 0) this.log("invalidating held session (upstream rejected it)");
|
|
1269
|
-
this.held = void 0;
|
|
1775
|
+
function loadCustomCert(certPath, keyPath) {
|
|
1776
|
+
if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
|
|
1777
|
+
if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
|
|
1778
|
+
let cert;
|
|
1779
|
+
try {
|
|
1780
|
+
cert = readFileSync(certPath, "utf8");
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
|
|
1270
1783
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1784
|
+
let key;
|
|
1785
|
+
try {
|
|
1786
|
+
key = readFileSync(keyPath, "utf8");
|
|
1787
|
+
} catch (error) {
|
|
1788
|
+
throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
|
|
1274
1789
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
});
|
|
1280
|
-
this.inflight = pending;
|
|
1281
|
-
return pending;
|
|
1790
|
+
try {
|
|
1791
|
+
new X509Certificate(cert);
|
|
1792
|
+
} catch {
|
|
1793
|
+
throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
|
|
1282
1794
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1795
|
+
return {
|
|
1796
|
+
cert,
|
|
1797
|
+
key
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
/** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
|
|
1801
|
+
function parseSelfSignedHosts(text) {
|
|
1802
|
+
return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Whether a PEM certificate's validity window has already closed. A lapsed
|
|
1806
|
+
* certificate is a hard failure browsers will not let the user proceed past,
|
|
1807
|
+
* so the listener replaces one rather than keep serving it.
|
|
1808
|
+
*/
|
|
1809
|
+
function isCertExpired(certPem, now = Date.now()) {
|
|
1810
|
+
const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
|
|
1811
|
+
return Number.isFinite(expiresAt) && expiresAt <= now;
|
|
1812
|
+
}
|
|
1813
|
+
/** Describe a PEM certificate (throws on malformed input). */
|
|
1814
|
+
function describeCert(certPem) {
|
|
1815
|
+
const cert = new X509Certificate(certPem);
|
|
1816
|
+
return {
|
|
1817
|
+
subject: cert.subject,
|
|
1818
|
+
issuer: cert.issuer,
|
|
1819
|
+
validFrom: cert.validFrom,
|
|
1820
|
+
validTo: cert.validTo,
|
|
1821
|
+
fingerprint256: cert.fingerprint256,
|
|
1822
|
+
...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
function errorMessage(error) {
|
|
1826
|
+
return error instanceof Error ? error.message : String(error);
|
|
1827
|
+
}
|
|
1828
|
+
//#endregion
|
|
1829
|
+
//#region src/tool.ts
|
|
1830
|
+
const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
|
|
1831
|
+
/**
|
|
1832
|
+
* Build the `lan_gateway` tool over a controller interface implemented by the
|
|
1833
|
+
* plugin entry. Split so the tool stays testable and the plugin decides how
|
|
1834
|
+
* the controller mutates state.
|
|
1835
|
+
*/
|
|
1836
|
+
function lanGatewayTool(control) {
|
|
1837
|
+
return defineTool({
|
|
1838
|
+
name: LAN_GATEWAY_TOOL_NAME,
|
|
1839
|
+
description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the ingress/TLS state, and the upstream-session-relay state. `enable` starts listening on 0.0.0.0 — a password is required, and by default every source (loopback, LAN, internet) must sign in; set lanPasswordless to exempt LAN/loopback. The listener also refuses to run over plaintext unless TLS, a declared trustedTerminator, or an explicit allowInsecurePlaintext opt-in is present. `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password; changing it revokes every existing session, and clearing it stops the listener. `rotate-secret` invalidates every issued login cookie and live WebSocket. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
|
|
1840
|
+
parameters: {
|
|
1841
|
+
command: {
|
|
1842
|
+
type: "string",
|
|
1843
|
+
enum: [
|
|
1844
|
+
"status",
|
|
1845
|
+
"enable",
|
|
1846
|
+
"disable",
|
|
1847
|
+
"set-password",
|
|
1848
|
+
"rotate-secret",
|
|
1849
|
+
"tls-regenerate"
|
|
1850
|
+
],
|
|
1851
|
+
description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password (setting revokes all sessions; clearing stops the listener). `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
|
|
1852
|
+
},
|
|
1853
|
+
password: {
|
|
1854
|
+
type: "string",
|
|
1855
|
+
description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
|
|
1856
|
+
}
|
|
1857
|
+
},
|
|
1858
|
+
output: {
|
|
1859
|
+
schema: {
|
|
1860
|
+
type: "object",
|
|
1861
|
+
additionalProperties: false,
|
|
1862
|
+
properties: {
|
|
1863
|
+
ok: {
|
|
1864
|
+
type: "boolean",
|
|
1865
|
+
required: true
|
|
1866
|
+
},
|
|
1867
|
+
message: {
|
|
1868
|
+
type: "string",
|
|
1869
|
+
required: true
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
},
|
|
1873
|
+
render: (_args, value) => [{
|
|
1874
|
+
type: "text",
|
|
1875
|
+
text: value.message
|
|
1876
|
+
}]
|
|
1877
|
+
},
|
|
1878
|
+
async execute(args, _exec) {
|
|
1879
|
+
switch (args.command ?? "status") {
|
|
1880
|
+
case "status": return control.status();
|
|
1881
|
+
case "enable": return control.enable();
|
|
1882
|
+
case "disable": return control.disable();
|
|
1883
|
+
case "set-password": {
|
|
1884
|
+
const password = args.password;
|
|
1885
|
+
return control.setPassword(typeof password === "string" ? password : void 0);
|
|
1886
|
+
}
|
|
1887
|
+
case "rotate-secret": return control.rotateSecret();
|
|
1888
|
+
case "tls-regenerate": return control.regenerateTls();
|
|
1889
|
+
}
|
|
1288
1890
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
if (result !== void 0) {
|
|
1292
|
-
this.held = result;
|
|
1293
|
-
this.log("session acquired and cached");
|
|
1294
|
-
} else this.log("exchange failed; keeping current session");
|
|
1295
|
-
return this.held?.header;
|
|
1296
|
-
}
|
|
1297
|
-
};
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1298
1893
|
//#endregion
|
|
1299
1894
|
//#region src/index.ts
|
|
1300
1895
|
/** Stable Cordis plugin name. */
|
|
@@ -1308,13 +1903,6 @@ const inject = ["webServer", "tools"];
|
|
|
1308
1903
|
* shape works against both that release line and the older branded one.
|
|
1309
1904
|
*/
|
|
1310
1905
|
const NS = "lan-gateway";
|
|
1311
|
-
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
1312
|
-
const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
1313
|
-
"dshTargetPort",
|
|
1314
|
-
"tlsCertPath",
|
|
1315
|
-
"tlsKeyPath",
|
|
1316
|
-
"trustedTerminator"
|
|
1317
|
-
]);
|
|
1318
1906
|
/** Schemastery configuration validated by the Loader. */
|
|
1319
1907
|
const Config = z.object({
|
|
1320
1908
|
enabled: z.boolean().default(false),
|
|
@@ -1365,14 +1953,16 @@ function resolveSecureCookies(cfg) {
|
|
|
1365
1953
|
/** Resolve the TLS material for a config, or undefined when TLS is off. */
|
|
1366
1954
|
function resolveTls(cfg) {
|
|
1367
1955
|
if (!cfg.tlsEnabled) return void 0;
|
|
1368
|
-
if (cfg.tlsMode === "custom") return
|
|
1956
|
+
if (cfg.tlsMode === "custom") return {
|
|
1957
|
+
material: loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? ""),
|
|
1958
|
+
renewed: false
|
|
1959
|
+
};
|
|
1369
1960
|
const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
|
|
1370
1961
|
if (hosts.length === 0) throw new Error("tlsSelfSignedHosts must name at least one host (DNS name or IP)");
|
|
1371
|
-
|
|
1962
|
+
return loadOrRenewSelfSigned({
|
|
1372
1963
|
hosts,
|
|
1373
1964
|
days: cfg.tlsCertMaxAgeDays
|
|
1374
1965
|
});
|
|
1375
|
-
return material;
|
|
1376
1966
|
}
|
|
1377
1967
|
/**
|
|
1378
1968
|
* Config fields that require a listener restart when they change, plus whether
|
|
@@ -1402,32 +1992,26 @@ function listenerKey(cfg, relayAvailable) {
|
|
|
1402
1992
|
relayAvailable
|
|
1403
1993
|
]);
|
|
1404
1994
|
}
|
|
1405
|
-
/**
|
|
1995
|
+
/**
|
|
1996
|
+
* One-line TLS description for status output.
|
|
1997
|
+
*
|
|
1998
|
+
* Never generates: this is the read path behind `GET /lan-gateway/config` and
|
|
1999
|
+
* `lan_gateway status`, and a status query that mints an RSA key and writes a
|
|
2000
|
+
* certificate to disk is not a read. The material is created when the listener
|
|
2001
|
+
* starts, or by `lan_gateway tls-regenerate`.
|
|
2002
|
+
*/
|
|
1406
2003
|
function tlsStatusLine(cfg) {
|
|
1407
2004
|
if (!cfg.tlsEnabled) return "off";
|
|
1408
2005
|
if (cfg.tlsMode === "custom") return `custom (${cfg.tlsCertPath ?? "?"}, ${cfg.tlsKeyPath ?? "?"})`;
|
|
1409
2006
|
try {
|
|
1410
|
-
const
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
});
|
|
1414
|
-
const info = describeCert(material.cert);
|
|
2007
|
+
const status = readSelfSignedStatus();
|
|
2008
|
+
if (status === void 0) return "self-signed (not generated yet — created when the listener starts)";
|
|
2009
|
+
const info = describeCert(status.cert);
|
|
1415
2010
|
return `self-signed [${info.subject}] exp ${info.validTo}`;
|
|
1416
2011
|
} catch (error) {
|
|
1417
2012
|
return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`;
|
|
1418
2013
|
}
|
|
1419
2014
|
}
|
|
1420
|
-
/** Whether `hostname` is loopback (127/8, localhost, ::1). */
|
|
1421
|
-
function isLoopbackHost(hostname) {
|
|
1422
|
-
if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
|
|
1423
|
-
const parts = hostname.split(".");
|
|
1424
|
-
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1425
|
-
}
|
|
1426
|
-
const READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
1427
|
-
"GET",
|
|
1428
|
-
"HEAD",
|
|
1429
|
-
"OPTIONS"
|
|
1430
|
-
]);
|
|
1431
2015
|
/**
|
|
1432
2016
|
* Same-origin loopback fence for the native `/lan-gateway/config` route. The
|
|
1433
2017
|
* gateway refuses to relay this prefix, so the only way in is the native
|
|
@@ -1453,28 +2037,107 @@ function isTrustedConfigRequest(req) {
|
|
|
1453
2037
|
if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
|
|
1454
2038
|
return true;
|
|
1455
2039
|
}
|
|
2040
|
+
/**
|
|
2041
|
+
* Turn a submitted config patch into the next user section: only keys the card
|
|
2042
|
+
* can edit, only real values, and `null` (or an emptied optional) removes the
|
|
2043
|
+
* key rather than storing it.
|
|
2044
|
+
*
|
|
2045
|
+
* The patch is built from the *submitted* object, never from a schema call's
|
|
2046
|
+
* output. Schemastery fills defaults into whatever it validates and passes
|
|
2047
|
+
* unknown keys through, so deriving the section from `Config(submitted)` wrote
|
|
2048
|
+
* `authRequired: true` (a capability that exists only to be refused) and any
|
|
2049
|
+
* stray key into the user's settings on every save — and, because it also
|
|
2050
|
+
* materialized `cookieName`, reset an operator's custom cookie name to the
|
|
2051
|
+
* schema default.
|
|
2052
|
+
*
|
|
2053
|
+
* A `null` value is the card's clear: the key is dropped from the patch, which
|
|
2054
|
+
* leaves it absent from the section, so it re-inherits the composition layer.
|
|
2055
|
+
*/
|
|
2056
|
+
function buildConfigPatch(submitted) {
|
|
2057
|
+
const patch = {};
|
|
2058
|
+
const clear = [];
|
|
2059
|
+
const unknown = [];
|
|
2060
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
2061
|
+
if (!CONFIG_FIELD_KEYS.has(key)) {
|
|
2062
|
+
unknown.push(key);
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
if (value === null || value === void 0) {
|
|
2066
|
+
clear.push(key);
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) {
|
|
2070
|
+
clear.push(key);
|
|
2071
|
+
continue;
|
|
2072
|
+
}
|
|
2073
|
+
patch[key] = value;
|
|
2074
|
+
}
|
|
2075
|
+
return {
|
|
2076
|
+
patch,
|
|
2077
|
+
clear,
|
|
2078
|
+
unknown
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
1456
2081
|
function apply(ctx, config) {
|
|
1457
2082
|
let state = loadState();
|
|
1458
2083
|
let gateway;
|
|
1459
2084
|
let startedWith;
|
|
1460
2085
|
let lastError;
|
|
2086
|
+
/**
|
|
2087
|
+
* The operator's run intent, used only while no settings service is attached.
|
|
2088
|
+
* With settings present, `enabled` in the settings section *is* the intent —
|
|
2089
|
+
* the card and the tool write the same field, so there is one truth rather
|
|
2090
|
+
* than two that disagree.
|
|
2091
|
+
*/
|
|
1461
2092
|
let manualOverride;
|
|
1462
2093
|
/** Whether the base enforces browser-session auth; set once `connection` is seen. */
|
|
1463
2094
|
let upstreamSessionAvailable = false;
|
|
2095
|
+
/**
|
|
2096
|
+
* Identifies the current `connection` handler. A provider that detaches and a
|
|
2097
|
+
* new one that attaches run their disposers in an order the plugin does not
|
|
2098
|
+
* control, and a stale disposer clearing `makeRelay` would strand the live
|
|
2099
|
+
* provider — so a disposer only acts if it is still the latest generation.
|
|
2100
|
+
*/
|
|
2101
|
+
let connectionGeneration = 0;
|
|
1464
2102
|
/** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
|
|
1465
2103
|
let makeRelay;
|
|
1466
2104
|
/** The authoritative config: settings section when attached, else composition. */
|
|
1467
2105
|
let configSource = () => config;
|
|
1468
|
-
/**
|
|
1469
|
-
let
|
|
2106
|
+
/** Whether writes go to the settings section rather than staying in memory. */
|
|
2107
|
+
let settingsAttached = false;
|
|
2108
|
+
/** The settings scope for the `lan-gateway` namespace, while one is attached. */
|
|
2109
|
+
let settingsScope;
|
|
2110
|
+
/**
|
|
2111
|
+
* The settings provider, for the one write a scope cannot express: a section
|
|
2112
|
+
* key must be *removed* to re-inherit the composition layer, and only the
|
|
2113
|
+
* provider's path-addressed `mutate` can unset one.
|
|
2114
|
+
*/
|
|
2115
|
+
let settingsProvider;
|
|
2116
|
+
/**
|
|
2117
|
+
* One queue for every lifecycle side effect. Settings changes, tool commands,
|
|
2118
|
+
* credential changes, TLS regeneration and plugin disposal all land here, so
|
|
2119
|
+
* two of them can never interleave a stop with a start.
|
|
2120
|
+
*/
|
|
2121
|
+
let lifecycle = Promise.resolve();
|
|
2122
|
+
/** Set by the dispose hook; a start that completes after it must undo itself. */
|
|
2123
|
+
let disposed = false;
|
|
1470
2124
|
const effective = () => configSource();
|
|
2125
|
+
/** Queue one lifecycle action behind every action already running. */
|
|
2126
|
+
const enqueue = (reason, action) => {
|
|
2127
|
+
lifecycle = lifecycle.then(action).catch((error) => {
|
|
2128
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
2129
|
+
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
|
|
2130
|
+
});
|
|
2131
|
+
return lifecycle;
|
|
2132
|
+
};
|
|
1471
2133
|
const startGateway = async (cfg) => {
|
|
1472
2134
|
if (gateway !== void 0) return;
|
|
1473
2135
|
const problems = gatewayStartProblems(cfg, { upstreamSessionAvailable });
|
|
1474
2136
|
if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
|
|
1475
2137
|
if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
|
|
1476
2138
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1477
|
-
const
|
|
2139
|
+
const resolved = resolveTls(cfg);
|
|
2140
|
+
const tls = resolved?.material;
|
|
1478
2141
|
const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1479
2142
|
const secureCookies = resolveSecureCookies(cfg);
|
|
1480
2143
|
const next = new LanGateway({
|
|
@@ -1486,12 +2149,21 @@ function apply(ctx, config) {
|
|
|
1486
2149
|
cookieName: cfg.cookieName,
|
|
1487
2150
|
secureCookies,
|
|
1488
2151
|
...tls !== void 0 ? { tls } : {},
|
|
1489
|
-
...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
|
|
2152
|
+
...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {},
|
|
2153
|
+
onStateChange: (updated) => {
|
|
2154
|
+
state = updated;
|
|
2155
|
+
saveState(state);
|
|
2156
|
+
}
|
|
1490
2157
|
}, state);
|
|
1491
2158
|
await next.listen();
|
|
2159
|
+
if (disposed) {
|
|
2160
|
+
await next.close();
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
1492
2163
|
gateway = next;
|
|
1493
2164
|
startedWith = listenerKey(cfg, makeRelay !== void 0);
|
|
1494
|
-
ctx.logger.info(`dsh-lan-gateway: listening on
|
|
2165
|
+
ctx.logger.info(`dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}${encryptedIngress ? "" : " (plaintext, explicit allowInsecurePlaintext)"}${makeRelay !== void 0 ? " [shared upstream session relay]" : " [no upstream session relay: base has no browser-session auth]"}`);
|
|
2166
|
+
if (resolved?.renewed === true) ctx.logger.warn("dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one — clients that had trusted the old certificate must trust the new one.");
|
|
1495
2167
|
};
|
|
1496
2168
|
const stopGateway = async () => {
|
|
1497
2169
|
const current = gateway;
|
|
@@ -1502,31 +2174,43 @@ function apply(ctx, config) {
|
|
|
1502
2174
|
ctx.logger.info("dsh-lan-gateway: stopped");
|
|
1503
2175
|
}
|
|
1504
2176
|
};
|
|
2177
|
+
/** The config the listener should be running under, intent included. */
|
|
2178
|
+
const desiredConfig = () => {
|
|
2179
|
+
const cfg = effective();
|
|
2180
|
+
if (settingsAttached) return cfg;
|
|
2181
|
+
return manualOverride === void 0 ? cfg : {
|
|
2182
|
+
...cfg,
|
|
2183
|
+
enabled: manualOverride
|
|
2184
|
+
};
|
|
2185
|
+
};
|
|
1505
2186
|
/** Reconcile the listener with the effective config (start/stop/restart). */
|
|
1506
2187
|
const syncGateway = (reason) => {
|
|
1507
|
-
|
|
2188
|
+
return enqueue(reason, async () => {
|
|
1508
2189
|
lastError = void 0;
|
|
1509
|
-
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
if (
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
await startGateway(cfg);
|
|
1518
|
-
}
|
|
1519
|
-
} catch (error) {
|
|
1520
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
1521
|
-
ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
|
|
2190
|
+
if (disposed) return;
|
|
2191
|
+
const cfg = desiredConfig();
|
|
2192
|
+
if (gateway === void 0) {
|
|
2193
|
+
if (cfg.enabled) await startGateway(cfg);
|
|
2194
|
+
} else if (!cfg.enabled) await stopGateway();
|
|
2195
|
+
else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
|
|
2196
|
+
await stopGateway();
|
|
2197
|
+
await startGateway(cfg);
|
|
1522
2198
|
}
|
|
1523
2199
|
});
|
|
1524
|
-
return syncing;
|
|
1525
2200
|
};
|
|
1526
|
-
|
|
2201
|
+
/** Record the run intent where it will survive: the settings section, or memory. */
|
|
2202
|
+
const setRunIntent = async (enabled) => {
|
|
2203
|
+
if (settingsAttached && settingsScope !== void 0) {
|
|
2204
|
+
await settingsScope.update({ enabled });
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
manualOverride = enabled;
|
|
2208
|
+
};
|
|
1527
2209
|
ctx.inject(["settings"], (sctx) => {
|
|
1528
2210
|
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
1529
2211
|
settingsScope = scope;
|
|
2212
|
+
settingsProvider = sctx.settings;
|
|
2213
|
+
settingsAttached = true;
|
|
1530
2214
|
configSource = () => scope.get();
|
|
1531
2215
|
sctx.effect(() => scope.watch(() => {
|
|
1532
2216
|
syncGateway("settings change");
|
|
@@ -1534,10 +2218,14 @@ function apply(ctx, config) {
|
|
|
1534
2218
|
sctx.effect(() => () => {
|
|
1535
2219
|
configSource = () => config;
|
|
1536
2220
|
settingsScope = void 0;
|
|
2221
|
+
settingsProvider = void 0;
|
|
2222
|
+
settingsAttached = false;
|
|
2223
|
+
syncGateway("settings detach");
|
|
1537
2224
|
});
|
|
1538
2225
|
syncGateway("settings attach");
|
|
1539
2226
|
});
|
|
1540
2227
|
ctx.inject(["connection"], (ccx) => {
|
|
2228
|
+
const generation = ++connectionGeneration;
|
|
1541
2229
|
upstreamSessionAvailable = true;
|
|
1542
2230
|
ctx.logger.info("dsh-lan-gateway: connection service attached; upstream session relay enabled");
|
|
1543
2231
|
makeRelay = (dshPort) => new UpstreamSessionRelay({
|
|
@@ -1545,9 +2233,26 @@ function apply(ctx, config) {
|
|
|
1545
2233
|
authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
|
|
1546
2234
|
log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`)
|
|
1547
2235
|
});
|
|
2236
|
+
ccx.effect(() => () => {
|
|
2237
|
+
if (generation !== connectionGeneration) return;
|
|
2238
|
+
makeRelay = void 0;
|
|
2239
|
+
upstreamSessionAvailable = false;
|
|
2240
|
+
syncGateway("connection detach");
|
|
2241
|
+
});
|
|
1548
2242
|
syncGateway("connection attach");
|
|
1549
2243
|
});
|
|
1550
2244
|
const configRouteHandler = async (req, res) => {
|
|
2245
|
+
const snapshot = () => {
|
|
2246
|
+
const cfg = effective();
|
|
2247
|
+
return {
|
|
2248
|
+
config: cfg,
|
|
2249
|
+
running: gateway !== void 0,
|
|
2250
|
+
port: cfg.gatewayPort,
|
|
2251
|
+
tls: tlsStatusLine(cfg),
|
|
2252
|
+
upstreamSessionAvailable,
|
|
2253
|
+
lastError: lastError ?? null
|
|
2254
|
+
};
|
|
2255
|
+
};
|
|
1551
2256
|
const send = (status, body) => {
|
|
1552
2257
|
res.writeHead(status, { "content-type": "application/json" });
|
|
1553
2258
|
res.end(JSON.stringify(body));
|
|
@@ -1557,15 +2262,7 @@ function apply(ctx, config) {
|
|
|
1557
2262
|
return;
|
|
1558
2263
|
}
|
|
1559
2264
|
if (req.method === "GET") {
|
|
1560
|
-
|
|
1561
|
-
send(200, {
|
|
1562
|
-
config: cfg,
|
|
1563
|
-
running: gateway !== void 0,
|
|
1564
|
-
port: cfg.gatewayPort,
|
|
1565
|
-
tls: tlsStatusLine(cfg),
|
|
1566
|
-
upstreamSessionAvailable,
|
|
1567
|
-
lastError: lastError ?? null
|
|
1568
|
-
});
|
|
2265
|
+
send(200, snapshot());
|
|
1569
2266
|
return;
|
|
1570
2267
|
}
|
|
1571
2268
|
if (req.method !== "POST") {
|
|
@@ -1585,41 +2282,35 @@ function apply(ctx, config) {
|
|
|
1585
2282
|
send(400, { error: "body must be a config object" });
|
|
1586
2283
|
return;
|
|
1587
2284
|
}
|
|
1588
|
-
|
|
1589
|
-
try {
|
|
1590
|
-
candidate = Config(submitted);
|
|
1591
|
-
} catch (error) {
|
|
1592
|
-
send(400, { error: error instanceof Error ? error.message : String(error) });
|
|
1593
|
-
return;
|
|
1594
|
-
}
|
|
1595
|
-
if (settingsScope === void 0) {
|
|
2285
|
+
if (settingsProvider === void 0) {
|
|
1596
2286
|
send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
|
|
1597
2287
|
return;
|
|
1598
2288
|
}
|
|
2289
|
+
const { patch, clear, unknown } = buildConfigPatch(submitted);
|
|
2290
|
+
const candidate = Config({
|
|
2291
|
+
...effective(),
|
|
2292
|
+
...patch
|
|
2293
|
+
});
|
|
1599
2294
|
const structural = candidate.authRequired === false || candidate.lanPasswordless && !upstreamSessionAvailable;
|
|
1600
2295
|
const problems = gatewayStartProblems(candidate, { upstreamSessionAvailable });
|
|
1601
2296
|
if (structural || candidate.enabled && problems.length > 0) {
|
|
1602
2297
|
send(409, { error: `config cannot start: ${problems.join(" ")}` });
|
|
1603
2298
|
return;
|
|
1604
2299
|
}
|
|
1605
|
-
const section = {};
|
|
1606
|
-
for (const [key, value] of Object.entries(candidate)) {
|
|
1607
|
-
if (value === null || value === void 0) continue;
|
|
1608
|
-
if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) continue;
|
|
1609
|
-
section[key] = value;
|
|
1610
|
-
}
|
|
1611
2300
|
try {
|
|
1612
|
-
|
|
2301
|
+
const ops = [...Object.entries(patch).map(([key, value]) => ({
|
|
2302
|
+
op: "set",
|
|
2303
|
+
path: [key],
|
|
2304
|
+
value
|
|
2305
|
+
})), ...clear.map((key) => ({
|
|
2306
|
+
op: "unset",
|
|
2307
|
+
path: [key]
|
|
2308
|
+
}))];
|
|
2309
|
+
if (ops.length > 0) await settingsProvider.mutate(NS, ops);
|
|
1613
2310
|
await syncGateway("config route save");
|
|
1614
|
-
const
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
running: gateway !== void 0,
|
|
1618
|
-
port: cfg.gatewayPort,
|
|
1619
|
-
tls: tlsStatusLine(cfg),
|
|
1620
|
-
upstreamSessionAvailable,
|
|
1621
|
-
lastError: lastError ?? null
|
|
1622
|
-
});
|
|
2311
|
+
const next = { ...snapshot() };
|
|
2312
|
+
if (unknown.length > 0) next["ignored"] = unknown;
|
|
2313
|
+
send(200, next);
|
|
1623
2314
|
} catch (error) {
|
|
1624
2315
|
send(409, { error: error instanceof Error ? error.message : String(error) });
|
|
1625
2316
|
}
|
|
@@ -1631,27 +2322,27 @@ function apply(ctx, config) {
|
|
|
1631
2322
|
}), "dsh-lan-gateway: config route");
|
|
1632
2323
|
ctx.tools.register(lanGatewayTool({
|
|
1633
2324
|
status() {
|
|
1634
|
-
const cfg =
|
|
2325
|
+
const cfg = desiredConfig();
|
|
1635
2326
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1636
2327
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1637
2328
|
return {
|
|
1638
2329
|
ok: true,
|
|
1639
|
-
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on
|
|
2330
|
+
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 && !settingsAttached ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
|
|
1640
2331
|
};
|
|
1641
2332
|
},
|
|
1642
2333
|
async enable() {
|
|
1643
|
-
|
|
2334
|
+
await setRunIntent(true);
|
|
1644
2335
|
await syncGateway("tool enable");
|
|
1645
2336
|
return gateway !== void 0 ? {
|
|
1646
2337
|
ok: true,
|
|
1647
|
-
message: `Gateway enabled: listening on
|
|
2338
|
+
message: `Gateway enabled: listening on ${gateway.boundAddress()}`
|
|
1648
2339
|
} : {
|
|
1649
2340
|
ok: false,
|
|
1650
2341
|
message: `Failed to enable gateway: ${lastError ?? "unknown error"}`
|
|
1651
2342
|
};
|
|
1652
2343
|
},
|
|
1653
2344
|
async disable() {
|
|
1654
|
-
|
|
2345
|
+
await setRunIntent(false);
|
|
1655
2346
|
await syncGateway("tool disable");
|
|
1656
2347
|
return {
|
|
1657
2348
|
ok: true,
|
|
@@ -1664,23 +2355,21 @@ function apply(ctx, config) {
|
|
|
1664
2355
|
message: "Password must be at least 8 characters."
|
|
1665
2356
|
};
|
|
1666
2357
|
const setting = password !== void 0 && password.length > 0;
|
|
1667
|
-
const
|
|
1668
|
-
state = setPassword(state, setting ? password : void 0);
|
|
2358
|
+
const hadPassword = state.password !== void 0;
|
|
2359
|
+
state = await setPassword(state, setting ? password : void 0);
|
|
1669
2360
|
saveState(state);
|
|
1670
2361
|
gateway?.setState(state);
|
|
1671
2362
|
if (!setting) {
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
await stopGateway();
|
|
2363
|
+
await setRunIntent(false);
|
|
2364
|
+
return enqueue("password cleared", async () => {
|
|
2365
|
+
if (gateway !== void 0) await stopGateway();
|
|
1675
2366
|
lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
|
|
1676
|
-
|
|
1677
|
-
}
|
|
1678
|
-
return {
|
|
2367
|
+
}).then(() => ({
|
|
1679
2368
|
ok: true,
|
|
1680
2369
|
message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
|
|
1681
|
-
};
|
|
2370
|
+
}));
|
|
1682
2371
|
}
|
|
1683
|
-
|
|
2372
|
+
if (!hadPassword) await syncGateway("password set");
|
|
1684
2373
|
return {
|
|
1685
2374
|
ok: true,
|
|
1686
2375
|
message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
|
|
@@ -1711,32 +2400,38 @@ function apply(ctx, config) {
|
|
|
1711
2400
|
ok: false,
|
|
1712
2401
|
message: "tlsSelfSignedHosts must name at least one host (DNS name or IP)."
|
|
1713
2402
|
};
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2403
|
+
let failure;
|
|
2404
|
+
await enqueue("tls regenerate", async () => {
|
|
2405
|
+
try {
|
|
2406
|
+
regenerateSelfSigned({
|
|
2407
|
+
hosts,
|
|
2408
|
+
days: cfg.tlsCertMaxAgeDays
|
|
2409
|
+
});
|
|
2410
|
+
if (gateway !== void 0) {
|
|
2411
|
+
await stopGateway();
|
|
2412
|
+
await startGateway(effective());
|
|
2413
|
+
}
|
|
1722
2414
|
lastError = void 0;
|
|
2415
|
+
} catch (error) {
|
|
2416
|
+
failure = error instanceof Error ? error.message : String(error);
|
|
1723
2417
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
};
|
|
1733
|
-
}
|
|
2418
|
+
});
|
|
2419
|
+
return failure === void 0 ? {
|
|
2420
|
+
ok: true,
|
|
2421
|
+
message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
|
|
2422
|
+
} : {
|
|
2423
|
+
ok: false,
|
|
2424
|
+
message: `Failed to regenerate TLS certificate: ${failure}`
|
|
2425
|
+
};
|
|
1734
2426
|
}
|
|
1735
2427
|
}));
|
|
1736
2428
|
ctx.effect(() => {
|
|
1737
2429
|
syncGateway("boot");
|
|
1738
|
-
return
|
|
2430
|
+
return async () => {
|
|
2431
|
+
disposed = true;
|
|
2432
|
+
await enqueue("dispose", stopGateway);
|
|
2433
|
+
};
|
|
1739
2434
|
}, "dsh-lan-gateway: listener lifecycle");
|
|
1740
2435
|
}
|
|
1741
2436
|
//#endregion
|
|
1742
|
-
export { Config, apply, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
|
|
2437
|
+
export { Config, apply, buildConfigPatch, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
|