@riceawa/dsh-lan-gateway 0.5.3 → 0.5.4
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 +47 -40
- package/lib/index.d.ts +13 -2
- package/lib/index.js +375 -74
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +98 -26
- package/src/gateway.ts +238 -28
- package/src/index.ts +42 -13
- package/src/state.ts +77 -7
- package/src/tls.ts +33 -0
- package/src/upstream-session.ts +17 -2
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
1
|
+
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scrypt, scryptSync, 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";
|
|
@@ -73,9 +73,19 @@ function classifySource(remoteAddress, lanCidrs = DEFAULT_LAN_CIDR_STRINGS) {
|
|
|
73
73
|
return "internet";
|
|
74
74
|
}
|
|
75
75
|
if (address === "::1") return "loopback";
|
|
76
|
-
if (address
|
|
76
|
+
if (inIpv6LinkLocal(address)) return "lan";
|
|
77
77
|
return "internet";
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
|
|
81
|
+
* `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
|
|
82
|
+
* test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
|
|
83
|
+
*/
|
|
84
|
+
function inIpv6LinkLocal(address) {
|
|
85
|
+
const match = /^([0-9a-fA-F]{1,4}):/.exec(address);
|
|
86
|
+
if (match === null) return false;
|
|
87
|
+
return (Number.parseInt(match[1], 16) & 65472) === 65152;
|
|
88
|
+
}
|
|
79
89
|
/** Encode a byte buffer as URL-safe base64 without padding. */
|
|
80
90
|
function base64url(input) {
|
|
81
91
|
return input.toString("base64url");
|
|
@@ -88,24 +98,29 @@ function base64url(input) {
|
|
|
88
98
|
* cookie whose epoch no longer matches the live state is rejected by
|
|
89
99
|
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
90
100
|
* do not participate in revocation.
|
|
101
|
+
* @param sid - optional per-session id (see {@link SessionClaims.sid}).
|
|
91
102
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
92
103
|
*/
|
|
93
|
-
function signCookie(secret, expiresMs, epoch = 0) {
|
|
94
|
-
const
|
|
104
|
+
function signCookie(secret, expiresMs, epoch = 0, sid) {
|
|
105
|
+
const claims = sid === void 0 ? {
|
|
95
106
|
exp: expiresMs,
|
|
96
107
|
epoch
|
|
97
|
-
}
|
|
108
|
+
} : {
|
|
109
|
+
exp: expiresMs,
|
|
110
|
+
epoch,
|
|
111
|
+
sid
|
|
112
|
+
};
|
|
113
|
+
const payload = base64url(Buffer.from(JSON.stringify(claims)));
|
|
98
114
|
return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
|
|
99
115
|
}
|
|
100
116
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
117
|
+
* Verify a cookie's signature, expiry and epoch.
|
|
118
|
+
* @returns the claims it carries, or undefined when it is not a valid session.
|
|
104
119
|
*/
|
|
105
|
-
function
|
|
106
|
-
if (value === void 0) return
|
|
120
|
+
function verifySession(secret, value, now, epoch = 0) {
|
|
121
|
+
if (value === void 0) return void 0;
|
|
107
122
|
const dot = value.indexOf(".");
|
|
108
|
-
if (dot === -1) return
|
|
123
|
+
if (dot === -1) return void 0;
|
|
109
124
|
const payload = value.slice(0, dot);
|
|
110
125
|
const sig = value.slice(dot + 1);
|
|
111
126
|
const expected = createHmac("sha256", secret).update(payload).digest();
|
|
@@ -113,16 +128,22 @@ function verifyCookie(secret, value, now, epoch = 0) {
|
|
|
113
128
|
try {
|
|
114
129
|
actual = Buffer.from(sig, "base64url");
|
|
115
130
|
} catch {
|
|
116
|
-
return
|
|
131
|
+
return;
|
|
117
132
|
}
|
|
118
|
-
if (expected.length !== actual.length) return
|
|
119
|
-
if (!timingSafeEqual(expected, actual)) return
|
|
133
|
+
if (expected.length !== actual.length) return void 0;
|
|
134
|
+
if (!timingSafeEqual(expected, actual)) return void 0;
|
|
120
135
|
try {
|
|
121
136
|
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
122
|
-
if (typeof decoded.exp !== "number" || decoded.exp <= now) return
|
|
123
|
-
|
|
137
|
+
if (typeof decoded.exp !== "number" || decoded.exp <= now) return void 0;
|
|
138
|
+
const cookieEpoch = typeof decoded.epoch === "number" ? decoded.epoch : 0;
|
|
139
|
+
if (cookieEpoch !== epoch) return void 0;
|
|
140
|
+
return {
|
|
141
|
+
exp: decoded.exp,
|
|
142
|
+
epoch: cookieEpoch,
|
|
143
|
+
...typeof decoded.sid === "string" ? { sid: decoded.sid } : {}
|
|
144
|
+
};
|
|
124
145
|
} catch {
|
|
125
|
-
return
|
|
146
|
+
return;
|
|
126
147
|
}
|
|
127
148
|
}
|
|
128
149
|
/**
|
|
@@ -143,10 +164,18 @@ function originMatchesHost(origin, host) {
|
|
|
143
164
|
}
|
|
144
165
|
}
|
|
145
166
|
/** A token bucket limiter keyed by source address. */
|
|
146
|
-
var RateLimiter = class {
|
|
167
|
+
var RateLimiter = class RateLimiter {
|
|
147
168
|
maxTokens;
|
|
148
169
|
windowMs;
|
|
170
|
+
/**
|
|
171
|
+
* Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
|
|
172
|
+
* `prune` runs, and a spray from many distinct addresses inside one window
|
|
173
|
+
* outruns it, so the map also sheds its soonest-expiring entries past this.
|
|
174
|
+
*/
|
|
175
|
+
static MAX_BUCKETS = 1e4;
|
|
149
176
|
buckets = /* @__PURE__ */ new Map();
|
|
177
|
+
/** Epoch millis at which the next opportunistic sweep is due. */
|
|
178
|
+
nextPruneAt = 0;
|
|
150
179
|
constructor(maxTokens, windowMs) {
|
|
151
180
|
this.maxTokens = maxTokens;
|
|
152
181
|
this.windowMs = windowMs;
|
|
@@ -158,24 +187,33 @@ var RateLimiter = class {
|
|
|
158
187
|
*/
|
|
159
188
|
allow(key) {
|
|
160
189
|
const now = Date.now();
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.
|
|
164
|
-
tokens: this.maxTokens - 1,
|
|
165
|
-
resetAt: now + this.windowMs
|
|
166
|
-
});
|
|
167
|
-
return true;
|
|
190
|
+
if (now >= this.nextPruneAt) {
|
|
191
|
+
this.prune(now);
|
|
192
|
+
this.nextPruneAt = now + this.windowMs;
|
|
168
193
|
}
|
|
169
|
-
|
|
170
|
-
|
|
194
|
+
const existing = this.buckets.get(key);
|
|
195
|
+
if (existing !== void 0 && existing.resetAt > now) {
|
|
196
|
+
if (existing.tokens <= 0) return false;
|
|
197
|
+
existing.tokens -= 1;
|
|
171
198
|
return true;
|
|
172
199
|
}
|
|
173
|
-
|
|
200
|
+
if (existing === void 0 && this.buckets.size >= RateLimiter.MAX_BUCKETS) this.evictSoonestToExpire();
|
|
201
|
+
this.buckets.set(key, {
|
|
202
|
+
tokens: this.maxTokens - 1,
|
|
203
|
+
resetAt: now + this.windowMs
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
174
206
|
}
|
|
175
|
-
/** Drop expired buckets to bound memory. */
|
|
207
|
+
/** Drop expired buckets to bound memory. Called from {@link allow}. */
|
|
176
208
|
prune(now = Date.now()) {
|
|
177
209
|
for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
|
|
178
210
|
}
|
|
211
|
+
/** Trim back to 90% of the ceiling, oldest expiry first. */
|
|
212
|
+
evictSoonestToExpire() {
|
|
213
|
+
const target = Math.floor(RateLimiter.MAX_BUCKETS * .9);
|
|
214
|
+
const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
215
|
+
for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) this.buckets.delete(key);
|
|
216
|
+
}
|
|
179
217
|
};
|
|
180
218
|
//#endregion
|
|
181
219
|
//#region src/login.ts
|
|
@@ -282,13 +320,27 @@ function stateDir(home = homedir()) {
|
|
|
282
320
|
return join(home, ".dsh", "lan-gateway");
|
|
283
321
|
}
|
|
284
322
|
const STATE_FILENAME = "state.json";
|
|
285
|
-
/**
|
|
286
|
-
function
|
|
323
|
+
/** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
|
|
324
|
+
function deriveKey(password, salt, keylen) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
scrypt(password, salt, keylen, (error, derived) => {
|
|
327
|
+
if (error !== null) reject(error);
|
|
328
|
+
else resolve(derived);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Whether a password is present and passes scrypt verification. Asynchronous
|
|
334
|
+
* on purpose: `scryptSync` occupies the event loop for tens of milliseconds
|
|
335
|
+
* per attempt, and that loop is shared with the dsh process the gateway is
|
|
336
|
+
* forwarding to.
|
|
337
|
+
*/
|
|
338
|
+
async function verifyPassword(state, password) {
|
|
287
339
|
if (state.password === void 0) return false;
|
|
288
340
|
const { hash, salt } = state.password;
|
|
289
341
|
try {
|
|
290
342
|
const expected = Buffer.from(hash, "hex");
|
|
291
|
-
const actual =
|
|
343
|
+
const actual = await deriveKey(password, Buffer.from(salt, "hex"), expected.length);
|
|
292
344
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
293
345
|
} catch {
|
|
294
346
|
return false;
|
|
@@ -301,13 +353,10 @@ function verifyPassword(state, password) {
|
|
|
301
353
|
*/
|
|
302
354
|
function setPassword(state, password) {
|
|
303
355
|
const base = {
|
|
304
|
-
|
|
356
|
+
cookieSecret: state.cookieSecret,
|
|
305
357
|
sessionEpoch: state.sessionEpoch + 1
|
|
306
358
|
};
|
|
307
|
-
if (password === void 0) return
|
|
308
|
-
cookieSecret: base.cookieSecret,
|
|
309
|
-
sessionEpoch: base.sessionEpoch
|
|
310
|
-
};
|
|
359
|
+
if (password === void 0) return base;
|
|
311
360
|
const salt = randomBytes(16);
|
|
312
361
|
const hash = scryptSync(password, salt, 64);
|
|
313
362
|
return {
|
|
@@ -318,12 +367,45 @@ function setPassword(state, password) {
|
|
|
318
367
|
}
|
|
319
368
|
};
|
|
320
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* Record a session id as revoked.
|
|
372
|
+
* @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
|
|
373
|
+
* rejected on its own account, so the entry is no longer needed; dropping
|
|
374
|
+
* expired entries here is what keeps the list bounded.
|
|
375
|
+
*/
|
|
376
|
+
function revokeSession(state, sid, expiresMs) {
|
|
377
|
+
const now = Date.now();
|
|
378
|
+
const revoked = {};
|
|
379
|
+
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) if (exp > now) revoked[id] = exp;
|
|
380
|
+
revoked[sid] = expiresMs;
|
|
381
|
+
return {
|
|
382
|
+
...state,
|
|
383
|
+
revokedSessions: revoked
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/** Whether `sid` names a session that has been signed out. */
|
|
387
|
+
function isSessionRevoked(state, sid) {
|
|
388
|
+
if (sid === void 0) return false;
|
|
389
|
+
return Object.hasOwn(state.revokedSessions ?? {}, sid);
|
|
390
|
+
}
|
|
321
391
|
function defaultState() {
|
|
322
392
|
return {
|
|
323
393
|
cookieSecret: randomBytes(32).toString("base64"),
|
|
324
394
|
sessionEpoch: 0
|
|
325
395
|
};
|
|
326
396
|
}
|
|
397
|
+
/** Keep the still-live entries of a persisted revocation list, or undefined. */
|
|
398
|
+
function parseRevokedSessions(raw) {
|
|
399
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
const out = {};
|
|
402
|
+
let anyLive = false;
|
|
403
|
+
for (const [sid, exp] of Object.entries(raw)) if (typeof exp === "number" && Number.isFinite(exp) && exp > now) {
|
|
404
|
+
out[sid] = exp;
|
|
405
|
+
anyLive = true;
|
|
406
|
+
}
|
|
407
|
+
return anyLive ? out : void 0;
|
|
408
|
+
}
|
|
327
409
|
/** Load state; on first run (or a corrupt file) generate a fresh secret. */
|
|
328
410
|
function loadState(home = homedir()) {
|
|
329
411
|
const dir = stateDir(home);
|
|
@@ -337,6 +419,8 @@ function loadState(home = homedir()) {
|
|
|
337
419
|
sessionEpoch
|
|
338
420
|
};
|
|
339
421
|
if (parsed.password !== void 0) base.password = parsed.password;
|
|
422
|
+
const revoked = parseRevokedSessions(parsed.revokedSessions);
|
|
423
|
+
if (revoked !== void 0) base.revokedSessions = revoked;
|
|
340
424
|
return base;
|
|
341
425
|
}
|
|
342
426
|
return defaultState();
|
|
@@ -359,8 +443,9 @@ function saveState(state, home = homedir()) {
|
|
|
359
443
|
//#endregion
|
|
360
444
|
//#region src/gateway.ts
|
|
361
445
|
/**
|
|
362
|
-
* The reverse-proxy gateway: a `node:http(s)` server bound to
|
|
363
|
-
*
|
|
446
|
+
* The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
|
|
447
|
+
* address (dual-stack, so IPv6 clients reach it too) that forwards every
|
|
448
|
+
* request to the loopback dsh web server.
|
|
364
449
|
*
|
|
365
450
|
* Security model (post-QVD / session-base):
|
|
366
451
|
* - Source is classified from `socket.remoteAddress` only (never
|
|
@@ -383,9 +468,10 @@ function saveState(state, home = homedir()) {
|
|
|
383
468
|
* authority-bound session cookie). The gateway therefore relays one shared
|
|
384
469
|
* upstream session acquired through the launch-token exchange and replays it
|
|
385
470
|
* on every forwarded request. See `upstream-session.ts`.
|
|
386
|
-
* - Sessions
|
|
387
|
-
*
|
|
388
|
-
*
|
|
471
|
+
* - Sessions are revocable two ways. Each carries a random id, so signing out
|
|
472
|
+
* retires exactly that session and the WebSockets it opened; and each
|
|
473
|
+
* carries a revocation epoch, so a password change or secret rotation kills
|
|
474
|
+
* every session at once — cookie, socket, and all.
|
|
389
475
|
*
|
|
390
476
|
* @module @riceawa/dsh-lan-gateway/gateway
|
|
391
477
|
*/
|
|
@@ -398,14 +484,89 @@ const READ_ONLY_METHODS$1 = /* @__PURE__ */ new Set([
|
|
|
398
484
|
"HEAD",
|
|
399
485
|
"OPTIONS"
|
|
400
486
|
]);
|
|
487
|
+
/** The upstream browser-session cookie name prefix; the relay owns this namespace. */
|
|
488
|
+
const UPSTREAM_COOKIE_PREFIX$1 = "dsh-auth-";
|
|
489
|
+
/**
|
|
490
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
491
|
+
* the non-standard proxy-connection.
|
|
492
|
+
*/
|
|
493
|
+
const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
494
|
+
"connection",
|
|
495
|
+
"keep-alive",
|
|
496
|
+
"proxy-authenticate",
|
|
497
|
+
"proxy-authorization",
|
|
498
|
+
"proxy-connection",
|
|
499
|
+
"te",
|
|
500
|
+
"trailer",
|
|
501
|
+
"transfer-encoding",
|
|
502
|
+
"upgrade"
|
|
503
|
+
]);
|
|
504
|
+
/**
|
|
505
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
506
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
507
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
508
|
+
*/
|
|
509
|
+
const FORWARDING_HEADERS = [
|
|
510
|
+
"forwarded",
|
|
511
|
+
"x-forwarded-for",
|
|
512
|
+
"x-forwarded-host",
|
|
513
|
+
"x-forwarded-port",
|
|
514
|
+
"x-forwarded-proto",
|
|
515
|
+
"x-real-ip"
|
|
516
|
+
];
|
|
517
|
+
/** Whether a Cookie fragment names the upstream session cookie. */
|
|
518
|
+
function isUpstreamSessionPair(pair) {
|
|
519
|
+
return pair.startsWith(UPSTREAM_COOKIE_PREFIX$1);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
523
|
+
* (possibly '').
|
|
524
|
+
*
|
|
525
|
+
* `attachUpstreamSession` appends the relay's session to the client's own
|
|
526
|
+
* cookie, and upstream reads the FIRST name match. A client that holds any
|
|
527
|
+
* `dsh-auth-<hash>` — typically one minted before dsh's signing secret was
|
|
528
|
+
* reset, so still present but no longer verifying — would therefore shadow the
|
|
529
|
+
* relay's session on every request. That draws a 401, the gateway reads the
|
|
530
|
+
* 401 as "upstream revoked our session" and discards it, the next request
|
|
531
|
+
* re-acquires, and the client's stale cookie shadows that one too: a loop that
|
|
532
|
+
* never converges. Stripping the namespace makes the relay's copy the only one.
|
|
533
|
+
*/
|
|
534
|
+
function withoutUpstreamSessionPairs(cookie) {
|
|
535
|
+
return cookie.split(";").map((pair) => pair.trim()).filter((pair) => pair !== "" && !isUpstreamSessionPair(pair)).join("; ");
|
|
536
|
+
}
|
|
401
537
|
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
402
538
|
function isOwnedPath(pathname) {
|
|
403
539
|
return pathname === "/lan-gateway" || pathname.startsWith("/lan-gateway/");
|
|
404
540
|
}
|
|
405
|
-
/**
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
541
|
+
/**
|
|
542
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
543
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
544
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
545
|
+
*
|
|
546
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
547
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
548
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
549
|
+
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
550
|
+
* config route once Host has been rewritten to loopback. Forwarding still
|
|
551
|
+
* relays the raw target: dsh applies the same normalization itself.
|
|
552
|
+
*
|
|
553
|
+
* WHATWG parsing does not drop a trailing slash, and neither does dsh's
|
|
554
|
+
* router, so `/__login/` is not the login page to either of them. The gateway
|
|
555
|
+
* recognizes its own surfaces there anyway: `/__logout/` must still sign out,
|
|
556
|
+
* and `/lan-gateway/config/` must be refused rather than relayed into dsh's
|
|
557
|
+
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
558
|
+
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
559
|
+
*/
|
|
560
|
+
function pathOf$1(url) {
|
|
561
|
+
try {
|
|
562
|
+
return new URL(url, "http://gateway.invalid").pathname.replace(/\/+$/, "") || "/";
|
|
563
|
+
} catch {
|
|
564
|
+
return url;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** A fresh per-session id: 128 random bits, URL-safe. */
|
|
568
|
+
function newSessionId() {
|
|
569
|
+
return randomBytes(16).toString("base64url");
|
|
409
570
|
}
|
|
410
571
|
/**
|
|
411
572
|
* The running gateway: owns the HTTP server and the auth state needed per
|
|
@@ -418,8 +579,14 @@ var LanGateway = class {
|
|
|
418
579
|
loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
|
|
419
580
|
state;
|
|
420
581
|
disposed = false;
|
|
421
|
-
/**
|
|
422
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Established WebSockets (upgraded client sockets), each keyed by the session
|
|
584
|
+
* that opened it. A socket outlives the request that authenticated it, so it
|
|
585
|
+
* has to be closable by session: on an epoch bump every socket dies, and on
|
|
586
|
+
* sign-out only that session's. The value is undefined for a cookie minted
|
|
587
|
+
* before per-session ids existed, which only a wholesale revocation reaches.
|
|
588
|
+
*/
|
|
589
|
+
activeDuplexes = /* @__PURE__ */ new Map();
|
|
423
590
|
constructor(config, state) {
|
|
424
591
|
this.config = config;
|
|
425
592
|
this.state = state;
|
|
@@ -439,7 +606,14 @@ var LanGateway = class {
|
|
|
439
606
|
if (state.sessionEpoch !== this.state.sessionEpoch) this.destroyActiveDuplexes();
|
|
440
607
|
this.state = state;
|
|
441
608
|
}
|
|
442
|
-
/**
|
|
609
|
+
/**
|
|
610
|
+
* Start listening on the configured port. The listener is dual-stack: with
|
|
611
|
+
* no host given, node binds the unspecified IPv6 address `::` — which also
|
|
612
|
+
* accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
|
|
613
|
+
* unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
|
|
614
|
+
* not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
|
|
615
|
+
* unable to reach a gateway that classifies them.
|
|
616
|
+
*/
|
|
443
617
|
async listen() {
|
|
444
618
|
return new Promise((resolve, reject) => {
|
|
445
619
|
const onError = (err) => {
|
|
@@ -452,9 +626,15 @@ var LanGateway = class {
|
|
|
452
626
|
};
|
|
453
627
|
this.server.once("error", onError);
|
|
454
628
|
this.server.once("listening", onListening);
|
|
455
|
-
this.server.listen(this.config.gatewayPort
|
|
629
|
+
this.server.listen(this.config.gatewayPort);
|
|
456
630
|
});
|
|
457
631
|
}
|
|
632
|
+
/** The address actually bound, for logs and status (never a claim about it). */
|
|
633
|
+
boundAddress() {
|
|
634
|
+
const address = this.server.address();
|
|
635
|
+
if (address === null || typeof address === "string") return `port ${this.config.gatewayPort}`;
|
|
636
|
+
return `${address.family === "IPv6" ? `[${address.address}]` : address.address}:${address.port}`;
|
|
637
|
+
}
|
|
458
638
|
/** Close the server, drop upgraded sockets, and stop accepting connections. */
|
|
459
639
|
async close() {
|
|
460
640
|
if (this.disposed) return;
|
|
@@ -466,11 +646,19 @@ var LanGateway = class {
|
|
|
466
646
|
});
|
|
467
647
|
}
|
|
468
648
|
destroyActiveDuplexes() {
|
|
469
|
-
for (const socket of this.activeDuplexes) socket.destroy();
|
|
649
|
+
for (const socket of this.activeDuplexes.keys()) socket.destroy();
|
|
470
650
|
this.activeDuplexes.clear();
|
|
471
651
|
}
|
|
472
|
-
|
|
473
|
-
|
|
652
|
+
/** Close the sockets one session opened, so signing out ends its live streams too. */
|
|
653
|
+
destroyDuplexesFor(sid) {
|
|
654
|
+
for (const [socket, owner] of this.activeDuplexes) {
|
|
655
|
+
if (owner !== sid) continue;
|
|
656
|
+
this.activeDuplexes.delete(socket);
|
|
657
|
+
socket.destroy();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
trackDuplex(socket, sid) {
|
|
661
|
+
this.activeDuplexes.set(socket, sid);
|
|
474
662
|
socket.on("close", () => {
|
|
475
663
|
this.activeDuplexes.delete(socket);
|
|
476
664
|
});
|
|
@@ -487,10 +675,21 @@ var LanGateway = class {
|
|
|
487
675
|
if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
|
|
488
676
|
}
|
|
489
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* The session a request carries, or undefined when it presents none, presents
|
|
680
|
+
* one that no longer verifies under the current epoch, or presents one whose
|
|
681
|
+
* id has been signed out.
|
|
682
|
+
*/
|
|
683
|
+
session(req) {
|
|
684
|
+
const cookie = this.sessionCookie(req);
|
|
685
|
+
if (cookie === void 0) return void 0;
|
|
686
|
+
const claims = verifySession(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
|
|
687
|
+
if (claims === void 0) return void 0;
|
|
688
|
+
return isSessionRevoked(this.state, claims.sid) ? void 0 : claims;
|
|
689
|
+
}
|
|
490
690
|
/** Whether a request carries a session valid under the current epoch. */
|
|
491
691
|
authorized(req) {
|
|
492
|
-
|
|
493
|
-
return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now(), this.state.sessionEpoch);
|
|
692
|
+
return this.session(req) !== void 0;
|
|
494
693
|
}
|
|
495
694
|
/** Whether this source must present a gateway session (default: everyone). */
|
|
496
695
|
requiresLogin(source) {
|
|
@@ -539,7 +738,7 @@ var LanGateway = class {
|
|
|
539
738
|
/** Handle one HTTP request: anonymous allowlist → owned-path refuse → session gate → same-site gate → relay. */
|
|
540
739
|
async handleHttp(req, res) {
|
|
541
740
|
const url = req.url ?? "/";
|
|
542
|
-
const pathname = pathOf(url);
|
|
741
|
+
const pathname = pathOf$1(url);
|
|
543
742
|
const source = this.sourceOf(req);
|
|
544
743
|
if (pathname === "/__login") {
|
|
545
744
|
this.handleLogin(req, res);
|
|
@@ -582,7 +781,7 @@ var LanGateway = class {
|
|
|
582
781
|
this.serveLoginError(res, "Too many attempts — please wait a minute.");
|
|
583
782
|
return;
|
|
584
783
|
}
|
|
585
|
-
readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
|
|
784
|
+
readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
|
|
586
785
|
if (body === void 0) return;
|
|
587
786
|
let password;
|
|
588
787
|
try {
|
|
@@ -590,22 +789,36 @@ var LanGateway = class {
|
|
|
590
789
|
} catch {
|
|
591
790
|
password = void 0;
|
|
592
791
|
}
|
|
593
|
-
if (password
|
|
792
|
+
if (!(password !== void 0 && await verifyPassword(this.state, password))) {
|
|
594
793
|
this.serveLoginError(res, "Incorrect password.");
|
|
595
794
|
return;
|
|
596
795
|
}
|
|
597
796
|
const maxAgeSeconds = this.config.cookieMaxAgeDays * 86400;
|
|
598
797
|
const expiresMs = Date.now() + maxAgeSeconds * 1e3;
|
|
599
|
-
const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch);
|
|
798
|
+
const cookie = signCookie(this.state.cookieSecret, expiresMs, this.state.sessionEpoch, newSessionId());
|
|
600
799
|
res.writeHead(302, {
|
|
601
800
|
location: "/",
|
|
602
801
|
...this.securityHeaders(),
|
|
603
802
|
"set-cookie": [this.sessionSetCookie(cookie, maxAgeSeconds)]
|
|
604
803
|
});
|
|
605
804
|
res.end();
|
|
805
|
+
}).catch(() => {
|
|
806
|
+
if (!res.headersSent) {
|
|
807
|
+
res.writeHead(500, this.securityHeaders());
|
|
808
|
+
res.end("login failed");
|
|
809
|
+
}
|
|
606
810
|
});
|
|
607
811
|
}
|
|
608
|
-
/**
|
|
812
|
+
/**
|
|
813
|
+
* POST /__logout: revoke this session and clear the cookie.
|
|
814
|
+
*
|
|
815
|
+
* The session is stateless, so clearing the cookie only stops the browser
|
|
816
|
+
* that ran the sign-out; a copy of the same value held anywhere else would
|
|
817
|
+
* keep working until it expired. Revoking the id in the cookie retires that
|
|
818
|
+
* one session for good, and leaves the account's other sessions — other
|
|
819
|
+
* devices, other browsers — alone. Bumping the session epoch here would be
|
|
820
|
+
* the blunter instrument: it signs out every session there is.
|
|
821
|
+
*/
|
|
609
822
|
handleLogout(req, res) {
|
|
610
823
|
if (req.method !== "POST") {
|
|
611
824
|
res.writeHead(405, { allow: "POST" });
|
|
@@ -617,6 +830,12 @@ var LanGateway = class {
|
|
|
617
830
|
res.end("forbidden");
|
|
618
831
|
return;
|
|
619
832
|
}
|
|
833
|
+
const claims = this.session(req);
|
|
834
|
+
if (claims?.sid !== void 0) {
|
|
835
|
+
this.state = revokeSession(this.state, claims.sid, claims.exp);
|
|
836
|
+
this.config.onStateChange?.(this.state);
|
|
837
|
+
this.destroyDuplexesFor(claims.sid);
|
|
838
|
+
}
|
|
620
839
|
res.writeHead(302, {
|
|
621
840
|
location: "/",
|
|
622
841
|
...this.securityHeaders(),
|
|
@@ -634,6 +853,12 @@ var LanGateway = class {
|
|
|
634
853
|
delete headers.connection;
|
|
635
854
|
delete headers.upgrade;
|
|
636
855
|
}
|
|
856
|
+
for (const name of FORWARDING_HEADERS) delete headers[name];
|
|
857
|
+
if (typeof headers.cookie === "string") {
|
|
858
|
+
const kept = withoutUpstreamSessionPairs(headers.cookie);
|
|
859
|
+
if (kept === "") delete headers.cookie;
|
|
860
|
+
else headers.cookie = kept;
|
|
861
|
+
}
|
|
637
862
|
return headers;
|
|
638
863
|
}
|
|
639
864
|
/** Attach the shared upstream session cookie to the outbound headers, if any. */
|
|
@@ -646,6 +871,29 @@ var LanGateway = class {
|
|
|
646
871
|
headers.cookie = typeof existing === "string" && existing !== "" ? `${existing}; ${cookie}` : cookie;
|
|
647
872
|
return true;
|
|
648
873
|
}
|
|
874
|
+
/**
|
|
875
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and
|
|
876
|
+
* the upstream session cookie withheld. Upstream's one cookie-minting route
|
|
877
|
+
* is the launch-token exchange at `/`, so a client that already holds a
|
|
878
|
+
* gateway session could otherwise post the token through the gateway and
|
|
879
|
+
* walk away with a durable upstream credential the relay exists to keep on
|
|
880
|
+
* this side. Cookies from other routes (plugins) still pass through.
|
|
881
|
+
*/
|
|
882
|
+
downstreamHeaders(upstream) {
|
|
883
|
+
const headers = {};
|
|
884
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
885
|
+
if (value === void 0) continue;
|
|
886
|
+
const lower = key.toLowerCase();
|
|
887
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue;
|
|
888
|
+
if (lower === "set-cookie") {
|
|
889
|
+
const list = (Array.isArray(value) ? value : [value]).filter((entry) => !isUpstreamSessionPair(entry.trim()));
|
|
890
|
+
if (list.length > 0) headers[key] = list;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
headers[key] = value;
|
|
894
|
+
}
|
|
895
|
+
return headers;
|
|
896
|
+
}
|
|
649
897
|
/** Forward an HTTP request to dsh, replaying the shared upstream session. */
|
|
650
898
|
async relayHttp(req, res, url) {
|
|
651
899
|
const session = this.config.upstreamSession;
|
|
@@ -660,7 +908,7 @@ var LanGateway = class {
|
|
|
660
908
|
headers
|
|
661
909
|
}, (proxyRes) => {
|
|
662
910
|
if (attached && session !== void 0 && proxyRes.statusCode === 401) session.invalidate();
|
|
663
|
-
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
911
|
+
res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers));
|
|
664
912
|
proxyRes.pipe(res);
|
|
665
913
|
});
|
|
666
914
|
proxyReq.on("error", () => {
|
|
@@ -672,7 +920,7 @@ var LanGateway = class {
|
|
|
672
920
|
/** Forward a WebSocket upgrade through the same gates, splicing the duplex to dsh. */
|
|
673
921
|
async handleUpgrade(req, socket, head) {
|
|
674
922
|
const url = req.url ?? "/";
|
|
675
|
-
const pathname = pathOf(url);
|
|
923
|
+
const pathname = pathOf$1(url);
|
|
676
924
|
const source = this.sourceOf(req);
|
|
677
925
|
const refuse = (status) => {
|
|
678
926
|
socket.write(`HTTP/1.1 ${status} ${status === 401 ? "Unauthorized" : "Forbidden"}\r\nConnection: close\r\n\r\n`);
|
|
@@ -682,7 +930,8 @@ var LanGateway = class {
|
|
|
682
930
|
refuse(403);
|
|
683
931
|
return;
|
|
684
932
|
}
|
|
685
|
-
|
|
933
|
+
const claims = this.session(req);
|
|
934
|
+
if (this.requiresLogin(source) && claims === void 0) {
|
|
686
935
|
refuse(401);
|
|
687
936
|
return;
|
|
688
937
|
}
|
|
@@ -702,7 +951,7 @@ var LanGateway = class {
|
|
|
702
951
|
headers
|
|
703
952
|
});
|
|
704
953
|
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
|
|
705
|
-
this.trackDuplex(socket);
|
|
954
|
+
this.trackDuplex(socket, claims?.sid);
|
|
706
955
|
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
|
|
707
956
|
const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
|
|
708
957
|
socket.write(`${statusLine}${headerLines}\r\n`);
|
|
@@ -982,6 +1231,29 @@ function loadOrCreateSelfSigned(opts, home = homedir()) {
|
|
|
982
1231
|
};
|
|
983
1232
|
}
|
|
984
1233
|
/**
|
|
1234
|
+
* Load the self-signed material a listener should serve: generate it on first
|
|
1235
|
+
* use, reuse the persisted pair otherwise, and replace a persisted certificate
|
|
1236
|
+
* whose validity has already lapsed.
|
|
1237
|
+
*
|
|
1238
|
+
* Nothing renews a self-signed certificate in place, and a browser refuses a
|
|
1239
|
+
* lapsed one outright, so without this a certificate that ran out would keep
|
|
1240
|
+
* being served until an operator happened to read the expiry date out of
|
|
1241
|
+
* `status` and act on it. Renewing mints a fresh key, so a client that had
|
|
1242
|
+
* trusted the old certificate has to trust the new one — but that is the case
|
|
1243
|
+
* either way, the old one having lapsed.
|
|
1244
|
+
*/
|
|
1245
|
+
function loadOrRenewSelfSigned(opts, home = homedir()) {
|
|
1246
|
+
const { material, created } = loadOrCreateSelfSigned(opts, home);
|
|
1247
|
+
if (created || !isCertExpired(material.cert)) return {
|
|
1248
|
+
material,
|
|
1249
|
+
renewed: false
|
|
1250
|
+
};
|
|
1251
|
+
return {
|
|
1252
|
+
material: regenerateSelfSigned(opts, home),
|
|
1253
|
+
renewed: true
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
985
1257
|
* Force-regenerate the self-signed certificate (new key + cert), replacing
|
|
986
1258
|
* the persisted files. Used by `lan_gateway tls-regenerate`.
|
|
987
1259
|
*/
|
|
@@ -1041,6 +1313,15 @@ function loadCustomCert(certPath, keyPath) {
|
|
|
1041
1313
|
function parseSelfSignedHosts(text) {
|
|
1042
1314
|
return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
|
|
1043
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Whether a PEM certificate's validity window has already closed. A lapsed
|
|
1318
|
+
* certificate is a hard failure browsers will not let the user proceed past,
|
|
1319
|
+
* so the listener replaces one rather than keep serving it.
|
|
1320
|
+
*/
|
|
1321
|
+
function isCertExpired(certPem, now = Date.now()) {
|
|
1322
|
+
const expiresAt = Date.parse(new X509Certificate(certPem).validTo);
|
|
1323
|
+
return Number.isFinite(expiresAt) && expiresAt <= now;
|
|
1324
|
+
}
|
|
1044
1325
|
/** Describe a PEM certificate (throws on malformed input). */
|
|
1045
1326
|
function describeCert(certPem) {
|
|
1046
1327
|
const cert = new X509Certificate(certPem);
|
|
@@ -1155,6 +1436,18 @@ function nameValueOnly(setCookie) {
|
|
|
1155
1436
|
const semi = setCookie.indexOf(";");
|
|
1156
1437
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
1157
1438
|
}
|
|
1439
|
+
/**
|
|
1440
|
+
* The pathname of a URL, for logging. Never the whole URL: the authenticated
|
|
1441
|
+
* URL carries the launch token as a query parameter, and that token is a
|
|
1442
|
+
* bearer credential for the upstream harness.
|
|
1443
|
+
*/
|
|
1444
|
+
function pathOf(url) {
|
|
1445
|
+
try {
|
|
1446
|
+
return new URL(url).pathname;
|
|
1447
|
+
} catch {
|
|
1448
|
+
return "<unparseable>";
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1158
1451
|
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
1159
1452
|
function cookieNameOf(setCookie) {
|
|
1160
1453
|
const eq = setCookie.indexOf("=");
|
|
@@ -1189,7 +1482,7 @@ function exchange(url, authority, port, log) {
|
|
|
1189
1482
|
try {
|
|
1190
1483
|
target = new URL(url);
|
|
1191
1484
|
} catch {
|
|
1192
|
-
log(
|
|
1485
|
+
log("exchange: authenticatedUrl is not parseable");
|
|
1193
1486
|
resolve(void 0);
|
|
1194
1487
|
return;
|
|
1195
1488
|
}
|
|
@@ -1286,7 +1579,7 @@ var UpstreamSessionRelay = class {
|
|
|
1286
1579
|
this.log("authenticatedUrl() returned undefined; keeping current session");
|
|
1287
1580
|
return this.held?.header;
|
|
1288
1581
|
}
|
|
1289
|
-
this.log(`acquiring session from ${url}`);
|
|
1582
|
+
this.log(`acquiring session from ${pathOf(url)}`);
|
|
1290
1583
|
const result = await exchange(url, this.authority, this.port, this.log);
|
|
1291
1584
|
if (result !== void 0) {
|
|
1292
1585
|
this.held = result;
|
|
@@ -1365,14 +1658,16 @@ function resolveSecureCookies(cfg) {
|
|
|
1365
1658
|
/** Resolve the TLS material for a config, or undefined when TLS is off. */
|
|
1366
1659
|
function resolveTls(cfg) {
|
|
1367
1660
|
if (!cfg.tlsEnabled) return void 0;
|
|
1368
|
-
if (cfg.tlsMode === "custom") return
|
|
1661
|
+
if (cfg.tlsMode === "custom") return {
|
|
1662
|
+
material: loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? ""),
|
|
1663
|
+
renewed: false
|
|
1664
|
+
};
|
|
1369
1665
|
const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
|
|
1370
1666
|
if (hosts.length === 0) throw new Error("tlsSelfSignedHosts must name at least one host (DNS name or IP)");
|
|
1371
|
-
|
|
1667
|
+
return loadOrRenewSelfSigned({
|
|
1372
1668
|
hosts,
|
|
1373
1669
|
days: cfg.tlsCertMaxAgeDays
|
|
1374
1670
|
});
|
|
1375
|
-
return material;
|
|
1376
1671
|
}
|
|
1377
1672
|
/**
|
|
1378
1673
|
* Config fields that require a listener restart when they change, plus whether
|
|
@@ -1474,7 +1769,8 @@ function apply(ctx, config) {
|
|
|
1474
1769
|
if (state.password === void 0) problems.unshift("no password set — run `lan_gateway set-password` before enabling the listener");
|
|
1475
1770
|
if (problems.length > 0) throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(" ")}`);
|
|
1476
1771
|
const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
|
|
1477
|
-
const
|
|
1772
|
+
const resolved = resolveTls(cfg);
|
|
1773
|
+
const tls = resolved?.material;
|
|
1478
1774
|
const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1479
1775
|
const secureCookies = resolveSecureCookies(cfg);
|
|
1480
1776
|
const next = new LanGateway({
|
|
@@ -1486,12 +1782,17 @@ function apply(ctx, config) {
|
|
|
1486
1782
|
cookieName: cfg.cookieName,
|
|
1487
1783
|
secureCookies,
|
|
1488
1784
|
...tls !== void 0 ? { tls } : {},
|
|
1489
|
-
...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {}
|
|
1785
|
+
...makeRelay !== void 0 ? { upstreamSession: makeRelay(dshPort) } : {},
|
|
1786
|
+
onStateChange: (updated) => {
|
|
1787
|
+
state = updated;
|
|
1788
|
+
saveState(state);
|
|
1789
|
+
}
|
|
1490
1790
|
}, state);
|
|
1491
1791
|
await next.listen();
|
|
1492
1792
|
gateway = next;
|
|
1493
1793
|
startedWith = listenerKey(cfg, makeRelay !== void 0);
|
|
1494
|
-
ctx.logger.info(`dsh-lan-gateway: listening on
|
|
1794
|
+
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]"}`);
|
|
1795
|
+
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
1796
|
};
|
|
1496
1797
|
const stopGateway = async () => {
|
|
1497
1798
|
const current = gateway;
|
|
@@ -1636,7 +1937,7 @@ function apply(ctx, config) {
|
|
|
1636
1937
|
const encrypted = cfg.tlsEnabled || cfg.trustedTerminator !== void 0;
|
|
1637
1938
|
return {
|
|
1638
1939
|
ok: true,
|
|
1639
|
-
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on
|
|
1940
|
+
message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on ${gateway.boundAddress()}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- login required for all sources: true${cfg.lanPasswordless ? " (LAN/loopback exempt via lanPasswordless)" : ""}\n- session epoch: ${state.sessionEpoch}\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)\n- upstream session relay: ${upstreamSessionAvailable ? "active (dsh browser-session auth present)" : "absent (older dsh base)"}\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== void 0 ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? "TLS" : "plaintext"} browser ingress)` : encrypted ? "encrypted" : cfg.allowInsecurePlaintext ? "PLAINTEXT (explicit allowInsecurePlaintext)" : "plaintext — will not start"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? "Secure" : "no Secure attribute (plaintext browser ingress)"}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
|
|
1640
1941
|
};
|
|
1641
1942
|
},
|
|
1642
1943
|
async enable() {
|
|
@@ -1644,7 +1945,7 @@ function apply(ctx, config) {
|
|
|
1644
1945
|
await syncGateway("tool enable");
|
|
1645
1946
|
return gateway !== void 0 ? {
|
|
1646
1947
|
ok: true,
|
|
1647
|
-
message: `Gateway enabled: listening on
|
|
1948
|
+
message: `Gateway enabled: listening on ${gateway.boundAddress()}`
|
|
1648
1949
|
} : {
|
|
1649
1950
|
ok: false,
|
|
1650
1951
|
message: `Failed to enable gateway: ${lastError ?? "unknown error"}`
|