@riceawa/dsh-lan-gateway 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1310 @@
1
+ import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { settingsNamespace } from "@deepseek-ai/dsh-settings";
4
+ import http from "node:http";
5
+ import https from "node:https";
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { defineTool } from "@deepseek-ai/dsh-tools";
10
+ /** Default LAN CIDRs: RFC1918 + link-local, IPv4. */
11
+ const DEFAULT_LAN_CIDR_STRINGS = [...[
12
+ "10.0.0.0/8",
13
+ "172.16.0.0/12",
14
+ "192.168.0.0/16",
15
+ "169.254.0.0/16"
16
+ ]];
17
+ /** Parse a dotted-quad IPv4 string to its 32-bit integer, or undefined. */
18
+ function parseIpv4(text) {
19
+ const parts = text.split(".");
20
+ if (parts.length !== 4) return void 0;
21
+ let out = 0;
22
+ for (const part of parts) {
23
+ if (!/^\d{1,3}$/.test(part)) return void 0;
24
+ const byte = Number(part);
25
+ if (byte > 255) return void 0;
26
+ out = out << 8 | byte;
27
+ }
28
+ return out >>> 0;
29
+ }
30
+ /** Parse `a.b.c.d/len` into a {@link Cidr}, or undefined on malformed input. */
31
+ function parseCidr(text) {
32
+ const slash = text.indexOf("/");
33
+ const addrText = slash === -1 ? text : text.slice(0, slash);
34
+ const prefixText = slash === -1 ? "32" : text.slice(slash + 1);
35
+ const addr = parseIpv4(addrText);
36
+ if (addr === void 0) return void 0;
37
+ if (!/^\d{1,2}$/.test(prefixText)) return void 0;
38
+ const prefix = Number(prefixText);
39
+ if (prefix < 0 || prefix > 32) return void 0;
40
+ return {
41
+ addr,
42
+ prefix
43
+ };
44
+ }
45
+ /** Whether a 32-bit IPv4 address falls inside one CIDR range. */
46
+ function inCidr(ip, cidr) {
47
+ if (cidr.prefix === 0) return true;
48
+ const mask = cidr.prefix === 32 ? 4294967295 : 4294967295 << 32 - cidr.prefix >>> 0;
49
+ return (ip & mask) === (cidr.addr & mask);
50
+ }
51
+ /** Normalize a raw socket address to a bare IPv4/6 string we classify on. */
52
+ function normalizeAddress(raw) {
53
+ const value = raw.trim();
54
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
55
+ if (mapped !== null) return mapped[1];
56
+ return value;
57
+ }
58
+ /**
59
+ * Classify a source address string into one of the three trust tiers.
60
+ * @param remoteAddress - the raw value of `req.socket.remoteAddress`.
61
+ * @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
62
+ * @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
63
+ */
64
+ function classifySource(remoteAddress, lanCidrs = DEFAULT_LAN_CIDR_STRINGS) {
65
+ const address = normalizeAddress(remoteAddress ?? "");
66
+ if (address === "") return "internet";
67
+ const ipv4 = parseIpv4(address);
68
+ if (ipv4 !== void 0) {
69
+ if (ipv4 >>> 24 === 127) return "loopback";
70
+ for (const cidrText of lanCidrs) {
71
+ const cidr = parseCidr(cidrText);
72
+ if (cidr !== void 0 && inCidr(ipv4, cidr)) return "lan";
73
+ }
74
+ return "internet";
75
+ }
76
+ if (address === "::1") return "loopback";
77
+ if (address.toLowerCase().startsWith("fe80:")) return "lan";
78
+ return "internet";
79
+ }
80
+ /** Encode a byte buffer as URL-safe base64 without padding. */
81
+ function base64url(input) {
82
+ return input.toString("base64url");
83
+ }
84
+ /**
85
+ * Issue a signed session cookie value.
86
+ * @param secret - the HMAC signing secret (base64 string).
87
+ * @param expiresMs - epoch millis at which the session expires.
88
+ * @returns a `payload.signature` string suitable for the cookie value.
89
+ */
90
+ function signCookie(secret, expiresMs) {
91
+ const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })));
92
+ return `${payload}.${createHmac("sha256", secret).update(payload).digest("base64url")}`;
93
+ }
94
+ /** Whether a cookie value is a valid, unexpired session signed with `secret`. */
95
+ function verifyCookie(secret, value, now) {
96
+ if (value === void 0) return false;
97
+ const dot = value.indexOf(".");
98
+ if (dot === -1) return false;
99
+ const payload = value.slice(0, dot);
100
+ const sig = value.slice(dot + 1);
101
+ const expected = createHmac("sha256", secret).update(payload).digest();
102
+ let actual;
103
+ try {
104
+ actual = Buffer.from(sig, "base64url");
105
+ } catch {
106
+ return false;
107
+ }
108
+ if (expected.length !== actual.length) return false;
109
+ if (!timingSafeEqual(expected, actual)) return false;
110
+ try {
111
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
112
+ return typeof decoded.exp === "number" && decoded.exp > now;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** A token bucket limiter keyed by source address. */
118
+ var RateLimiter = class {
119
+ maxTokens;
120
+ windowMs;
121
+ buckets = /* @__PURE__ */ new Map();
122
+ constructor(maxTokens, windowMs) {
123
+ this.maxTokens = maxTokens;
124
+ this.windowMs = windowMs;
125
+ }
126
+ /**
127
+ * Attempt to consume one token for `key`.
128
+ * @returns true when the attempt is allowed, false when the source is
129
+ * temporarily rate-limited.
130
+ */
131
+ allow(key) {
132
+ const now = Date.now();
133
+ const bucket = this.buckets.get(key);
134
+ if (bucket === void 0 || bucket.resetAt <= now) {
135
+ this.buckets.set(key, {
136
+ tokens: this.maxTokens - 1,
137
+ resetAt: now + this.windowMs
138
+ });
139
+ return true;
140
+ }
141
+ if (bucket.tokens > 0) {
142
+ bucket.tokens -= 1;
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ /** Drop expired buckets to bound memory. */
148
+ prune(now = Date.now()) {
149
+ for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
150
+ }
151
+ };
152
+ //#endregion
153
+ //#region src/login.ts
154
+ /** Path the gateway owns and never forwards. */
155
+ const LOGIN_PATH = "/__login";
156
+ /** Render the self-contained login page. */
157
+ function renderLoginPage(opts = {}) {
158
+ const errorHtml = opts.limited ? "<p class=\"error\">Too many attempts — wait a minute and try again.</p>" : opts.error ? `<p class="error">${escapeHtml(opts.error)}</p>` : "";
159
+ return `<!doctype html>
160
+ <html lang="en">
161
+ <head>
162
+ <meta charset="utf-8">
163
+ <meta name="viewport" content="width=device-width, initial-scale=1">
164
+ <title>DeepSeek Harness — Remote Access</title>
165
+ <style>
166
+ :root { color-scheme: dark; }
167
+ * { box-sizing: border-box; }
168
+ body {
169
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
170
+ background: #0f1115; color: #e6e9ef; font: 14px/1.5 system-ui, -apple-system, sans-serif;
171
+ }
172
+ .card {
173
+ width: min(360px, 90vw); padding: 32px 28px; border: 1px solid #262b36; border-radius: 12px;
174
+ background: #161a21; box-shadow: 0 8px 30px rgba(0,0,0,.4);
175
+ }
176
+ h1 { font-size: 17px; margin: 0 0 4px; }
177
+ p.sub { color: #8b93a3; margin: 0 0 20px; font-size: 13px; }
178
+ label { display: block; font-size: 12px; color: #aab2c1; margin-bottom: 6px; }
179
+ input[type=password] {
180
+ width: 100%; padding: 10px 12px; border: 1px solid #2d3442; border-radius: 8px;
181
+ background: #0f1115; color: #e6e9ef; font-size: 14px;
182
+ }
183
+ button {
184
+ width: 100%; margin-top: 16px; padding: 10px; border: 0; border-radius: 8px;
185
+ background: #4f6ef7; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
186
+ }
187
+ button:hover { background: #5c7afa; }
188
+ p.error { color: #ff7b72; font-size: 13px; margin: 12px 0 0; }
189
+ </style>
190
+ </head>
191
+ <body>
192
+ <form class="card" method="post" action="${LOGIN_PATH}">
193
+ <h1>DeepSeek Harness</h1>
194
+ <p class="sub">This instance requires a password from your network location.</p>
195
+ <label for="password">Password</label>
196
+ <input type="password" id="password" name="password" autofocus autocomplete="current-password" required>
197
+ ${errorHtml}
198
+ <button type="submit">Sign in</button>
199
+ </form>
200
+ </body>
201
+ </html>`;
202
+ }
203
+ /** Minimal HTML-escape for the error string interpolated into the page. */
204
+ function escapeHtml(text) {
205
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
206
+ }
207
+ /** Serve the GET login page. */
208
+ function serveLoginGet(res, extraHeaders = {}) {
209
+ res.writeHead(200, {
210
+ "content-type": "text/html; charset=utf-8",
211
+ "cache-control": "no-store",
212
+ ...extraHeaders
213
+ });
214
+ res.end(renderLoginPage());
215
+ }
216
+ /** Read a request body up to a byte ceiling, rejecting anything larger. */
217
+ function readBody(req, maxBytes, res) {
218
+ return new Promise((resolve) => {
219
+ let size = 0;
220
+ const chunks = [];
221
+ req.on("data", (chunk) => {
222
+ size += chunk.length;
223
+ if (size > maxBytes) {
224
+ res.writeHead(413);
225
+ res.end();
226
+ resolve(void 0);
227
+ req.destroy();
228
+ return;
229
+ }
230
+ chunks.push(chunk);
231
+ });
232
+ req.on("end", () => {
233
+ resolve(Buffer.concat(chunks).toString("utf8"));
234
+ });
235
+ req.on("error", () => {
236
+ res.writeHead(400);
237
+ res.end();
238
+ resolve(void 0);
239
+ });
240
+ });
241
+ }
242
+ //#endregion
243
+ //#region src/state.ts
244
+ /**
245
+ * Persistent runtime state for the LAN gateway: the cookie-signing secret and
246
+ * the scrypt password hash. Lives in `~/.dsh/lan-gateway/state.json` (0600),
247
+ * NOT in the schemastery Config — secrets must never surface in
248
+ * `--dump-config` output. Writes are atomic (temp file + rename).
249
+ *
250
+ * @module @riceawa/dsh-lan-gateway/state
251
+ */
252
+ /** The state directory: `~/.dsh/lan-gateway`. */
253
+ function stateDir(home = homedir()) {
254
+ return join(home, ".dsh", "lan-gateway");
255
+ }
256
+ const STATE_FILENAME = "state.json";
257
+ /** Whether a password is present and passes scrypt verification. */
258
+ function verifyPassword(state, password) {
259
+ if (state.password === void 0) return false;
260
+ const { hash, salt } = state.password;
261
+ try {
262
+ const expected = Buffer.from(hash, "hex");
263
+ const actual = scryptSync(password, Buffer.from(salt, "hex"), expected.length);
264
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+ /** Set (or clear) the password, re-salted on every write. */
270
+ function setPassword(state, password) {
271
+ if (password === void 0) return { cookieSecret: state.cookieSecret };
272
+ const salt = randomBytes(16);
273
+ const hash = scryptSync(password, salt, 64);
274
+ return {
275
+ ...state,
276
+ password: {
277
+ hash: hash.toString("hex"),
278
+ salt: salt.toString("hex")
279
+ }
280
+ };
281
+ }
282
+ function defaultState() {
283
+ return { cookieSecret: randomBytes(32).toString("base64") };
284
+ }
285
+ /** Load state; on first run (or a corrupt file) generate a fresh secret. */
286
+ function loadState(home = homedir()) {
287
+ const dir = stateDir(home);
288
+ try {
289
+ const raw = readFileSync(join(dir, STATE_FILENAME), "utf8");
290
+ const parsed = JSON.parse(raw);
291
+ if (typeof parsed?.cookieSecret === "string" && parsed.cookieSecret.length >= 16) return parsed;
292
+ return defaultState();
293
+ } catch {
294
+ return defaultState();
295
+ }
296
+ }
297
+ /** Persist state atomically. */
298
+ function saveState(state, home = homedir()) {
299
+ const dir = stateDir(home);
300
+ mkdirSync(dir, { recursive: true });
301
+ const target = join(dir, STATE_FILENAME);
302
+ const tmp = join(dir, `.state.${process.pid}.tmp`);
303
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
304
+ renameSync(tmp, target);
305
+ try {
306
+ chmodSync(target, 384);
307
+ } catch {}
308
+ }
309
+ //#endregion
310
+ //#region src/gateway.ts
311
+ /**
312
+ * The reverse-proxy gateway: a `node:http` server bound to `0.0.0.0` that
313
+ * forwards every request to the loopback dsh web server, rewriting Host and
314
+ * Origin so the dsh `/api` trust fence (which only trusts loopback) passes.
315
+ *
316
+ * Security model:
317
+ * - Source is classified from `socket.remoteAddress` only (never
318
+ * `X-Forwarded-For`). LAN/loopback sources are proxied without a password;
319
+ * anything else must present a valid signed cookie or complete the login.
320
+ * - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
321
+ * blinded — so the gateway runs its own origin check on `/api*` requests
322
+ * BEFORE rewriting (reject `sec-fetch-site: cross-site` and any Origin that
323
+ * does not match the gateway authority the browser actually used).
324
+ *
325
+ * @module @riceawa/dsh-lan-gateway/gateway
326
+ */
327
+ const DEFAULT_BODY_LIMIT_BYTES = 65536;
328
+ const LOGIN_ATTEMPTS_LIMIT = 5;
329
+ const LOGIN_ATTEMPTS_WINDOW_MS = 6e4;
330
+ /**
331
+ * The running gateway: owns the HTTP server and the auth state needed per
332
+ * request. Created by the plugin on enable; torn down by the plugin on
333
+ * disable or tree disposal.
334
+ */
335
+ var LanGateway = class {
336
+ config;
337
+ server;
338
+ loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS);
339
+ state;
340
+ disposed = false;
341
+ constructor(config, state) {
342
+ this.config = config;
343
+ this.state = state;
344
+ const handle = (req, res) => {
345
+ this.handleHttp(req, res);
346
+ };
347
+ this.server = this.config.tls !== void 0 ? https.createServer({
348
+ cert: this.config.tls.cert,
349
+ key: this.config.tls.key
350
+ }, handle) : http.createServer(handle);
351
+ this.server.on("upgrade", (req, socket, head) => {
352
+ this.handleUpgrade(req, socket, head);
353
+ });
354
+ }
355
+ /** Replace the in-memory state (e.g. after a password change). */
356
+ setState(state) {
357
+ this.state = state;
358
+ }
359
+ /** Start listening; rejects if the port is already in use. */
360
+ async listen() {
361
+ return new Promise((resolve, reject) => {
362
+ const onError = (err) => {
363
+ this.server.off("listening", onListening);
364
+ reject(err);
365
+ };
366
+ const onListening = () => {
367
+ this.server.off("error", onError);
368
+ resolve();
369
+ };
370
+ this.server.once("error", onError);
371
+ this.server.once("listening", onListening);
372
+ this.server.listen(this.config.gatewayPort, "0.0.0.0");
373
+ });
374
+ }
375
+ /** Close the server and stop accepting connections. */
376
+ async close() {
377
+ if (this.disposed) return;
378
+ this.disposed = true;
379
+ return new Promise((resolve) => {
380
+ this.server.close(() => resolve());
381
+ this.server.closeAllConnections();
382
+ });
383
+ }
384
+ sourceClass(req) {
385
+ return classifySource(req.socket.remoteAddress, this.config.lanCidrs);
386
+ }
387
+ /** Parse the session cookie out of a Cookie header. */
388
+ sessionCookie(req) {
389
+ const header = req.headers.cookie;
390
+ if (typeof header !== "string") return void 0;
391
+ for (const part of header.split(";")) {
392
+ const trimmed = part.trim();
393
+ if (trimmed.startsWith(`${this.config.cookieName}=`)) return trimmed.slice(this.config.cookieName.length + 1);
394
+ }
395
+ }
396
+ /** Whether a request carries a valid session for its source. */
397
+ authorized(req) {
398
+ const cookie = this.sessionCookie(req);
399
+ return cookie !== void 0 && verifyCookie(this.state.cookieSecret, cookie, Date.now());
400
+ }
401
+ serveUnauthorized(res, limited) {
402
+ res.writeHead(302, {
403
+ location: `${LOGIN_PATH}${limited ? "?limited=1" : ""}`,
404
+ ...this.securityHeaders()
405
+ });
406
+ res.end();
407
+ }
408
+ serveLoginError(res, message) {
409
+ const opts = { error: message };
410
+ res.writeHead(401, {
411
+ "content-type": "text/html; charset=utf-8",
412
+ "cache-control": "no-store",
413
+ ...this.securityHeaders()
414
+ });
415
+ res.end(renderLoginPage(opts));
416
+ }
417
+ /** HSTS when the listener is HTTPS (never sent on plain HTTP). */
418
+ securityHeaders() {
419
+ return this.config.tls === void 0 ? {} : { "strict-transport-security": "max-age=15552000" };
420
+ }
421
+ /** Handle one HTTP request: auth gate → CSRF fence → forward. */
422
+ async handleHttp(req, res) {
423
+ const source = this.sourceClass(req);
424
+ const url = req.url ?? "/";
425
+ const pathname = url.split("?")[0] ?? "/";
426
+ if (pathname === "/__login") {
427
+ this.handleLogin(req, res);
428
+ return;
429
+ }
430
+ if (source === "internet" && this.config.authRequired) {
431
+ if (!this.authorized(req)) {
432
+ this.serveUnauthorized(res, false);
433
+ return;
434
+ }
435
+ }
436
+ if (pathname === "/api" || pathname.startsWith("/api/")) {
437
+ if (!this.passesCsrfFence(req)) {
438
+ res.writeHead(403, this.securityHeaders());
439
+ res.end("forbidden");
440
+ return;
441
+ }
442
+ }
443
+ this.forward(req, res, url);
444
+ }
445
+ /** Reject cross-site API traffic: the gateway's own origin check. */
446
+ passesCsrfFence(req) {
447
+ const headers = req.headers;
448
+ if (headers["sec-fetch-site"] === "cross-site") return false;
449
+ const origin = headers.origin;
450
+ if (origin === void 0) return true;
451
+ try {
452
+ const originHost = new URL(origin).host;
453
+ const requestHost = typeof headers.host === "string" ? headers.host : "";
454
+ return originHost === requestHost || originHost === stripDefaultPort(requestHost);
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+ /** Handle the login GET form / POST submission. */
460
+ handleLogin(req, res) {
461
+ req.url?.includes("limited=1");
462
+ if (req.method === "GET" || req.method === "HEAD") {
463
+ serveLoginGet(res, this.securityHeaders());
464
+ return;
465
+ }
466
+ if (req.method !== "POST") {
467
+ res.writeHead(405, { allow: "GET, POST" });
468
+ res.end();
469
+ return;
470
+ }
471
+ const key = req.socket.remoteAddress ?? "unknown";
472
+ if (!this.loginLimiter.allow(key)) {
473
+ this.serveLoginError(res, "Too many attempts — please wait a minute.");
474
+ return;
475
+ }
476
+ readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
477
+ if (body === void 0) return;
478
+ let password;
479
+ try {
480
+ password = new URLSearchParams(body).get("password") ?? void 0;
481
+ } catch {
482
+ password = void 0;
483
+ }
484
+ if (password === void 0 || !verifyPassword(this.state, password)) {
485
+ this.serveLoginError(res, "Incorrect password.");
486
+ return;
487
+ }
488
+ const expiresMs = Date.now() + this.config.cookieMaxAgeDays * 864e5;
489
+ const cookie = signCookie(this.state.cookieSecret, expiresMs);
490
+ const secure = this.config.tls !== void 0 ? "; Secure" : "";
491
+ res.writeHead(302, {
492
+ location: "/",
493
+ ...this.securityHeaders(),
494
+ "set-cookie": [`${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86400}${secure}`]
495
+ });
496
+ res.end();
497
+ });
498
+ }
499
+ /** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
500
+ forward(req, res, url) {
501
+ const headers = { ...req.headers };
502
+ headers.host = `127.0.0.1:${this.config.dshPort}`;
503
+ if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
504
+ delete headers["proxy-connection"];
505
+ delete headers.connection;
506
+ const proxyReq = http.request({
507
+ host: "127.0.0.1",
508
+ port: this.config.dshPort,
509
+ method: req.method,
510
+ path: url,
511
+ headers
512
+ }, (proxyRes) => {
513
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
514
+ proxyRes.pipe(res);
515
+ });
516
+ proxyReq.on("error", () => {
517
+ if (!res.headersSent) res.writeHead(502);
518
+ res.destroy();
519
+ });
520
+ req.pipe(proxyReq);
521
+ }
522
+ /** Forward a WebSocket upgrade, splicing the raw duplex through to dsh. */
523
+ handleUpgrade(req, socket, head) {
524
+ if (this.sourceClass(req) === "internet" && this.config.authRequired && !this.authorized(req)) {
525
+ socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
526
+ socket.destroy();
527
+ return;
528
+ }
529
+ const headers = { ...req.headers };
530
+ headers.host = `127.0.0.1:${this.config.dshPort}`;
531
+ if (typeof headers.origin === "string") headers.origin = `http://127.0.0.1:${this.config.dshPort}`;
532
+ delete headers["proxy-connection"];
533
+ const proxyReq = http.request({
534
+ host: "127.0.0.1",
535
+ port: this.config.dshPort,
536
+ method: "GET",
537
+ path: req.url ?? "/",
538
+ headers
539
+ });
540
+ proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
541
+ const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}\r\n`;
542
+ const headerLines = Object.entries(proxyRes.headers).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r\n`).join("");
543
+ socket.write(`${statusLine}${headerLines}\r\n`);
544
+ if (head !== void 0 && head.length > 0) proxySocket.write(head);
545
+ proxySocket.pipe(socket).pipe(proxySocket);
546
+ if (proxyHead !== void 0 && proxyHead.length > 0) proxySocket.unshift(proxyHead);
547
+ socket.on("error", () => proxySocket.destroy());
548
+ proxySocket.on("error", () => socket.destroy());
549
+ });
550
+ proxyReq.on("error", () => socket.destroy());
551
+ proxyReq.end();
552
+ }
553
+ };
554
+ /** Strip an explicit default port from a Host authority, if present. */
555
+ function stripDefaultPort(host) {
556
+ const parsed = /^(.+?)(?::(\d+))?$/.exec(host);
557
+ if (parsed?.[2] === "80" || parsed?.[2] === "443") return parsed[1];
558
+ return host;
559
+ }
560
+ //#endregion
561
+ //#region src/x509.ts
562
+ /**
563
+ * Minimal X.509 v3 self-signed certificate generator built on `node:crypto`
564
+ * only — no openssl binary, no npm dependencies.
565
+ *
566
+ * The certificate is a standard RSA-2048 / sha256WithRSAEncryption leaf cert
567
+ * (CA:FALSE) carrying the requested DNS/IP SANs, so browsers accept it for
568
+ * `https://<host>:<port>` after the user approves the self-signed warning.
569
+ *
570
+ * @module @riceawa/dsh-lan-gateway/x509
571
+ */
572
+ const TAG_SEQUENCE = 48;
573
+ const TAG_SET = 49;
574
+ const TAG_INTEGER = 2;
575
+ const TAG_OID = 6;
576
+ const TAG_BIT_STRING = 3;
577
+ const TAG_OCTET_STRING = 4;
578
+ const TAG_UTF8_STRING = 12;
579
+ const TAG_UTCTIME = 23;
580
+ /** Context-specific primitive [2] (dNSName / iPAddress inside SAN). */
581
+ const TAG_CONTEXT_2 = 130;
582
+ /** Context-specific primitive [7] (iPAddress). */
583
+ const TAG_CONTEXT_7 = 135;
584
+ /** DER length octets (short form up to 127, long form above). */
585
+ function derLength(length) {
586
+ if (length < 128) return Buffer.from([length]);
587
+ const bytes = [];
588
+ let n = length;
589
+ while (n > 0) {
590
+ bytes.unshift(n & 255);
591
+ n >>>= 8;
592
+ }
593
+ return Buffer.from([128 | bytes.length, ...bytes]);
594
+ }
595
+ /** Tag a body with an identifier octet. */
596
+ function derTag(tag, body) {
597
+ return Buffer.concat([
598
+ Buffer.from([tag]),
599
+ derLength(body.length),
600
+ body
601
+ ]);
602
+ }
603
+ /** SEQUENCE OF parts. */
604
+ function derSeq(...parts) {
605
+ return derTag(TAG_SEQUENCE, Buffer.concat(parts));
606
+ }
607
+ /** SET OF parts (one RDN). */
608
+ function derSet(...parts) {
609
+ return derTag(TAG_SET, Buffer.concat(parts));
610
+ }
611
+ /** INTEGER from raw big-endian bytes (strips leading zeros, keeps sign bit clean). */
612
+ function derInt(value) {
613
+ let start = 0;
614
+ while (start < value.length - 1 && value[start] === 0) start += 1;
615
+ let body = value.subarray(start);
616
+ if ((body[0] & 128) !== 0) body = Buffer.concat([Buffer.from([0]), body]);
617
+ return derTag(TAG_INTEGER, body);
618
+ }
619
+ /** OBJECT IDENTIFIER from a dotted string like `1.2.840.113549.1.1.11`. */
620
+ function derOid(oid) {
621
+ const parts = oid.split(".").map(Number);
622
+ if (parts.length < 2 || parts.some((p) => !Number.isInteger(p) || p < 0)) throw new Error(`invalid OID: ${oid}`);
623
+ const body = [parts[0] * 40 + parts[1]];
624
+ for (const part of parts.slice(2)) {
625
+ let n = part;
626
+ const chunk = [n & 127];
627
+ n >>>= 7;
628
+ while (n > 0) {
629
+ chunk.unshift(n & 127 | 128);
630
+ n >>>= 7;
631
+ }
632
+ body.push(...chunk);
633
+ }
634
+ return derTag(TAG_OID, Buffer.from(body));
635
+ }
636
+ /** BIT STRING over raw content (unused-bits octet prepended). */
637
+ function derBitString(content, unusedBits = 0) {
638
+ return derTag(TAG_BIT_STRING, Buffer.concat([Buffer.from([unusedBits]), content]));
639
+ }
640
+ /** OCTET STRING. */
641
+ function derOctetString(content) {
642
+ return derTag(TAG_OCTET_STRING, content);
643
+ }
644
+ /** UTF8String (legal DirectoryString for the CN). */
645
+ function derUtf8String(text) {
646
+ return derTag(TAG_UTF8_STRING, Buffer.from(text, "utf8"));
647
+ }
648
+ /** UTCTime: `YYMMDDHHMMSSZ` (valid until 2050). */
649
+ function derUtcTime(date) {
650
+ const pad = (n) => String(n).padStart(2, "0");
651
+ const text = `${pad(date.getUTCFullYear() % 100)}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
652
+ return derTag(TAG_UTCTIME, Buffer.from(text, "ascii"));
653
+ }
654
+ /** BOOLEAN. */
655
+ function derBoolean(value) {
656
+ return derTag(1, Buffer.from([value ? 255 : 0]));
657
+ }
658
+ /** SHA-256 with RSA encryption (no parameters). */
659
+ function sha256WithRsa() {
660
+ return derSeq(derOid("1.2.840.113549.1.1.11"));
661
+ }
662
+ /**
663
+ * Parse an IPv6 literal into its 16 raw bytes. Supports `::` compression,
664
+ * hex groups, and an embedded dotted-quad IPv4 tail.
665
+ */
666
+ function parseIpv6Bytes(text) {
667
+ let address = text.trim();
668
+ if (address.startsWith("[") && address.endsWith("]")) address = address.slice(1, -1);
669
+ if (address.includes("/")) address = address.split("/")[0];
670
+ const embeddedIpv4 = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(address);
671
+ let head = address;
672
+ let tail = [];
673
+ if (embeddedIpv4 !== null) {
674
+ head = embeddedIpv4[1].replace(/:$/, "");
675
+ tail = embeddedIpv4[2].split(".").map(Number);
676
+ if (tail.some((b) => !Number.isInteger(b) || b < 0 || b > 255)) return void 0;
677
+ }
678
+ const doubleColon = head.indexOf("::");
679
+ if (doubleColon !== -1) {
680
+ if (head.indexOf("::", doubleColon + 1) !== -1) return void 0;
681
+ const left = head.slice(0, doubleColon);
682
+ const right = head.slice(doubleColon + 2);
683
+ const leftWords = parseWords(left);
684
+ const rightWords = parseWords(right);
685
+ if (leftWords === void 0 || rightWords === void 0) return void 0;
686
+ if (leftWords.length + rightWords.length + tail.length / 2 > 8) return void 0;
687
+ const gap = 8 - leftWords.length - rightWords.length - tail.length / 2;
688
+ return wordsToBytes([
689
+ ...leftWords,
690
+ ...Array(gap).fill(0),
691
+ ...rightWords
692
+ ], tail);
693
+ }
694
+ const words = parseWords(head);
695
+ if (words === void 0) return void 0;
696
+ if (words.length + tail.length / 2 !== 8) return void 0;
697
+ return wordsToBytes(words, tail);
698
+ }
699
+ function parseWords(text) {
700
+ if (text === "") return [];
701
+ const parts = text.split(":");
702
+ if (parts.some((p) => p === "")) return void 0;
703
+ const words = [];
704
+ for (const part of parts) {
705
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return void 0;
706
+ words.push(Number.parseInt(part, 16));
707
+ }
708
+ return words;
709
+ }
710
+ function wordsToBytes(words, ipv4Tail) {
711
+ const bytes = [];
712
+ for (const word of words) bytes.push(word >> 8 & 255, word & 255);
713
+ bytes.push(...ipv4Tail);
714
+ return Buffer.from(bytes);
715
+ }
716
+ /** Whether `text` is an IPv4 literal. */
717
+ function isIpv4Literal(text) {
718
+ const parts = text.split(".");
719
+ return parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
720
+ }
721
+ /** A SAN general name: [2] dNSName (IA5) or [7] iPAddress (raw bytes). */
722
+ function sanGeneralName(host) {
723
+ const trimmed = host.trim();
724
+ if (isIpv4Literal(trimmed)) return derTag(TAG_CONTEXT_7, Buffer.from(trimmed.split(".").map(Number)));
725
+ const ipv6 = parseIpv6Bytes(trimmed);
726
+ if (ipv6 !== void 0) return derTag(TAG_CONTEXT_7, ipv6);
727
+ return derTag(TAG_CONTEXT_2, Buffer.from(trimmed, "ascii"));
728
+ }
729
+ /** PEM-encode a DER body. */
730
+ function pemEncode(label, der) {
731
+ return `-----BEGIN ${label}-----\n${der.toString("base64").match(/.{1,64}/g)?.join("\n") ?? ""}\n-----END ${label}-----\n`;
732
+ }
733
+ /**
734
+ * Generate a self-signed X.509 v3 leaf certificate for `hosts`.
735
+ * @param options - hosts, validity, subject.
736
+ * @returns DER + PEM certificate, PEM private key, and the key objects.
737
+ */
738
+ function generateSelfSignedCert(options) {
739
+ const hosts = options.hosts.map((h) => h.trim()).filter((h) => h !== "");
740
+ if (hosts.length === 0) throw new Error("self-signed certificate needs at least one host");
741
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
742
+ modulusLength: 2048,
743
+ publicExponent: 65537
744
+ });
745
+ const commonName = options.commonName?.trim() || hosts[0];
746
+ const serial = randomBytes(16);
747
+ serial[0] &= 127;
748
+ const issuer = derSeq(derSet(derSeq(derOid("2.5.4.3"), derUtf8String(commonName))));
749
+ const subject = issuer;
750
+ const notBefore = /* @__PURE__ */ new Date(Date.now() - 36e5);
751
+ const notAfter = new Date(notBefore.getTime() + options.days * 864e5);
752
+ const validity = derSeq(derUtcTime(notBefore), derUtcTime(notAfter));
753
+ const spki = publicKey.export({
754
+ type: "spki",
755
+ format: "der"
756
+ });
757
+ const extensionsWrapper = derTag(163, derSeq(derSeq(derOid("2.5.29.19"), derBoolean(true), derOctetString(derSeq())), derSeq(derOid("2.5.29.15"), derBoolean(true), derOctetString(derBitString(Buffer.from([160])))), derSeq(derOid("2.5.29.37"), derOctetString(derSeq(derOid("1.3.6.1.5.5.7.3.1")))), derSeq(derOid("2.5.29.17"), derOctetString(derSeq(...hosts.map(sanGeneralName))))));
758
+ const tbs = derSeq(derTag(160, derInt(Buffer.from([2]))), derInt(serial), sha256WithRsa(), issuer, validity, subject, spki, extensionsWrapper);
759
+ const signature = createSign("sha256").update(tbs).end().sign(privateKey);
760
+ const certDer = derSeq(tbs, sha256WithRsa(), derBitString(signature));
761
+ return {
762
+ certDer,
763
+ certPem: pemEncode("CERTIFICATE", certDer),
764
+ keyPem: privateKey.export({
765
+ type: "pkcs8",
766
+ format: "pem"
767
+ }).toString(),
768
+ publicKey,
769
+ privateKey
770
+ };
771
+ }
772
+ //#endregion
773
+ //#region src/tls.ts
774
+ /**
775
+ * TLS material management for the gateway: self-signed certificates are
776
+ * generated once and persisted under `~/.dsh/lan-gateway/tls/` (0600) so
777
+ * restarts reuse the same certificate instead of minting a new one every
778
+ * boot; custom certificates are read straight from user-supplied PEM paths.
779
+ *
780
+ * @module @riceawa/dsh-lan-gateway/tls
781
+ */
782
+ /** The TLS state directory: `~/.dsh/lan-gateway/tls`. */
783
+ function tlsDir(home = homedir()) {
784
+ return join(home, ".dsh", "lan-gateway", "tls");
785
+ }
786
+ const SELF_SIGNED_CERT_FILE = "selfsigned.crt";
787
+ const SELF_SIGNED_KEY_FILE = "selfsigned.key";
788
+ function privateWrite(path, content) {
789
+ writeFileSync(path, content, { mode: 384 });
790
+ try {
791
+ chmodSync(path, 384);
792
+ } catch {}
793
+ }
794
+ /**
795
+ * Load the persisted self-signed certificate, generating it on first use.
796
+ * @param opts - hosts / validity for a fresh certificate.
797
+ * @param home - dsh home override (tests).
798
+ * @returns the material and whether it was just created.
799
+ */
800
+ function loadOrCreateSelfSigned(opts, home = homedir()) {
801
+ const dir = tlsDir(home);
802
+ const certPath = join(dir, SELF_SIGNED_CERT_FILE);
803
+ const keyPath = join(dir, SELF_SIGNED_KEY_FILE);
804
+ if (existsSync(certPath) && existsSync(keyPath)) try {
805
+ const cert = readFileSync(certPath, "utf8");
806
+ const key = readFileSync(keyPath, "utf8");
807
+ new X509Certificate(cert);
808
+ return {
809
+ material: {
810
+ cert,
811
+ key
812
+ },
813
+ created: false
814
+ };
815
+ } catch {}
816
+ const material = generateSelfSignedMaterial(opts);
817
+ mkdirSync(dir, { recursive: true });
818
+ privateWrite(keyPath, material.key);
819
+ privateWrite(certPath, material.cert);
820
+ return {
821
+ material,
822
+ created: true
823
+ };
824
+ }
825
+ /**
826
+ * Force-regenerate the self-signed certificate (new key + cert), replacing
827
+ * the persisted files. Used by `lan_gateway tls-regenerate`.
828
+ */
829
+ function regenerateSelfSigned(opts, home = homedir()) {
830
+ const dir = tlsDir(home);
831
+ mkdirSync(dir, { recursive: true });
832
+ const material = generateSelfSignedMaterial(opts);
833
+ privateWrite(join(dir, SELF_SIGNED_KEY_FILE), material.key);
834
+ privateWrite(join(dir, SELF_SIGNED_CERT_FILE), material.cert);
835
+ return material;
836
+ }
837
+ function generateSelfSignedMaterial(opts) {
838
+ const hosts = opts.hosts.map((h) => h.trim()).filter((h) => h !== "");
839
+ if (hosts.length === 0) throw new Error("self-signed TLS needs at least one host in tlsSelfSignedHosts");
840
+ const { certPem, keyPem } = generateSelfSignedCert({
841
+ hosts,
842
+ days: opts.days,
843
+ ...opts.commonName !== void 0 ? { commonName: opts.commonName } : {}
844
+ });
845
+ return {
846
+ cert: certPem,
847
+ key: keyPem
848
+ };
849
+ }
850
+ /**
851
+ * Load a user-supplied certificate + key pair from PEM files.
852
+ * @param certPath - path to the PEM certificate (or chain).
853
+ * @param keyPath - path to the PEM private key.
854
+ * @returns the material.
855
+ */
856
+ function loadCustomCert(certPath, keyPath) {
857
+ if (certPath === "") throw new Error("tlsMode=custom requires tlsCertPath (PEM certificate)");
858
+ if (keyPath === "") throw new Error("tlsMode=custom requires tlsKeyPath (PEM private key)");
859
+ let cert;
860
+ try {
861
+ cert = readFileSync(certPath, "utf8");
862
+ } catch (error) {
863
+ throw new Error(`cannot read TLS certificate "${certPath}": ${errorMessage(error)}`);
864
+ }
865
+ let key;
866
+ try {
867
+ key = readFileSync(keyPath, "utf8");
868
+ } catch (error) {
869
+ throw new Error(`cannot read TLS private key "${keyPath}": ${errorMessage(error)}`);
870
+ }
871
+ try {
872
+ new X509Certificate(cert);
873
+ } catch {
874
+ throw new Error(`"${certPath}" does not contain a valid PEM certificate`);
875
+ }
876
+ return {
877
+ cert,
878
+ key
879
+ };
880
+ }
881
+ /** Parse the user-facing `tlsSelfSignedHosts` string into SAN entries. */
882
+ function parseSelfSignedHosts(text) {
883
+ return (text ?? "").split(/[,;]/).map((host) => host.trim()).filter((host) => host !== "").slice(0, 32);
884
+ }
885
+ /** Describe a PEM certificate (throws on malformed input). */
886
+ function describeCert(certPem) {
887
+ const cert = new X509Certificate(certPem);
888
+ return {
889
+ subject: cert.subject,
890
+ issuer: cert.issuer,
891
+ validFrom: cert.validFrom,
892
+ validTo: cert.validTo,
893
+ fingerprint256: cert.fingerprint256,
894
+ ...cert.subjectAltName !== void 0 ? { san: cert.subjectAltName } : {}
895
+ };
896
+ }
897
+ function errorMessage(error) {
898
+ return error instanceof Error ? error.message : String(error);
899
+ }
900
+ //#endregion
901
+ //#region src/tool.ts
902
+ const LAN_GATEWAY_TOOL_NAME = "lan_gateway";
903
+ /**
904
+ * Build the `lan_gateway` tool over a controller interface implemented by the
905
+ * plugin entry. Split so the tool stays testable and the plugin decides how
906
+ * the controller mutates state.
907
+ */
908
+ function lanGatewayTool(control) {
909
+ return defineTool({
910
+ name: LAN_GATEWAY_TOOL_NAME,
911
+ description: "Manage the LAN/internet gateway for this DeepSeek Harness web GUI. `status` shows whether the gateway is listening, on which port, toward which dsh port, whether a password is set, the trusted LAN CIDRs, and the TLS state. `enable` starts listening on 0.0.0.0 (loopback and LAN sources need no password; anything else must sign in). `disable` stops listening. `set-password` sets (or, with an empty password, clears) the gateway password for non-LAN access. `rotate-secret` invalidates every issued login cookie. `tls-regenerate` mints a fresh self-signed certificate (tlsMode must be self-signed) and restarts the listener.",
912
+ parameters: {
913
+ command: {
914
+ type: "string",
915
+ enum: [
916
+ "status",
917
+ "enable",
918
+ "disable",
919
+ "set-password",
920
+ "rotate-secret",
921
+ "tls-regenerate"
922
+ ],
923
+ description: "`status` (default) — report gateway state. `enable` / `disable` — start or stop the listener. `set-password` — set or clear the login password. `rotate-secret` — invalidate all existing sessions. `tls-regenerate` — mint a new self-signed certificate."
924
+ },
925
+ password: {
926
+ type: "string",
927
+ description: "Required for `set-password`: the new password (min 8 chars). Omit or pass empty to clear."
928
+ }
929
+ },
930
+ output: {
931
+ schema: {
932
+ type: "object",
933
+ additionalProperties: false,
934
+ properties: {
935
+ ok: {
936
+ type: "boolean",
937
+ required: true
938
+ },
939
+ message: {
940
+ type: "string",
941
+ required: true
942
+ }
943
+ }
944
+ },
945
+ render: (_args, value) => [{
946
+ type: "text",
947
+ text: value.message
948
+ }]
949
+ },
950
+ async execute(args, _exec) {
951
+ switch (args.command ?? "status") {
952
+ case "status": return control.status();
953
+ case "enable": return control.enable();
954
+ case "disable": return control.disable();
955
+ case "set-password": {
956
+ const password = args.password;
957
+ return control.setPassword(typeof password === "string" ? password : void 0);
958
+ }
959
+ case "rotate-secret": return control.rotateSecret();
960
+ case "tls-regenerate": return control.regenerateTls();
961
+ }
962
+ }
963
+ });
964
+ }
965
+ //#endregion
966
+ //#region src/index.ts
967
+ /** Stable Cordis plugin name. */
968
+ const name = "dsh-lan-gateway";
969
+ /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
970
+ const inject = ["webServer", "tools"];
971
+ /** The `lan-gateway` user-settings namespace, mirroring the composition schema. */
972
+ const NS = settingsNamespace("lan-gateway");
973
+ /** Optional config keys: an empty submitted value clears them back to the composition layer. */
974
+ const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
975
+ "dshTargetPort",
976
+ "tlsCertPath",
977
+ "tlsKeyPath"
978
+ ]);
979
+ /** Schemastery configuration validated by the Loader. */
980
+ const Config = z.object({
981
+ enabled: z.boolean().default(false),
982
+ gatewayPort: z.natural().min(1).max(65535).default(3081),
983
+ dshTargetPort: z.natural().min(1).max(65535),
984
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
985
+ authRequired: z.boolean().default(true),
986
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
987
+ cookieName: z.string().default("dsh_gw_auth"),
988
+ tlsEnabled: z.boolean().default(false),
989
+ tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
990
+ tlsCertPath: z.string(),
991
+ tlsKeyPath: z.string(),
992
+ tlsSelfSignedHosts: z.string().default("localhost"),
993
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825)
994
+ });
995
+ /** Resolve the TLS material for a config, or undefined when TLS is off. */
996
+ function resolveTls(cfg) {
997
+ if (!cfg.tlsEnabled) return void 0;
998
+ if (cfg.tlsMode === "custom") return loadCustomCert(cfg.tlsCertPath ?? "", cfg.tlsKeyPath ?? "");
999
+ const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
1000
+ if (hosts.length === 0) throw new Error("tlsSelfSignedHosts must name at least one host (DNS name or IP)");
1001
+ const { material } = loadOrCreateSelfSigned({
1002
+ hosts,
1003
+ days: cfg.tlsCertMaxAgeDays
1004
+ });
1005
+ return material;
1006
+ }
1007
+ /** Config fields that require a listener restart when they change. */
1008
+ function listenerKey(cfg) {
1009
+ return JSON.stringify([
1010
+ cfg.gatewayPort,
1011
+ cfg.dshTargetPort,
1012
+ cfg.lanCidrs,
1013
+ cfg.authRequired,
1014
+ cfg.cookieMaxAgeDays,
1015
+ cfg.cookieName,
1016
+ cfg.tlsEnabled,
1017
+ cfg.tlsMode,
1018
+ cfg.tlsCertPath,
1019
+ cfg.tlsKeyPath,
1020
+ cfg.tlsSelfSignedHosts,
1021
+ cfg.tlsCertMaxAgeDays
1022
+ ]);
1023
+ }
1024
+ /** One-line TLS description for status output. */
1025
+ function tlsStatusLine(cfg) {
1026
+ if (!cfg.tlsEnabled) return "off";
1027
+ if (cfg.tlsMode === "custom") return `custom (${cfg.tlsCertPath ?? "?"}, ${cfg.tlsKeyPath ?? "?"})`;
1028
+ try {
1029
+ const { material } = loadOrCreateSelfSigned({
1030
+ hosts: parseSelfSignedHosts(cfg.tlsSelfSignedHosts),
1031
+ days: cfg.tlsCertMaxAgeDays
1032
+ });
1033
+ const info = describeCert(material.cert);
1034
+ return `self-signed [${info.subject}] exp ${info.validTo}`;
1035
+ } catch (error) {
1036
+ return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`;
1037
+ }
1038
+ }
1039
+ /** Whether `hostname` is loopback (127/8, localhost, ::1). */
1040
+ function isLoopbackHost(hostname) {
1041
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "::1") return true;
1042
+ const parts = hostname.split(".");
1043
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1044
+ }
1045
+ /**
1046
+ * Same-origin loopback fence for the config route (mirrors the fence the dsh
1047
+ * host uses for its own /api, and what dsh-lan-gateway's sibling plugins do):
1048
+ * the Host must be loopback (the gateway rewrites it), cross-site fetches are
1049
+ * refused, and any Origin must match the Host the browser actually used.
1050
+ */
1051
+ function isTrustedRequest(req) {
1052
+ const host = req.headers?.host;
1053
+ if (typeof host !== "string" || host === "") return false;
1054
+ let hostUrl;
1055
+ try {
1056
+ hostUrl = new URL(`http://${host}`);
1057
+ } catch {
1058
+ return false;
1059
+ }
1060
+ if (!isLoopbackHost(hostUrl.hostname)) return false;
1061
+ if (req.headers?.["sec-fetch-site"] === "cross-site") return false;
1062
+ const origin = req.headers?.origin;
1063
+ if (origin === void 0) return true;
1064
+ try {
1065
+ return new URL(origin).host === hostUrl.host;
1066
+ } catch {
1067
+ return false;
1068
+ }
1069
+ }
1070
+ function apply(ctx, config) {
1071
+ let state = loadState();
1072
+ let gateway;
1073
+ let startedWith;
1074
+ let lastError;
1075
+ let manualOverride;
1076
+ /** The authoritative config: settings section when attached, else composition. */
1077
+ let configSource = () => config;
1078
+ /** Serializes listener start/stop/restart so settings changes cannot race. */
1079
+ let syncing = Promise.resolve();
1080
+ const effective = () => configSource();
1081
+ const startGateway = async (cfg) => {
1082
+ if (gateway !== void 0) return;
1083
+ if (cfg.authRequired && state.password === void 0) throw new Error("dsh-lan-gateway: no password set — run `lan_gateway set-password` (or set authRequired=false in the plugin config) before enabling.");
1084
+ const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1085
+ const tls = resolveTls(cfg);
1086
+ const next = new LanGateway({
1087
+ gatewayPort: cfg.gatewayPort,
1088
+ dshPort,
1089
+ lanCidrs: cfg.lanCidrs,
1090
+ authRequired: cfg.authRequired,
1091
+ cookieMaxAgeDays: cfg.cookieMaxAgeDays,
1092
+ cookieName: cfg.cookieName,
1093
+ ...tls !== void 0 ? { tls } : {}
1094
+ }, state);
1095
+ await next.listen();
1096
+ gateway = next;
1097
+ startedWith = listenerKey(cfg);
1098
+ ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}`);
1099
+ };
1100
+ const stopGateway = async () => {
1101
+ const current = gateway;
1102
+ gateway = void 0;
1103
+ startedWith = void 0;
1104
+ if (current !== void 0) {
1105
+ await current.close();
1106
+ ctx.logger.info("dsh-lan-gateway: stopped");
1107
+ }
1108
+ };
1109
+ /** Reconcile the listener with the effective config (start/stop/restart). */
1110
+ const syncGateway = (reason) => {
1111
+ syncing = syncing.then(async () => {
1112
+ lastError = void 0;
1113
+ const cfg = effective();
1114
+ const shouldRun = manualOverride ?? cfg.enabled;
1115
+ try {
1116
+ if (gateway === void 0) {
1117
+ if (shouldRun) await startGateway(cfg);
1118
+ } else if (!shouldRun) await stopGateway();
1119
+ else if (startedWith !== listenerKey(cfg)) {
1120
+ await stopGateway();
1121
+ await startGateway(cfg);
1122
+ }
1123
+ } catch (error) {
1124
+ lastError = error instanceof Error ? error.message : String(error);
1125
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`);
1126
+ }
1127
+ });
1128
+ return syncing;
1129
+ };
1130
+ let settingsScope;
1131
+ ctx.inject(["settings"], (sctx) => {
1132
+ const scope = sctx.settings.register(NS, Config, { base: config });
1133
+ settingsScope = scope;
1134
+ configSource = () => scope.get();
1135
+ sctx.effect(() => scope.watch(() => {
1136
+ syncGateway("settings change");
1137
+ }));
1138
+ sctx.effect(() => () => {
1139
+ configSource = () => config;
1140
+ settingsScope = void 0;
1141
+ });
1142
+ syncGateway("settings attach");
1143
+ });
1144
+ const configRouteHandler = async (req, res) => {
1145
+ const send = (status, body) => {
1146
+ res.writeHead(status, { "content-type": "application/json" });
1147
+ res.end(JSON.stringify(body));
1148
+ };
1149
+ if (!isTrustedRequest(req)) {
1150
+ send(403, { error: "request refused: this route answers loopback-origin requests only" });
1151
+ return;
1152
+ }
1153
+ if (req.method === "GET") {
1154
+ const cfg = effective();
1155
+ send(200, {
1156
+ config: cfg,
1157
+ running: gateway !== void 0,
1158
+ port: cfg.gatewayPort,
1159
+ tls: tlsStatusLine(cfg),
1160
+ lastError: lastError ?? null
1161
+ });
1162
+ return;
1163
+ }
1164
+ if (req.method !== "POST") {
1165
+ send(405, { error: "method not allowed" });
1166
+ return;
1167
+ }
1168
+ const body = await readBody(req, 65536, res);
1169
+ if (body === void 0) return;
1170
+ let submitted;
1171
+ try {
1172
+ submitted = JSON.parse(body);
1173
+ } catch {
1174
+ send(400, { error: "invalid JSON body" });
1175
+ return;
1176
+ }
1177
+ if (typeof submitted !== "object" || submitted === null || Array.isArray(submitted)) {
1178
+ send(400, { error: "body must be a config object" });
1179
+ return;
1180
+ }
1181
+ let candidate;
1182
+ try {
1183
+ candidate = Config(submitted);
1184
+ } catch (error) {
1185
+ send(400, { error: error instanceof Error ? error.message : String(error) });
1186
+ return;
1187
+ }
1188
+ if (settingsScope === void 0) {
1189
+ send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
1190
+ return;
1191
+ }
1192
+ const section = {};
1193
+ for (const [key, value] of Object.entries(candidate)) {
1194
+ if (value === null || value === void 0) continue;
1195
+ if (typeof value === "string" && value === "" && OPTIONAL_CONFIG_KEYS.has(key)) continue;
1196
+ section[key] = value;
1197
+ }
1198
+ try {
1199
+ await settingsScope.replace(section);
1200
+ await syncGateway("config route save");
1201
+ const cfg = effective();
1202
+ send(200, {
1203
+ config: cfg,
1204
+ running: gateway !== void 0,
1205
+ port: cfg.gatewayPort,
1206
+ tls: tlsStatusLine(cfg),
1207
+ lastError: lastError ?? null
1208
+ });
1209
+ } catch (error) {
1210
+ send(409, { error: error instanceof Error ? error.message : String(error) });
1211
+ }
1212
+ };
1213
+ ctx.effect(() => ctx.webServer.register({
1214
+ kind: "exact",
1215
+ path: "/lan-gateway/config",
1216
+ handler: configRouteHandler
1217
+ }), "dsh-lan-gateway: config route");
1218
+ ctx.tools.register(lanGatewayTool({
1219
+ status() {
1220
+ const cfg = effective();
1221
+ const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
1222
+ return {
1223
+ ok: true,
1224
+ message: `LAN gateway: ${gateway !== void 0 ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : "stopped"}\n- dsh target: 127.0.0.1:${dshPort}\n- password: ${state.password !== void 0 ? "set" : "NOT SET"}\n- auth required for non-LAN: ${cfg.authRequired}\n- trusted LAN CIDRs: ${cfg.lanCidrs.join(", ") || "(none)"}\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d\n- TLS: ${tlsStatusLine(cfg)}` + (manualOverride !== void 0 ? `\n- manual override: ${manualOverride ? "enabled" : "disabled"}` : "") + (lastError !== void 0 ? `\n- last error: ${lastError}` : "")
1225
+ };
1226
+ },
1227
+ async enable() {
1228
+ manualOverride = true;
1229
+ await syncGateway("tool enable");
1230
+ return gateway !== void 0 ? {
1231
+ ok: true,
1232
+ message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}`
1233
+ } : {
1234
+ ok: false,
1235
+ message: `Failed to enable gateway: ${lastError ?? "unknown error"}`
1236
+ };
1237
+ },
1238
+ async disable() {
1239
+ manualOverride = false;
1240
+ await syncGateway("tool disable");
1241
+ return {
1242
+ ok: true,
1243
+ message: "Gateway disabled."
1244
+ };
1245
+ },
1246
+ setPassword(password) {
1247
+ if (password !== void 0 && password.length > 0 && password.length < 8) return {
1248
+ ok: false,
1249
+ message: "Password must be at least 8 characters."
1250
+ };
1251
+ const setting = password !== void 0 && password.length > 0;
1252
+ state = setPassword(state, setting ? password : void 0);
1253
+ saveState(state);
1254
+ gateway?.setState(state);
1255
+ return {
1256
+ ok: true,
1257
+ message: setting ? "Password set. Non-LAN access now requires it." : "Password cleared. Non-LAN access is now password-free (only safe if authRequired is false or no non-LAN sources exist)."
1258
+ };
1259
+ },
1260
+ rotateSecret() {
1261
+ const next = { cookieSecret: randomBytes(32).toString("base64") };
1262
+ if (state.password !== void 0) next.password = state.password;
1263
+ state = next;
1264
+ saveState(state);
1265
+ gateway?.setState(state);
1266
+ return {
1267
+ ok: true,
1268
+ message: "Session secret rotated. All existing login cookies are now invalid."
1269
+ };
1270
+ },
1271
+ async regenerateTls() {
1272
+ const cfg = effective();
1273
+ if (!cfg.tlsEnabled || cfg.tlsMode !== "self-signed") return {
1274
+ ok: false,
1275
+ message: "TLS is off or in custom mode — nothing to regenerate. Enable tlsEnabled with tlsMode=self-signed first."
1276
+ };
1277
+ const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts);
1278
+ if (hosts.length === 0) return {
1279
+ ok: false,
1280
+ message: "tlsSelfSignedHosts must name at least one host (DNS name or IP)."
1281
+ };
1282
+ try {
1283
+ regenerateSelfSigned({
1284
+ hosts,
1285
+ days: cfg.tlsCertMaxAgeDays
1286
+ });
1287
+ if (gateway !== void 0) {
1288
+ await stopGateway();
1289
+ await startGateway(effective());
1290
+ lastError = void 0;
1291
+ }
1292
+ return {
1293
+ ok: true,
1294
+ message: "Self-signed certificate regenerated (new key). Listener restarted with the new certificate."
1295
+ };
1296
+ } catch (error) {
1297
+ return {
1298
+ ok: false,
1299
+ message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`
1300
+ };
1301
+ }
1302
+ }
1303
+ }));
1304
+ ctx.effect(async () => {
1305
+ await syncGateway("boot");
1306
+ return stopGateway;
1307
+ }, "dsh-lan-gateway: listener lifecycle");
1308
+ }
1309
+ //#endregion
1310
+ export { Config, apply, inject, name };