@lornatang/dsh-frontgate 0.0.0-stage → 0.1.2

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,1167 @@
1
+ // src/index.ts
2
+ import http2 from "node:http";
3
+ import https2 from "node:https";
4
+ import { randomBytes as randomBytes3 } from "node:crypto";
5
+ import { readFileSync as readFileSync2 } from "node:fs";
6
+
7
+ // src/config.ts
8
+ import z from "@deepseek-ai/schemastery";
9
+ var DEFAULTS = {
10
+ listenHost: "127.0.0.1",
11
+ listenPort: 3090,
12
+ sessionDays: 7,
13
+ title: "DeepSeek Harness",
14
+ password: "",
15
+ passwordHash: "",
16
+ tlsCertFile: "",
17
+ tlsKeyFile: "",
18
+ publicOrigin: "",
19
+ trustProxy: false,
20
+ maxAttempts: 5,
21
+ lockMinutes: 5,
22
+ exchangeTimeoutMs: 5e3,
23
+ maxConnectionsPerPeer: 64,
24
+ maxPendingLoginsPerIp: 4,
25
+ upstreamHeaderTimeoutMs: 6e4,
26
+ upstreamIdleTimeoutMs: 3e5,
27
+ upstreamCaFile: "",
28
+ upstreamServerName: ""
29
+ };
30
+ var Config = z.object({
31
+ listenHost: z.string().default(DEFAULTS.listenHost),
32
+ listenPort: z.natural().max(65535).default(DEFAULTS.listenPort),
33
+ sessionDays: z.natural().min(1).max(365).default(DEFAULTS.sessionDays),
34
+ title: z.string().default(DEFAULTS.title),
35
+ password: z.string().default(DEFAULTS.password),
36
+ passwordHash: z.string().default(DEFAULTS.passwordHash),
37
+ tlsCertFile: z.string().default(DEFAULTS.tlsCertFile),
38
+ tlsKeyFile: z.string().default(DEFAULTS.tlsKeyFile),
39
+ publicOrigin: z.string().default(DEFAULTS.publicOrigin),
40
+ trustProxy: z.boolean().default(DEFAULTS.trustProxy),
41
+ maxAttempts: z.natural().min(1).max(50).default(DEFAULTS.maxAttempts),
42
+ lockMinutes: z.natural().min(1).max(1440).default(DEFAULTS.lockMinutes),
43
+ exchangeTimeoutMs: z.natural().min(100).max(6e4).default(DEFAULTS.exchangeTimeoutMs),
44
+ maxConnectionsPerPeer: z.natural().max(1024).default(DEFAULTS.maxConnectionsPerPeer),
45
+ maxPendingLoginsPerIp: z.natural().min(1).max(128).default(DEFAULTS.maxPendingLoginsPerIp),
46
+ upstreamHeaderTimeoutMs: z.natural().max(36e5).default(DEFAULTS.upstreamHeaderTimeoutMs),
47
+ upstreamIdleTimeoutMs: z.natural().max(36e5).default(DEFAULTS.upstreamIdleTimeoutMs),
48
+ upstreamCaFile: z.string().default(DEFAULTS.upstreamCaFile),
49
+ upstreamServerName: z.string().default(DEFAULTS.upstreamServerName)
50
+ });
51
+
52
+ // src/auth.ts
53
+ import { createHash, createHmac, randomBytes, scrypt, scryptSync, timingSafeEqual } from "node:crypto";
54
+ var COOKIE = "dsh_gate";
55
+ var TOKEN_VERSION = "v1";
56
+ var MAX_TOKEN_LENGTH = 256;
57
+ var MIN_PASSWORD_LENGTH = 12;
58
+ var SCRYPT = Object.freeze({ N: 32768, r: 8, p: 1, keylen: 32 });
59
+ var PASSWORD_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
60
+ var HASH_RE = /^scrypt\$\d+\$\d+\$\d+\$[0-9a-f]{32,64}\$[0-9a-f]{32,128}$/;
61
+ var scryptMaxmem = (N, r) => Math.max(64 * 1024 * 1024, 256 * N * r);
62
+ function assertStrongPassword(password, source) {
63
+ const length = [...String(password)].length;
64
+ if (length < MIN_PASSWORD_LENGTH) {
65
+ throw new Error(
66
+ "dsh-frontgate: " + source + " must be at least " + MIN_PASSWORD_LENGTH + " characters; got " + length
67
+ );
68
+ }
69
+ return String(password);
70
+ }
71
+ function hashPassword(password, params = {}) {
72
+ const { N = SCRYPT.N, r = SCRYPT.r, p = SCRYPT.p, keylen = SCRYPT.keylen } = params;
73
+ const salt = randomBytes(16);
74
+ const key = scryptSync(String(password), salt, keylen, { N, r, p, maxmem: scryptMaxmem(N, r) });
75
+ return "scrypt$" + N + "$" + r + "$" + p + "$" + salt.toString("hex") + "$" + key.toString("hex");
76
+ }
77
+ function scryptAsync(password, salt, keylen, params) {
78
+ return new Promise((resolve) => {
79
+ scrypt(
80
+ password,
81
+ salt,
82
+ keylen,
83
+ { ...params, maxmem: scryptMaxmem(params.N, params.r) },
84
+ (err, key) => resolve(err ? void 0 : key)
85
+ );
86
+ });
87
+ }
88
+ function parseStored(stored) {
89
+ if (typeof stored !== "string") return void 0;
90
+ const parts = stored.split("$");
91
+ if (parts.length !== 6 || parts[0] !== "scrypt") return void 0;
92
+ const N = Number(parts[1]);
93
+ const r = Number(parts[2]);
94
+ const p = Number(parts[3]);
95
+ if (!Number.isInteger(N) || N < 1024 || N > 1048576 || (N & N - 1) !== 0) return void 0;
96
+ if (!Number.isInteger(r) || r < 1 || r > 32) return void 0;
97
+ if (!Number.isInteger(p) || p < 1 || p > 4) return void 0;
98
+ let salt;
99
+ let want;
100
+ try {
101
+ salt = Buffer.from(parts[4], "hex");
102
+ want = Buffer.from(parts[5], "hex");
103
+ } catch {
104
+ return void 0;
105
+ }
106
+ if (salt.length < 8 || salt.length > 32 || want.length < 16 || want.length > 64) return void 0;
107
+ return { N, r, p, salt, want };
108
+ }
109
+ function validPasswordHash(value) {
110
+ return typeof value === "string" && HASH_RE.test(value) && parseStored(value) !== void 0;
111
+ }
112
+ async function verifyPassword(password, stored) {
113
+ const parsed = parseStored(stored);
114
+ if (!parsed) return false;
115
+ const { N, r, p, salt, want } = parsed;
116
+ const got = await scryptAsync(String(password), salt, want.length, { N, r, p });
117
+ return got !== void 0 && got.length === want.length && timingSafeEqual(got, want);
118
+ }
119
+ function verifyPasswordSync(password, stored) {
120
+ const parsed = parseStored(stored);
121
+ if (!parsed) return false;
122
+ const { N, r, p, salt, want } = parsed;
123
+ try {
124
+ const got = scryptSync(String(password), salt, want.length, { N, r, p, maxmem: scryptMaxmem(N, r) });
125
+ return got.length === want.length && timingSafeEqual(got, want);
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ function randomPassword() {
131
+ const bytes = randomBytes(16);
132
+ let out = "";
133
+ for (let i = 0; i < 16; i++) out += PASSWORD_ALPHABET[bytes[i] & 31];
134
+ return out.slice(0, 4) + "-" + out.slice(4, 8) + "-" + out.slice(8, 12) + "-" + out.slice(12, 16);
135
+ }
136
+ var passwordVersion = (passwordHash) => createHash("sha256").update(passwordHash).digest("base64url").slice(0, 12);
137
+ function signToken(secret, pv, ttlSeconds) {
138
+ const now = Math.floor(Date.now() / 1e3);
139
+ const body = TOKEN_VERSION + "." + now + "." + (now + ttlSeconds) + "." + randomBytes(16).toString("base64url") + "." + pv;
140
+ return body + "." + createHmac("sha256", secret).update(body).digest("base64url");
141
+ }
142
+ function verifyToken(secret, pv, token) {
143
+ if (typeof token !== "string" || token.length > MAX_TOKEN_LENGTH) return false;
144
+ const parts = token.split(".");
145
+ if (parts.length !== 6) return false;
146
+ const [version, iat, exp, nonce, tokenPv, mac] = parts;
147
+ if (version !== TOKEN_VERSION || tokenPv !== pv) return false;
148
+ if (!/^[A-Za-z0-9_-]{22}$/.test(nonce) || !/^[A-Za-z0-9_-]{43}$/.test(mac)) return false;
149
+ const now = Math.floor(Date.now() / 1e3);
150
+ const issued = Number(iat);
151
+ const expires = Number(exp);
152
+ if (!Number.isSafeInteger(issued) || !Number.isSafeInteger(expires)) return false;
153
+ if (expires <= now || issued > now + 300) return false;
154
+ const body = version + "." + iat + "." + exp + "." + nonce + "." + tokenPv;
155
+ const want = createHmac("sha256", secret).update(body).digest();
156
+ let got;
157
+ try {
158
+ got = Buffer.from(mac, "base64url");
159
+ } catch {
160
+ return false;
161
+ }
162
+ return got.length === want.length && got.toString("base64url") === mac && timingSafeEqual(got, want);
163
+ }
164
+ function sessionCookie(value, seconds, useTls) {
165
+ const attrs = [COOKIE + "=" + value, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=" + seconds];
166
+ if (useTls) attrs.push("Secure");
167
+ return attrs.join("; ");
168
+ }
169
+ var clearCookie = (secure) => sessionCookie("", 0, secure);
170
+
171
+ // src/context.ts
172
+ function resolveLogger(ctx) {
173
+ void ctx;
174
+ const write = (message) => {
175
+ process.stderr.write("[dsh-frontgate] " + message + "\n");
176
+ };
177
+ return { info: write, warn: write };
178
+ }
179
+
180
+ // src/http.ts
181
+ var MAX_FORM = 4 * 1024;
182
+ var FORM_TIMEOUT = 8 * 1e3;
183
+ var intOr = (value, fallback, min, max) => {
184
+ const n = Number.parseInt(String(value), 10);
185
+ return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
186
+ };
187
+ function cookieValue(header, name2) {
188
+ if (typeof header !== "string") return "";
189
+ for (const part of header.split(";")) {
190
+ const at = part.indexOf("=");
191
+ if (at !== -1 && part.slice(0, at).trim() === name2) return part.slice(at + 1).trim();
192
+ }
193
+ return "";
194
+ }
195
+ function clientIp(req, trustProxy) {
196
+ if (trustProxy) {
197
+ const forwarded = req.headers["x-forwarded-for"];
198
+ if (typeof forwarded === "string" && forwarded) return (forwarded.split(",")[0] ?? "").trim().slice(0, 64);
199
+ }
200
+ return String(req.socket.remoteAddress || "").replace(/^::ffff:/, "") || "unknown";
201
+ }
202
+ function normalizePublicOrigin(value) {
203
+ if (!value) return "";
204
+ try {
205
+ const url = new URL(value);
206
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("invalid origin");
207
+ return url.origin;
208
+ } catch {
209
+ throw new Error("dsh-frontgate: publicOrigin must be an HTTP(S) origin without a path, query, or credentials");
210
+ }
211
+ }
212
+ function requestOrigin(req, publicOrigin, useTls) {
213
+ const host = req.headers.host;
214
+ if (typeof host !== "string" || !host || /[\s/@?#\\]/.test(host)) return void 0;
215
+ try {
216
+ const protocol = publicOrigin ? new URL(publicOrigin).protocol : useTls ? "https:" : "http:";
217
+ const url = new URL(protocol + "//" + host);
218
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) return void 0;
219
+ if (publicOrigin && url.origin !== publicOrigin) return void 0;
220
+ return url.origin;
221
+ } catch {
222
+ return void 0;
223
+ }
224
+ }
225
+ var hostMatchesPublicOrigin = (req, publicOrigin, useTls) => requestOrigin(req, publicOrigin, useTls) !== void 0;
226
+ function originMatchesHost(req, publicOrigin, useTls) {
227
+ const expected = requestOrigin(req, publicOrigin, useTls);
228
+ if (!expected) return false;
229
+ const origin = req.headers.origin;
230
+ if (origin === void 0) return true;
231
+ try {
232
+ const url = new URL(origin);
233
+ return origin === url.origin && url.origin === expected;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+ function sameOrigin(req, publicOrigin, useTls) {
239
+ const site = req.headers["sec-fetch-site"];
240
+ if (site !== void 0 && site !== "same-origin" && site !== "none") return false;
241
+ return originMatchesHost(req, publicOrigin, useTls);
242
+ }
243
+ function safeNext(value) {
244
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return "/";
245
+ if (value.includes("\\") || /[\u0000-\u001f\u007f]/.test(value)) return "/";
246
+ return value;
247
+ }
248
+ var ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
249
+ var escapeHtml = (text2) => String(text2).replace(/[&<>"']/g, (ch) => ENTITIES[ch] ?? ch);
250
+ var CSP = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
251
+ var GATE_HEADERS = {
252
+ "cache-control": "no-store",
253
+ "content-security-policy": CSP,
254
+ "x-content-type-options": "nosniff",
255
+ "referrer-policy": "no-referrer",
256
+ "x-frame-options": "DENY"
257
+ };
258
+ var LOGIN_REFERRER_POLICY = "same-origin";
259
+ function send(res, status, headers, body) {
260
+ res.writeHead(status, headers);
261
+ res.end(body);
262
+ }
263
+ var html = (res, status, body) => send(res, status, { ...GATE_HEADERS, "referrer-policy": LOGIN_REFERRER_POLICY, "content-type": "text/html; charset=utf-8" }, body);
264
+ var json = (res, status, payload) => send(res, status, { ...GATE_HEADERS, "content-type": "application/json; charset=utf-8" }, JSON.stringify(payload));
265
+ var text = (res, status, body, extra = {}) => send(res, status, { ...GATE_HEADERS, "content-type": "text/plain; charset=utf-8", ...extra }, body);
266
+ var redirect = (res, location, status = 302) => send(res, status, { ...GATE_HEADERS, location }, "");
267
+ function readForm(req) {
268
+ return new Promise((resolve, reject) => {
269
+ let size = 0;
270
+ let settled = false;
271
+ const chunks = [];
272
+ const finish = (fn, value) => {
273
+ if (settled) return;
274
+ settled = true;
275
+ clearTimeout(timer);
276
+ fn(value);
277
+ };
278
+ const fail = (error) => {
279
+ if (settled) return;
280
+ settled = true;
281
+ clearTimeout(timer);
282
+ reject(error);
283
+ };
284
+ const timer = setTimeout(() => {
285
+ fail(new Error("timeout"));
286
+ req.resume();
287
+ }, FORM_TIMEOUT);
288
+ req.on("data", (chunk) => {
289
+ if (settled) return;
290
+ size += chunk.length;
291
+ if (size > MAX_FORM) {
292
+ fail(new Error("too large"));
293
+ req.resume();
294
+ return;
295
+ }
296
+ chunks.push(chunk);
297
+ });
298
+ req.on("end", () => finish(resolve, Buffer.concat(chunks).toString("utf8")));
299
+ req.on("error", (err) => fail(err));
300
+ req.on("close", () => {
301
+ if (!req.complete) fail(new Error("aborted"));
302
+ });
303
+ });
304
+ }
305
+ var isLoopbackHost = (host) => host === "localhost" || host === "::1" || /^127\./.test(host);
306
+ var connectHost = (hostname) => hostname.replace(/^\[/, "").replace(/\]$/, "");
307
+
308
+ // src/polyfill.ts
309
+ var POLYFILL = '<script data-dsh-frontgate>(function(){var c=globalThis.crypto;if(!c||typeof c.randomUUID==="function")return;c.randomUUID=function(){var b=c.getRandomValues(new Uint8Array(16));b[6]=b[6]&15|64;b[8]=b[8]&63|128;var s="";for(var i=0;i<16;i++)s+=(b[i]+256).toString(16).slice(1);return s.slice(0,8)+"-"+s.slice(8,12)+"-"+s.slice(12,16)+"-"+s.slice(16,20)+"-"+s.slice(20)}})();</script>';
310
+ function injectPolyfill(htmlText) {
311
+ if (typeof htmlText !== "string" || htmlText.includes("data-dsh-frontgate")) return htmlText;
312
+ const at = htmlText.indexOf("</head>");
313
+ return at === -1 ? htmlText : htmlText.slice(0, at) + POLYFILL + htmlText.slice(at);
314
+ }
315
+
316
+ // src/proxy.ts
317
+ import http from "node:http";
318
+ import https from "node:https";
319
+ import net from "node:net";
320
+ import tls from "node:tls";
321
+ var HARNESS_COOKIE_TTL = 25 * 60 * 1e3;
322
+ var REQUEST_DROP = /* @__PURE__ */ new Set([
323
+ "host",
324
+ "origin",
325
+ "cookie",
326
+ "connection",
327
+ "keep-alive",
328
+ "proxy-connection",
329
+ "transfer-encoding",
330
+ "upgrade",
331
+ "te",
332
+ "trailer",
333
+ "x-forwarded-for",
334
+ "x-forwarded-proto",
335
+ "x-forwarded-host"
336
+ ]);
337
+ var RESPONSE_DROP = /* @__PURE__ */ new Set([
338
+ "set-cookie",
339
+ "connection",
340
+ "keep-alive",
341
+ "proxy-connection",
342
+ "transfer-encoding",
343
+ "upgrade",
344
+ "trailer"
345
+ ]);
346
+ function sendUpstreamError(res) {
347
+ if (res.headersSent) {
348
+ res.destroy();
349
+ return;
350
+ }
351
+ text(res, 502, "dsh-frontgate: upstream unreachable\n");
352
+ }
353
+ function createProxy(options) {
354
+ const {
355
+ targetOrigin,
356
+ publicProto,
357
+ trustProxy,
358
+ exchangeTimeoutMs,
359
+ upstreamHeaderTimeoutMs,
360
+ upstreamIdleTimeoutMs,
361
+ upstreamCa,
362
+ upstreamServerName,
363
+ authenticatedUrl,
364
+ log
365
+ } = options;
366
+ const target = new URL(targetOrigin);
367
+ const secureUpstream = target.protocol === "https:";
368
+ const transport = secureUpstream ? https : http;
369
+ const AgentClass = secureUpstream ? https.Agent : http.Agent;
370
+ const upstreamAgent = new AgentClass({ keepAlive: true, maxSockets: 128, keepAliveMsecs: 1e3 });
371
+ const tlsOptions = secureUpstream ? { ...upstreamCa ? { ca: upstreamCa } : {}, ...upstreamServerName ? { servername: upstreamServerName } : {} } : {};
372
+ const exchanges = /* @__PURE__ */ new Set();
373
+ function exchangeHarnessCookie() {
374
+ const url = new URL(authenticatedUrl(targetOrigin + "/"));
375
+ return new Promise((resolve, reject) => {
376
+ let settled = false;
377
+ const finish = (error, cookie) => {
378
+ if (settled) return;
379
+ settled = true;
380
+ clearTimeout(timer);
381
+ exchanges.delete(req);
382
+ if (error) reject(error);
383
+ else resolve(cookie);
384
+ };
385
+ const req = transport.request({
386
+ host: connectHost(url.hostname),
387
+ port: Number(url.port) || (secureUpstream ? 443 : 80),
388
+ path: url.pathname + url.search,
389
+ method: "GET",
390
+ headers: { host: target.host, accept: "text/html" },
391
+ ...tlsOptions
392
+ }, (res) => {
393
+ res.resume();
394
+ const lines = res.headers["set-cookie"] || [];
395
+ const cookie = lines.map((line) => line.split(";")[0]).filter(Boolean).join("; ");
396
+ if (!cookie) finish(new Error("launch-token exchange returned " + res.statusCode + " without set-cookie"));
397
+ else finish(void 0, cookie);
398
+ });
399
+ const timer = setTimeout(() => req.destroy(new Error("launch-token exchange timed out")), exchangeTimeoutMs);
400
+ exchanges.add(req);
401
+ req.on("error", (err) => finish(err));
402
+ req.end();
403
+ });
404
+ }
405
+ let harnessCache = { value: "", at: 0 };
406
+ let harnessInflight = null;
407
+ function invalidateHarnessCookie(cookie) {
408
+ if (harnessCache.value === cookie) harnessCache = { value: "", at: 0 };
409
+ }
410
+ function harnessCookie() {
411
+ if (harnessCache.value && Date.now() - harnessCache.at < HARNESS_COOKIE_TTL) return Promise.resolve(harnessCache.value);
412
+ if (harnessInflight) return harnessInflight;
413
+ harnessInflight = Promise.resolve().then(exchangeHarnessCookie).then((cookie) => {
414
+ harnessCache = { value: cookie, at: Date.now() };
415
+ return cookie;
416
+ }).finally(() => {
417
+ harnessInflight = null;
418
+ });
419
+ return harnessInflight;
420
+ }
421
+ function upstreamHeaders(req, cookie) {
422
+ const out = {};
423
+ for (const [key, value] of Object.entries(req.headers)) {
424
+ if (!REQUEST_DROP.has(key)) out[key] = value;
425
+ }
426
+ out.host = target.host;
427
+ if (req.headers.origin !== void 0) out.origin = target.origin;
428
+ if (cookie) out.cookie = cookie;
429
+ out["x-forwarded-for"] = clientIp(req, trustProxy);
430
+ out["x-forwarded-proto"] = publicProto;
431
+ out["x-forwarded-host"] = String(req.headers.host || "");
432
+ return out;
433
+ }
434
+ function rewriteLocation(value) {
435
+ if (!value.startsWith(targetOrigin)) return value;
436
+ const rest = value.slice(targetOrigin.length);
437
+ if (rest === "") return "/";
438
+ if (rest.startsWith("//") || rest.startsWith("/\\")) return "/";
439
+ return rest.startsWith("/") ? rest : value;
440
+ }
441
+ function forward(req, res, cookie) {
442
+ const started = Date.now();
443
+ let headerTimer;
444
+ let idleTimer;
445
+ let timedOut = false;
446
+ const clearTimers = () => {
447
+ if (headerTimer) clearTimeout(headerTimer);
448
+ if (idleTimer) clearTimeout(idleTimer);
449
+ headerTimer = void 0;
450
+ idleTimer = void 0;
451
+ };
452
+ const proxyReq = transport.request({
453
+ agent: upstreamAgent,
454
+ host: connectHost(target.hostname),
455
+ port: Number(target.port) || (secureUpstream ? 443 : 80),
456
+ method: req.method,
457
+ path: req.url,
458
+ headers: upstreamHeaders(req, cookie),
459
+ ...tlsOptions
460
+ }, (proxyRes) => {
461
+ if (headerTimer) clearTimeout(headerTimer);
462
+ headerTimer = void 0;
463
+ if (res.destroyed) {
464
+ proxyRes.destroy();
465
+ return;
466
+ }
467
+ const headers = {};
468
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
469
+ if (!RESPONSE_DROP.has(key)) headers[key] = value;
470
+ }
471
+ const location = proxyRes.headers["location"];
472
+ if (location !== void 0) headers.location = rewriteLocation(location);
473
+ const status = proxyRes.statusCode || 502;
474
+ if (status === 401) invalidateHarnessCookie(cookie);
475
+ res.writeHead(status, headers);
476
+ const armIdle = () => {
477
+ if (idleTimer) clearTimeout(idleTimer);
478
+ if (!upstreamIdleTimeoutMs) return;
479
+ idleTimer = setTimeout(() => {
480
+ if (proxyRes.isPaused() || res.writableNeedDrain) {
481
+ armIdle();
482
+ return;
483
+ }
484
+ timedOut = true;
485
+ proxyRes.destroy(new Error("upstream response idle timeout"));
486
+ }, upstreamIdleTimeoutMs);
487
+ };
488
+ proxyRes.on("data", armIdle);
489
+ proxyRes.once("end", clearTimers);
490
+ res.on("drain", armIdle);
491
+ armIdle();
492
+ proxyRes.pipe(res);
493
+ proxyRes.on("error", () => {
494
+ clearTimers();
495
+ res.destroy();
496
+ });
497
+ const elapsed = Date.now() - started;
498
+ if (status >= 400 || elapsed > 1e3) {
499
+ log.info(req.method + " " + String(req.url || "/").split("?")[0] + " " + status + " " + elapsed + "ms");
500
+ }
501
+ });
502
+ if (upstreamHeaderTimeoutMs) {
503
+ headerTimer = setTimeout(() => {
504
+ timedOut = true;
505
+ proxyReq.destroy(new Error("upstream response headers timeout"));
506
+ }, upstreamHeaderTimeoutMs);
507
+ }
508
+ proxyReq.on("error", (err) => {
509
+ clearTimers();
510
+ if (res.destroyed) return;
511
+ log.warn("upstream error: " + err.message);
512
+ if (timedOut && !res.headersSent) return text(res, 504, "dsh-frontgate: upstream timeout\n");
513
+ sendUpstreamError(res);
514
+ });
515
+ res.on("close", () => {
516
+ clearTimers();
517
+ proxyReq.destroy();
518
+ });
519
+ req.pipe(proxyReq);
520
+ }
521
+ function forwardUpgrade(req, socket, head, cookie) {
522
+ const port = Number(target.port) || (secureUpstream ? 443 : 80);
523
+ const host = connectHost(target.hostname);
524
+ const lines = [req.method + " " + (req.url || "/") + " HTTP/1.1"];
525
+ const raw = req.rawHeaders || [];
526
+ for (let i = 0; i + 1 < raw.length; i += 2) {
527
+ const lower = raw[i].toLowerCase();
528
+ if (lower === "host" || lower === "origin" || lower === "cookie" || lower === "proxy-connection") continue;
529
+ if (lower === "x-forwarded-for" || lower === "x-forwarded-proto" || lower === "x-forwarded-host") continue;
530
+ if (lower === "transfer-encoding") continue;
531
+ lines.push(raw[i] + ": " + raw[i + 1]);
532
+ }
533
+ lines.push("Host: " + target.host);
534
+ if (req.headers.origin !== void 0) lines.push("Origin: " + target.origin);
535
+ if (cookie) lines.push("Cookie: " + cookie);
536
+ lines.push("X-Forwarded-For: " + clientIp(req, trustProxy));
537
+ lines.push("X-Forwarded-Proto: " + publicProto);
538
+ lines.push("X-Forwarded-Host: " + String(req.headers.host || ""));
539
+ lines.push("", "");
540
+ const payload = lines.join("\r\n");
541
+ const backend = secureUpstream ? tls.connect({ host, port, ...tlsOptions }) : net.connect(port, host);
542
+ let closed = false;
543
+ const teardown = (message) => {
544
+ if (closed) return;
545
+ closed = true;
546
+ if (message) log.warn("websocket " + String(req.url || "/").split("?")[0] + ": " + message);
547
+ socket.destroy();
548
+ backend.destroy();
549
+ };
550
+ const start = () => {
551
+ backend.setTimeout(0);
552
+ let statusLine = "";
553
+ let statusRead = false;
554
+ backend.on("data", (chunk) => {
555
+ if (statusRead) return;
556
+ statusLine += chunk.toString("latin1", 0, Math.max(0, 256 - statusLine.length));
557
+ const end = statusLine.indexOf("\r\n");
558
+ if (end !== -1 || statusLine.length >= 256) {
559
+ statusRead = true;
560
+ if (/^HTTP\/1\.[01] 401(?:\s|$)/.test(statusLine.slice(0, end === -1 ? void 0 : end))) {
561
+ invalidateHarnessCookie(cookie);
562
+ }
563
+ }
564
+ });
565
+ backend.write(payload);
566
+ if (head && head.length) backend.write(head);
567
+ backend.pipe(socket);
568
+ socket.pipe(backend);
569
+ };
570
+ backend.setTimeout(1e4, () => teardown("connect timeout"));
571
+ if (secureUpstream) backend.once("secureConnect", start);
572
+ else backend.once("connect", start);
573
+ backend.on("error", (err) => teardown(err.message));
574
+ socket.on("error", (err) => teardown(err.message));
575
+ socket.on("end", () => teardown());
576
+ backend.on("end", () => teardown());
577
+ socket.on("close", () => teardown());
578
+ backend.on("close", () => teardown());
579
+ }
580
+ return {
581
+ harnessCookie,
582
+ forward,
583
+ forwardUpgrade,
584
+ destroy: () => {
585
+ for (const request of exchanges) request.destroy(new Error("proxy stopped"));
586
+ upstreamAgent.destroy();
587
+ }
588
+ };
589
+ }
590
+
591
+ // src/page.ts
592
+ function loginPage(title, next, error) {
593
+ const action = "/__gate/login?next=" + encodeURIComponent(next);
594
+ const message = error ? '<p class="err">' + escapeHtml(error) + "</p>" : "";
595
+ return [
596
+ "<!doctype html>",
597
+ '<html lang="en"><head><meta charset="utf-8">',
598
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
599
+ '<meta name="referrer" content="' + LOGIN_REFERRER_POLICY + '">',
600
+ "<title>" + escapeHtml(title) + " - Sign in</title>",
601
+ "<style>",
602
+ " :root{color-scheme:dark}",
603
+ ' body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0e1014;color:#e6e8ee;font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}',
604
+ " form{width:min(360px,calc(100vw - 48px));padding:28px 26px 24px;border:1px solid #262b36;border-radius:14px;background:#151922;box-shadow:0 20px 60px rgba(0,0,0,.45)}",
605
+ " h1{margin:0 0 6px;font-size:16px;font-weight:600}",
606
+ " p{margin:0 0 18px;color:#8b93a5;font-size:12.5px}",
607
+ " .err{padding:9px 11px;margin:0 0 14px;border-radius:8px;background:rgba(240,92,92,.12);border:1px solid rgba(240,92,92,.3);color:#ff9b9b}",
608
+ " input{width:100%;box-sizing:border-box;padding:10px 12px;margin:0 0 14px;border-radius:9px;border:1px solid #2c3341;background:#0f131a;color:inherit;font:inherit;outline:none}",
609
+ " input:focus{border-color:#3f6df0;box-shadow:0 0 0 3px rgba(63,109,240,.18)}",
610
+ " button{width:100%;padding:10px 12px;border:0;border-radius:9px;background:#3f6df0;color:#fff;font:inherit;font-weight:600;cursor:pointer}",
611
+ " button:hover{background:#4b78ff}",
612
+ "</style></head>",
613
+ "<body>",
614
+ '<form method="post" action="' + action + '">',
615
+ " <h1>" + escapeHtml(title) + "</h1>",
616
+ " <p>This instance is protected by a password gate. Enter the access password.</p>",
617
+ " " + message,
618
+ ' <input type="password" name="password" placeholder="Password" autofocus autocomplete="current-password">',
619
+ ' <button type="submit">Sign in</button>',
620
+ "</form>",
621
+ "</body></html>"
622
+ ].join("\n");
623
+ }
624
+
625
+ // src/gate.ts
626
+ var MAX_TRACKED_CLIENTS = 1024;
627
+ var CHECK_CONCURRENCY = 2;
628
+ function createHandler(options) {
629
+ const { runtime, harnessCookie, validSession, revokeSession, forward, log } = options;
630
+ const { title, passwordHash, secret, pv, sessionSeconds, maxAttempts, maxPendingLoginsPerIp, lockMs, trustProxy, useTls, publicOrigin, secureCookie } = runtime;
631
+ const failures = /* @__PURE__ */ new Map();
632
+ const clientsInFlight = /* @__PURE__ */ new Set();
633
+ const pendingForms = /* @__PURE__ */ new Map();
634
+ let checksInFlight = 0;
635
+ function lockRemaining(ip) {
636
+ const entry = failures.get(ip);
637
+ if (!entry || !entry.until) return 0;
638
+ if (entry.until > Date.now()) return entry.until - Date.now();
639
+ failures.delete(ip);
640
+ return 0;
641
+ }
642
+ function recordFailure(ip) {
643
+ let entry = failures.get(ip);
644
+ if (!entry) {
645
+ if (failures.size >= MAX_TRACKED_CLIENTS) {
646
+ const oldest = failures.keys().next().value;
647
+ if (oldest !== void 0) failures.delete(oldest);
648
+ }
649
+ entry = { count: 0, until: 0 };
650
+ failures.set(ip, entry);
651
+ }
652
+ entry.count += 1;
653
+ if (entry.count >= maxAttempts) {
654
+ entry.count = 0;
655
+ entry.until = Date.now() + lockMs;
656
+ }
657
+ }
658
+ function sessionOf(req) {
659
+ const token = cookieValue(req.headers.cookie, COOKIE);
660
+ return Boolean(token) && validSession(token);
661
+ }
662
+ async function handleLogin(req, res, search) {
663
+ const next = safeNext(new URLSearchParams(search).get("next") || "/");
664
+ if (req.method === "POST") {
665
+ if (!sameOrigin(req, publicOrigin, useTls)) {
666
+ req.resume();
667
+ res.setHeader("connection", "close");
668
+ return html(res, 403, loginPage(title, next, "Origin check failed."));
669
+ }
670
+ const declared = Number(req.headers["content-length"] || 0);
671
+ if (declared > MAX_FORM) {
672
+ req.resume();
673
+ return text(res, 413, "request body too large\n", { connection: "close" });
674
+ }
675
+ const ip = clientIp(req, trustProxy);
676
+ const wait = lockRemaining(ip);
677
+ if (wait > 0) {
678
+ req.resume();
679
+ return text(res, 429, "too many attempts\n", { "retry-after": String(Math.ceil(wait / 1e3)), connection: "close" });
680
+ }
681
+ const pending = pendingForms.get(ip) ?? 0;
682
+ if (pending >= maxPendingLoginsPerIp) {
683
+ req.resume();
684
+ return text(res, 429, "too many pending logins\n", { "retry-after": "1", connection: "close" });
685
+ }
686
+ pendingForms.set(ip, pending + 1);
687
+ let form;
688
+ try {
689
+ form = await readForm(req);
690
+ } catch (err) {
691
+ if (res.destroyed) return;
692
+ if (err.message === "too large") return text(res, 413, "request body too large\n", { connection: "close" });
693
+ if (err.message === "timeout") return text(res, 408, "request timeout\n", { connection: "close" });
694
+ return text(res, 400, "bad request\n", { connection: "close" });
695
+ } finally {
696
+ const count = pendingForms.get(ip) ?? 1;
697
+ if (count <= 1) pendingForms.delete(ip);
698
+ else pendingForms.set(ip, count - 1);
699
+ }
700
+ const remaining = lockRemaining(ip);
701
+ if (remaining > 0) return text(res, 429, "too many attempts\n", { "retry-after": String(Math.ceil(remaining / 1e3)) });
702
+ if (clientsInFlight.has(ip) || checksInFlight >= CHECK_CONCURRENCY) {
703
+ return text(res, 429, "busy, retry shortly\n", { "retry-after": "1" });
704
+ }
705
+ clientsInFlight.add(ip);
706
+ checksInFlight += 1;
707
+ let ok = false;
708
+ try {
709
+ ok = await verifyPassword(new URLSearchParams(form).get("password") || "", passwordHash);
710
+ } finally {
711
+ checksInFlight -= 1;
712
+ clientsInFlight.delete(ip);
713
+ }
714
+ if (!ok) {
715
+ recordFailure(ip);
716
+ log.info("login failed from " + ip);
717
+ return html(res, 401, loginPage(title, next, "Wrong password."));
718
+ }
719
+ failures.delete(ip);
720
+ log.info("login from " + ip);
721
+ res.setHeader("set-cookie", sessionCookie(signToken(secret, pv, sessionSeconds), sessionSeconds, secureCookie));
722
+ return redirect(res, next, 303);
723
+ }
724
+ if (req.method !== "GET" && req.method !== "HEAD") {
725
+ return text(res, 405, "method not allowed\n", { allow: "GET, POST" });
726
+ }
727
+ if (sessionOf(req)) return redirect(res, "/");
728
+ return html(res, 200, loginPage(title, next, ""));
729
+ }
730
+ return async function handle(req, res) {
731
+ const raw = req.url || "/";
732
+ if (!raw.startsWith("/")) return text(res, 400, "bad request\n");
733
+ const queryAt = raw.indexOf("?");
734
+ const path = queryAt === -1 ? raw : raw.slice(0, queryAt);
735
+ const search = queryAt === -1 ? "" : raw.slice(queryAt);
736
+ if (path === "/__gate/health") return json(res, 200, { ok: true, service: "dsh-frontgate" });
737
+ if (!hostMatchesPublicOrigin(req, publicOrigin, useTls)) return text(res, 403, "invalid host\n");
738
+ if (path === "/__gate/login") return handleLogin(req, res, search);
739
+ if (path === "/__gate/logout") {
740
+ if (!sameOrigin(req, publicOrigin, useTls)) return text(res, 403, "forbidden\n");
741
+ const token2 = cookieValue(req.headers.cookie, COOKIE);
742
+ if (token2 && validSession(token2)) {
743
+ try {
744
+ revokeSession(token2);
745
+ } catch (err) {
746
+ log.warn("could not revoke session: " + err.message);
747
+ return text(res, 503, "could not sign out; retry later\n");
748
+ }
749
+ }
750
+ res.setHeader("set-cookie", clearCookie(secureCookie));
751
+ return redirect(res, "/__gate/login");
752
+ }
753
+ if (path.startsWith("/__gate/")) return text(res, 404, "not found\n");
754
+ const token = cookieValue(req.headers.cookie, COOKIE);
755
+ const unauthorized = () => {
756
+ if (path === "/api" || path.startsWith("/api/")) {
757
+ return json(res, 401, { error: "unauthorized", login: "/__gate/login" });
758
+ }
759
+ return redirect(res, "/__gate/login?next=" + encodeURIComponent(raw));
760
+ };
761
+ if (!token || !validSession(token)) return unauthorized();
762
+ if (!sameOrigin(req, publicOrigin, useTls)) {
763
+ log.warn("cross-origin request blocked: " + String(req.method) + " " + path);
764
+ if (path === "/api" || path.startsWith("/api/")) return json(res, 403, { error: "forbidden" });
765
+ return text(res, 403, "forbidden\n");
766
+ }
767
+ let cookie;
768
+ try {
769
+ cookie = await harnessCookie();
770
+ } catch (err) {
771
+ if (res.destroyed) return;
772
+ log.warn("harness cookie exchange failed: " + err.message);
773
+ return json(res, 503, { error: "harness_unavailable" });
774
+ }
775
+ if (req.aborted || res.destroyed || !res.writable) return;
776
+ if (!validSession(token)) return unauthorized();
777
+ forward(req, res, cookie);
778
+ };
779
+ }
780
+
781
+ // src/state.ts
782
+ import { chmodSync, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
783
+ import { randomBytes as randomBytes2 } from "node:crypto";
784
+ import { dirname, join } from "node:path";
785
+ import { homedir } from "node:os";
786
+ var MAX_REVOKED_SESSIONS = 1e4;
787
+ function readState(path, mustExist = false) {
788
+ let data;
789
+ try {
790
+ data = readFileSync(path, "utf8");
791
+ } catch (err) {
792
+ if (!mustExist && err.code === "ENOENT") return {};
793
+ throw new Error("dsh-frontgate: cannot read state file " + path, { cause: err });
794
+ }
795
+ try {
796
+ const value = JSON.parse(data);
797
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid state");
798
+ const state = value;
799
+ if (!validPasswordHash(state.passwordHash) || typeof state.secret !== "string" || state.secret.length < 64) {
800
+ throw new Error("invalid credentials");
801
+ }
802
+ if (state.revokedSessions !== void 0 && (!Array.isArray(state.revokedSessions) || state.revokedSessions.length > MAX_REVOKED_SESSIONS || state.revokedSessions.some((entry) => !entry || typeof entry !== "object" || typeof entry.digest !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(entry.digest) || !Number.isSafeInteger(entry.expires) || entry.expires <= 0))) {
803
+ throw new Error("invalid revoked sessions");
804
+ }
805
+ return state;
806
+ } catch (err) {
807
+ throw new Error("dsh-frontgate: invalid state file " + path + "; restore it from a backup before restarting", { cause: err });
808
+ }
809
+ }
810
+ function tightenState(path) {
811
+ let info;
812
+ try {
813
+ info = lstatSync(path);
814
+ } catch (err) {
815
+ if (err.code === "ENOENT") return false;
816
+ throw new Error("dsh-frontgate: cannot inspect state file " + path, { cause: err });
817
+ }
818
+ if (!info.isFile()) throw new Error("dsh-frontgate: state file must be a regular file: " + path);
819
+ if (process.platform === "win32") return true;
820
+ if (typeof process.geteuid === "function" && info.uid !== process.geteuid()) {
821
+ throw new Error("dsh-frontgate: state file must be owned by the service user: " + path);
822
+ }
823
+ try {
824
+ chmodSync(path, 384);
825
+ } catch (err) {
826
+ if ((info.mode & 63) !== 0) {
827
+ throw new Error("dsh-frontgate: cannot secure state file permissions for " + path, { cause: err });
828
+ }
829
+ }
830
+ try {
831
+ info = lstatSync(path);
832
+ } catch (err) {
833
+ throw new Error("dsh-frontgate: cannot inspect state file " + path, { cause: err });
834
+ }
835
+ if (!info.isFile() || (info.mode & 63) !== 0 || typeof process.geteuid === "function" && info.uid !== process.geteuid()) {
836
+ throw new Error("dsh-frontgate: state file must be owner-only: " + path);
837
+ }
838
+ return true;
839
+ }
840
+ function writeState(path, value) {
841
+ mkdirSync(dirname(path), { recursive: true });
842
+ const temporary = path + "." + randomBytes2(12).toString("hex") + ".tmp";
843
+ let fd;
844
+ try {
845
+ fd = openSync(temporary, "wx", 384);
846
+ writeFileSync(fd, JSON.stringify(value, null, 2) + "\n");
847
+ fsyncSync(fd);
848
+ closeSync(fd);
849
+ fd = void 0;
850
+ renameSync(temporary, path);
851
+ } finally {
852
+ if (fd !== void 0) closeSync(fd);
853
+ try {
854
+ unlinkSync(temporary);
855
+ } catch (err) {
856
+ if (err.code !== "ENOENT") throw err;
857
+ }
858
+ }
859
+ }
860
+ function statePathFor(patchPath) {
861
+ const file = patchPath ?? join(process.env.DSH_HOME || join(homedir(), ".dsh"), "profiles", "web", "cordis.patch.yml");
862
+ return join(dirname(file), "data", "dsh-frontgate.json");
863
+ }
864
+ function hashFor(plain, state) {
865
+ return state.passwordHash && verifyPasswordSync(plain, state.passwordHash) ? state.passwordHash : hashPassword(plain);
866
+ }
867
+
868
+ // src/sessions.ts
869
+ import { createHash as createHash2 } from "node:crypto";
870
+ var sessionDigest = (token) => createHash2("sha256").update(token).digest("base64url");
871
+ function createSessionStore(path, state, pv, onRevoke) {
872
+ const secret = state.secret;
873
+ const passwordHash = state.passwordHash;
874
+ let revoked = new Map((state.revokedSessions ?? []).map(({ digest, expires }) => [digest, expires]));
875
+ const valid = (token) => {
876
+ if (!verifyToken(secret, pv, token)) return false;
877
+ const digest = sessionDigest(token);
878
+ const expires = revoked.get(digest);
879
+ if (expires === void 0) return true;
880
+ if (expires * 1e3 > Date.now()) return false;
881
+ revoked.delete(digest);
882
+ return true;
883
+ };
884
+ const revoke = (token) => {
885
+ if (!valid(token)) return false;
886
+ if (!tightenState(path)) throw new Error("state file is missing");
887
+ const latest = readState(path, true);
888
+ if (latest.secret !== secret || latest.passwordHash !== passwordHash) {
889
+ throw new Error("state changed; restart the gate before signing out");
890
+ }
891
+ const now = Math.floor(Date.now() / 1e3);
892
+ const next = /* @__PURE__ */ new Map();
893
+ for (const entry of latest.revokedSessions ?? []) if (entry.expires > now) next.set(entry.digest, entry.expires);
894
+ for (const [digest2, expires] of revoked) if (expires > now) next.set(digest2, expires);
895
+ const digest = sessionDigest(token);
896
+ if (next.size > MAX_REVOKED_SESSIONS || !next.has(digest) && next.size >= MAX_REVOKED_SESSIONS) {
897
+ throw new Error("revocation store is full");
898
+ }
899
+ next.set(digest, Number(token.split(".")[2]));
900
+ writeState(path, { ...latest, revokedSessions: [...next].map(([digest2, expires]) => ({ digest: digest2, expires })) });
901
+ revoked = next;
902
+ onRevoke(digest);
903
+ return true;
904
+ };
905
+ return { valid, revoke };
906
+ }
907
+
908
+ // src/index.ts
909
+ var name = "dsh-frontgate";
910
+ var inject = ["webServer", "connection"];
911
+ function apply(ctx, config = {}) {
912
+ const logger = resolveLogger(ctx);
913
+ const cfg = { ...DEFAULTS, ...config };
914
+ const listenHost = String(cfg.listenHost);
915
+ const listenPort = intOr(cfg.listenPort, DEFAULTS.listenPort, 0, 65535);
916
+ const sessionSeconds = intOr(cfg.sessionDays, DEFAULTS.sessionDays, 1, 365) * 86400;
917
+ const maxAttempts = intOr(cfg.maxAttempts, DEFAULTS.maxAttempts, 1, 50);
918
+ const lockMs = intOr(cfg.lockMinutes, DEFAULTS.lockMinutes, 1, 1440) * 6e4;
919
+ const exchangeTimeoutMs = intOr(cfg.exchangeTimeoutMs, DEFAULTS.exchangeTimeoutMs, 100, 6e4);
920
+ const maxConnectionsPerPeer = intOr(cfg.maxConnectionsPerPeer, DEFAULTS.maxConnectionsPerPeer, 0, 1024);
921
+ const maxPendingLoginsPerIp = intOr(cfg.maxPendingLoginsPerIp, DEFAULTS.maxPendingLoginsPerIp, 1, 128);
922
+ const upstreamHeaderTimeoutMs = intOr(cfg.upstreamHeaderTimeoutMs, DEFAULTS.upstreamHeaderTimeoutMs, 0, 36e5);
923
+ const upstreamIdleTimeoutMs = intOr(cfg.upstreamIdleTimeoutMs, DEFAULTS.upstreamIdleTimeoutMs, 0, 36e5);
924
+ const useTls = Boolean(cfg.tlsCertFile || cfg.tlsKeyFile);
925
+ if (useTls && !(cfg.tlsCertFile && cfg.tlsKeyFile)) {
926
+ throw new Error("dsh-frontgate: tlsCertFile and tlsKeyFile must be set together");
927
+ }
928
+ const publicOrigin = normalizePublicOrigin(String(cfg.publicOrigin || ""));
929
+ if (useTls && publicOrigin && !publicOrigin.startsWith("https://")) {
930
+ throw new Error("dsh-frontgate: publicOrigin must use HTTPS when the gate serves TLS");
931
+ }
932
+ const publicProto = publicOrigin ? new URL(publicOrigin).protocol.slice(0, -1) : useTls ? "https" : "http";
933
+ const tlsOptions = useTls ? { cert: readFileSync2(String(cfg.tlsCertFile)), key: readFileSync2(String(cfg.tlsKeyFile)) } : void 0;
934
+ const statePath = statePathFor(ctx.profileContext?.patchPath);
935
+ const stateExists = tightenState(statePath);
936
+ const state = readState(statePath, stateExists);
937
+ const configuredPassword = typeof cfg.password === "string" ? cfg.password : "";
938
+ const envPassword = typeof process.env.DSH_FRONTGATE_PASSWORD === "string" ? process.env.DSH_FRONTGATE_PASSWORD : "";
939
+ const envPasswordHash = process.env.DSH_FRONTGATE_PASSWORD_HASH || "";
940
+ if (cfg.passwordHash && !validPasswordHash(cfg.passwordHash)) {
941
+ throw new Error("dsh-frontgate: invalid passwordHash");
942
+ }
943
+ if (!cfg.passwordHash && !configuredPassword && envPasswordHash && !validPasswordHash(envPasswordHash)) {
944
+ throw new Error("dsh-frontgate: invalid DSH_FRONTGATE_PASSWORD_HASH");
945
+ }
946
+ let passwordHash = "";
947
+ let generatedPassword = "";
948
+ if (typeof cfg.passwordHash === "string" && cfg.passwordHash) passwordHash = cfg.passwordHash;
949
+ else if (configuredPassword) passwordHash = hashFor(assertStrongPassword(configuredPassword, 'the "password" option'), state);
950
+ else if (envPasswordHash) passwordHash = envPasswordHash;
951
+ else if (envPassword) passwordHash = hashFor(assertStrongPassword(envPassword, "DSH_FRONTGATE_PASSWORD"), state);
952
+ else if (validPasswordHash(state.passwordHash)) passwordHash = state.passwordHash;
953
+ if (!passwordHash) {
954
+ generatedPassword = randomPassword();
955
+ passwordHash = hashPassword(generatedPassword);
956
+ }
957
+ const passwordChanged = stateExists && state.passwordHash !== passwordHash;
958
+ const secret = !passwordChanged && typeof state.secret === "string" && state.secret.length >= 64 ? state.secret : randomBytes3(32).toString("hex");
959
+ const pv = passwordVersion(passwordHash);
960
+ const persistedState = {
961
+ passwordHash,
962
+ secret,
963
+ createdAt: state.createdAt || (/* @__PURE__ */ new Date()).toISOString(),
964
+ revokedSessions: passwordChanged ? [] : state.revokedSessions ?? []
965
+ };
966
+ if (state.passwordHash !== passwordHash || state.secret !== secret) {
967
+ writeState(statePath, persistedState);
968
+ }
969
+ if (generatedPassword) {
970
+ logger.info("no password configured - generated a random one and saved its hash to " + statePath);
971
+ logger.info("PASSWORD: " + generatedPassword);
972
+ logger.info("save this password now; it will not be shown again");
973
+ }
974
+ const targetOrigin = ctx.webServer.getOrigin();
975
+ const target = new URL(targetOrigin);
976
+ if (listenPort !== 0 && listenPort === Number(target.port)) {
977
+ throw new Error("dsh-frontgate: listenPort " + listenPort + " collides with the harness listener " + targetOrigin);
978
+ }
979
+ const upgrades = /* @__PURE__ */ new Map();
980
+ const sessions = createSessionStore(statePath, persistedState, pv, (digest) => {
981
+ for (const [socket, entry] of upgrades) if (entry.digest === digest) socket.destroy();
982
+ });
983
+ const proxy = createProxy({
984
+ targetOrigin,
985
+ publicProto,
986
+ trustProxy: Boolean(cfg.trustProxy),
987
+ exchangeTimeoutMs,
988
+ upstreamHeaderTimeoutMs,
989
+ upstreamIdleTimeoutMs,
990
+ upstreamCa: cfg.upstreamCaFile ? readFileSync2(String(cfg.upstreamCaFile)) : void 0,
991
+ upstreamServerName: String(cfg.upstreamServerName || ""),
992
+ authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
993
+ log: logger
994
+ });
995
+ const handler = createHandler({
996
+ runtime: {
997
+ title: cfg.title,
998
+ passwordHash,
999
+ secret,
1000
+ pv,
1001
+ sessionSeconds,
1002
+ maxAttempts,
1003
+ maxPendingLoginsPerIp,
1004
+ lockMs,
1005
+ trustProxy: Boolean(cfg.trustProxy),
1006
+ useTls,
1007
+ publicOrigin,
1008
+ secureCookie: publicProto === "https"
1009
+ },
1010
+ harnessCookie: proxy.harnessCookie,
1011
+ validSession: sessions.valid,
1012
+ revokeSession: sessions.revoke,
1013
+ forward: proxy.forward,
1014
+ log: logger
1015
+ });
1016
+ const requestListener = (req, res) => {
1017
+ if (useTls) res.setHeader("strict-transport-security", "max-age=31536000");
1018
+ handler(req, res).catch((err) => {
1019
+ logger.warn("handler error: " + (err instanceof Error && err.stack ? err.stack : String(err)));
1020
+ if (!res.headersSent) text(res, 500, "dsh-frontgate: internal error\n");
1021
+ else res.destroy();
1022
+ });
1023
+ };
1024
+ const serverOptions = { connectionsCheckingInterval: 5e3, headersTimeout: 1e4, requestTimeout: 3e4 };
1025
+ const server = tlsOptions ? https2.createServer({ ...tlsOptions, ...serverOptions }, requestListener) : http2.createServer(serverOptions, requestListener);
1026
+ server.maxConnections = 1024;
1027
+ const peers = /* @__PURE__ */ new Map();
1028
+ if (maxConnectionsPerPeer > 0) server.on("connection", (socket) => {
1029
+ const peer = String(socket.remoteAddress || "unknown").replace(/^::ffff:/, "");
1030
+ const count = peers.get(peer) ?? 0;
1031
+ if (count >= maxConnectionsPerPeer) {
1032
+ socket.destroy();
1033
+ return;
1034
+ }
1035
+ peers.set(peer, count + 1);
1036
+ socket.once("close", () => {
1037
+ const remaining = (peers.get(peer) ?? 1) - 1;
1038
+ if (remaining <= 0) peers.delete(peer);
1039
+ else peers.set(peer, remaining);
1040
+ });
1041
+ });
1042
+ let shuttingDown = false;
1043
+ function trackUpgrade(socket, token) {
1044
+ const expiresAt = Number(token.split(".")[2]) * 1e3;
1045
+ const entry = { digest: sessionDigest(token) };
1046
+ upgrades.set(socket, entry);
1047
+ const arm = () => {
1048
+ const remaining = expiresAt - Date.now();
1049
+ if (remaining <= 0) {
1050
+ socket.destroy();
1051
+ return;
1052
+ }
1053
+ entry.timer = setTimeout(arm, Math.min(remaining, 2147483647));
1054
+ };
1055
+ arm();
1056
+ socket.once("close", () => {
1057
+ if (entry.timer) clearTimeout(entry.timer);
1058
+ upgrades.delete(socket);
1059
+ });
1060
+ }
1061
+ server.on("upgrade", (req, socket, head) => {
1062
+ socket.on("error", () => socket.destroy());
1063
+ try {
1064
+ const token = cookieValue(req.headers.cookie, COOKIE);
1065
+ if (!token || !sessions.valid(token)) {
1066
+ socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
1067
+ return;
1068
+ }
1069
+ if (!sameOrigin(req, publicOrigin, useTls)) {
1070
+ logger.warn("websocket blocked: cross-origin upgrade from " + String(req.headers.origin || ""));
1071
+ socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
1072
+ return;
1073
+ }
1074
+ trackUpgrade(socket, token);
1075
+ const chunks = head.length ? [head] : [];
1076
+ let pendingBytes = head.length;
1077
+ const onEarlyData = (chunk) => {
1078
+ pendingBytes += chunk.length;
1079
+ if (pendingBytes > 65536) {
1080
+ socket.destroy();
1081
+ return;
1082
+ }
1083
+ chunks.push(chunk);
1084
+ };
1085
+ const onEarlyEnd = () => {
1086
+ socket.destroy();
1087
+ };
1088
+ socket.on("data", onEarlyData);
1089
+ socket.once("end", onEarlyEnd);
1090
+ if (pendingBytes > 65536) {
1091
+ socket.destroy();
1092
+ return;
1093
+ }
1094
+ socket.resume();
1095
+ proxy.harnessCookie().then((cookie) => {
1096
+ if (shuttingDown || socket.destroyed || !sessions.valid(token)) return;
1097
+ socket.pause();
1098
+ socket.off("data", onEarlyData);
1099
+ socket.off("end", onEarlyEnd);
1100
+ proxy.forwardUpgrade(req, socket, Buffer.concat(chunks, pendingBytes), cookie);
1101
+ }).catch((err) => {
1102
+ if (socket.destroyed) return;
1103
+ logger.warn("websocket blocked: " + err.message);
1104
+ socket.end("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
1105
+ });
1106
+ } catch (err) {
1107
+ logger.warn("upgrade error: " + err.message);
1108
+ socket.destroy();
1109
+ }
1110
+ });
1111
+ server.on("clientError", (_err, socket) => {
1112
+ if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
1113
+ socket.destroy();
1114
+ });
1115
+ server.on("error", (err) => logger.warn("listener error: " + err.message));
1116
+ const shutdown = () => {
1117
+ shuttingDown = true;
1118
+ for (const socket of upgrades.keys()) socket.destroy();
1119
+ proxy.destroy();
1120
+ const withConnections = server;
1121
+ if (typeof withConnections.closeAllConnections === "function") withConnections.closeAllConnections();
1122
+ server.close();
1123
+ };
1124
+ ctx.effect(() => ctx.webServer.tapIndex(injectPolyfill), "dsh-frontgate: secure-context polyfill");
1125
+ let ready;
1126
+ ctx.effect(() => {
1127
+ ready = new Promise((resolve, reject) => {
1128
+ server.once("error", reject);
1129
+ server.listen(listenPort, listenHost, () => {
1130
+ server.removeListener("error", reject);
1131
+ const address = server.address();
1132
+ const port = address && typeof address === "object" ? address.port : listenPort;
1133
+ logger.info("listening on " + (useTls ? "https" : "http") + "://" + listenHost + ":" + port + " -> " + targetOrigin);
1134
+ if (!isLoopbackHost(listenHost) && !useTls) {
1135
+ logger.warn("plaintext HTTP is exposed on " + listenHost + "; terminate TLS or use a VPN");
1136
+ }
1137
+ if (!isLoopbackHost(listenHost)) {
1138
+ logger.warn("use a dedicated hostname or IP: browser cookies are shared across ports");
1139
+ }
1140
+ if (cfg.trustProxy) {
1141
+ logger.warn("trustProxy is enabled; restrict access to this listener to trusted proxies; TCP peer limits apply to the proxy");
1142
+ }
1143
+ const bound = ctx.webServer.config?.host;
1144
+ if (typeof bound === "string" && !isLoopbackHost(bound)) {
1145
+ logger.warn("harness is bound to " + bound + ", so it is reachable without this gate");
1146
+ }
1147
+ resolve();
1148
+ });
1149
+ });
1150
+ ready.catch((err) => logger.warn("listen failed on " + listenHost + ":" + listenPort + ": " + err.message));
1151
+ return shutdown;
1152
+ }, "dsh-frontgate: listener");
1153
+ return {
1154
+ ready: () => ready ?? Promise.resolve(),
1155
+ address: () => server.address(),
1156
+ stop: shutdown
1157
+ };
1158
+ }
1159
+ export {
1160
+ Config,
1161
+ apply,
1162
+ hashPassword,
1163
+ inject,
1164
+ injectPolyfill,
1165
+ name,
1166
+ verifyPassword
1167
+ };