@lornatang/dsh-frontgate 0.0.0-stage → 0.1.1

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