@lornatang/dsh-frontgate 0.1.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 - 2026-11-12
4
+
5
+ - Persist session revocations on sign-out, reject alternate token encodings,
6
+ and close WebSockets on sign-out, expiry, or shutdown.
7
+ - Bound per-peer connections and incomplete logins; cancel disconnected clients
8
+ and time out stalled upstream HTTP responses without cutting off active streams.
9
+ - Verify HTTPS harness certificates, refresh stale harness cookies after a 401,
10
+ and return an HTTP error for oversized chunked login forms.
11
+ - Document Docker port mapping and add security, browser and proxy regression tests.
12
+
3
13
  ## 0.1.1 - 2026-11-12
4
14
 
5
15
  - Publish as `@lornatang/dsh-frontgate`; install with `dsh plugin` using the
package/README.md CHANGED
@@ -61,6 +61,11 @@ its port must differ from the harness port.
61
61
  | `publicOrigin` | empty | External origin when a reverse proxy terminates HTTPS. |
62
62
  | `trustProxy` | `false` | Use client IPs supplied by a trusted proxy. |
63
63
  | `exchangeTimeoutMs` | `5000` | Harness token exchange timeout in milliseconds. |
64
+ | `maxConnectionsPerPeer` | `64` | Simultaneous TCP connections per peer; `0` disables this limit. |
65
+ | `maxPendingLoginsPerIp` | `4` | Incomplete login forms allowed per client IP. |
66
+ | `upstreamHeaderTimeoutMs` | `60000` | Upstream response-header deadline, including queue time; `0` disables. |
67
+ | `upstreamIdleTimeoutMs` | `300000` | Upstream response idle deadline; `0` disables. |
68
+ | `upstreamCaFile` / `upstreamServerName` | empty | CA file and optional TLS hostname for a private HTTPS harness. |
64
69
  | `title` | `DeepSeek Harness` | Login page title. |
65
70
 
66
71
  Password precedence is configured hash, configured password,
@@ -73,13 +78,24 @@ correct passwords, until it expires; clients behind the same NAT may share it.
73
78
  For Docker, set `listenHost: '0.0.0.0'` and publish the gate port, for example:
74
79
 
75
80
  ```yaml
81
+ # ~/.dsh/profiles/web/cordis.patch.yml
82
+ - id: frontgate
83
+ config:
84
+ listenHost: '0.0.0.0'
85
+ listenPort: 30010
86
+ ```
87
+
88
+ ```yaml
89
+ # Docker Compose
76
90
  ports:
77
- - '30190:3090'
91
+ - '30190:30010'
78
92
  ```
79
93
 
80
- Then open `http://<host-ip>:30190`. Keep the harness itself on loopback so it
81
- cannot bypass the gate. HTTP exposes passwords and cookies; use a trusted LAN,
82
- VPN, or HTTPS.
94
+ Restart DSH after editing its profile, then open `http://<host-ip>:30190`. The
95
+ container port in the mapping must equal `listenPort`; the external port can
96
+ be different. Leave `publicOrigin` empty and `trustProxy` disabled for direct
97
+ HTTP access. Keep the harness itself on loopback so it cannot bypass the gate.
98
+ HTTP exposes passwords and cookies; use a trusted LAN, VPN, or HTTPS.
83
99
 
84
100
  Use a dedicated hostname or IP for the gate. Browser cookies are shared by
85
101
  all ports on the same hostname or IP, so an untrusted service on another port
@@ -91,6 +107,15 @@ any non-default port (Nginx: `proxy_set_header Host $http_host;`). Restrict
91
107
  direct access to the gate listener to the proxy. If `trustProxy` is enabled,
92
108
  the proxy must replace client-supplied `X-Forwarded-For` with the real client
93
109
  IP (Nginx: `proxy_set_header X-Forwarded-For $remote_addr;`).
110
+ `maxConnectionsPerPeer` counts the TCP peer, so proxied connections share the
111
+ proxy's limit. Adjust the limit for expected traffic and apply per-client limits
112
+ at the proxy when appropriate.
113
+
114
+ HTTPS harness certificates and hostnames are verified. For a private CA, set
115
+ `upstreamCaFile` to its PEM file; if the harness origin is an IP but its
116
+ certificate names a DNS host, also set `upstreamServerName` to that host.
117
+ For a stream that may be silent for more than five minutes, raise or disable
118
+ `upstreamIdleTimeoutMs`.
94
119
 
95
120
  ## Troubleshooting
96
121
 
@@ -98,7 +123,8 @@ IP (Nginx: `proxy_set_header X-Forwarded-For $remote_addr;`).
98
123
  reused. Set `password` and remove `passwordHash` to change it; this signs out
99
124
  existing sessions.
100
125
  - **Cannot connect:** Check `listenHost`, the gate's startup log and the Docker
101
- port mapping. To sign out, visit `/__gate/logout`.
126
+ port mapping. The gate port must match the container side of the mapping.
127
+ To sign out, visit `/__gate/logout`.
102
128
  - **Origin or host check fails:** Verify `publicOrigin` and the proxy's `Host`.
103
129
  - **State file error:** Restore a trusted backup and ensure the file is owned
104
130
  by the DSH user with mode `0600`. If no backup exists, stop DSH and move the
@@ -106,7 +132,9 @@ IP (Nginx: `proxy_set_header X-Forwarded-For $remote_addr;`).
106
132
 
107
133
  The state file is `~/.dsh/profiles/<name>/data/dsh-frontgate.json`. Keep it and
108
134
  its parent directories accessible only to trusted users. On Windows, use ACLs
109
- to restrict access.
135
+ to restrict access. Sign-out revocations persist across restarts; each profile
136
+ must have only one running gate instance. Expired revocations are removed on
137
+ sign-out, and a full or unwritable revocation store makes sign-out fail safely.
110
138
 
111
139
  ## Development
112
140
 
package/lib/index.js CHANGED
@@ -19,7 +19,13 @@ var DEFAULTS = {
19
19
  trustProxy: false,
20
20
  maxAttempts: 5,
21
21
  lockMinutes: 5,
22
- exchangeTimeoutMs: 5e3
22
+ exchangeTimeoutMs: 5e3,
23
+ maxConnectionsPerPeer: 64,
24
+ maxPendingLoginsPerIp: 4,
25
+ upstreamHeaderTimeoutMs: 6e4,
26
+ upstreamIdleTimeoutMs: 3e5,
27
+ upstreamCaFile: "",
28
+ upstreamServerName: ""
23
29
  };
24
30
  var Config = z.object({
25
31
  listenHost: z.string().default(DEFAULTS.listenHost),
@@ -34,7 +40,13 @@ var Config = z.object({
34
40
  trustProxy: z.boolean().default(DEFAULTS.trustProxy),
35
41
  maxAttempts: z.natural().min(1).max(50).default(DEFAULTS.maxAttempts),
36
42
  lockMinutes: z.natural().min(1).max(1440).default(DEFAULTS.lockMinutes),
37
- exchangeTimeoutMs: z.natural().min(100).max(6e4).default(DEFAULTS.exchangeTimeoutMs)
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)
38
50
  });
39
51
 
40
52
  // src/auth.ts
@@ -133,6 +145,7 @@ function verifyToken(secret, pv, token) {
133
145
  if (parts.length !== 6) return false;
134
146
  const [version, iat, exp, nonce, tokenPv, mac] = parts;
135
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;
136
149
  const now = Math.floor(Date.now() / 1e3);
137
150
  const issued = Number(iat);
138
151
  const expires = Number(exp);
@@ -146,7 +159,7 @@ function verifyToken(secret, pv, token) {
146
159
  } catch {
147
160
  return false;
148
161
  }
149
- return got.length === want.length && timingSafeEqual(got, want);
162
+ return got.length === want.length && got.toString("base64url") === mac && timingSafeEqual(got, want);
150
163
  }
151
164
  function sessionCookie(value, seconds, useTls) {
152
165
  const attrs = [COOKIE + "=" + value, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=" + seconds];
@@ -270,13 +283,14 @@ function readForm(req) {
270
283
  };
271
284
  const timer = setTimeout(() => {
272
285
  fail(new Error("timeout"));
273
- req.destroy();
286
+ req.resume();
274
287
  }, FORM_TIMEOUT);
275
288
  req.on("data", (chunk) => {
289
+ if (settled) return;
276
290
  size += chunk.length;
277
291
  if (size > MAX_FORM) {
278
292
  fail(new Error("too large"));
279
- req.destroy();
293
+ req.resume();
280
294
  return;
281
295
  }
282
296
  chunks.push(chunk);
@@ -337,12 +351,24 @@ function sendUpstreamError(res) {
337
351
  text(res, 502, "dsh-frontgate: upstream unreachable\n");
338
352
  }
339
353
  function createProxy(options) {
340
- const { targetOrigin, publicProto, trustProxy, exchangeTimeoutMs, authenticatedUrl, log } = 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;
341
366
  const target = new URL(targetOrigin);
342
367
  const secureUpstream = target.protocol === "https:";
343
368
  const transport = secureUpstream ? https : http;
344
369
  const AgentClass = secureUpstream ? https.Agent : http.Agent;
345
370
  const upstreamAgent = new AgentClass({ keepAlive: true, maxSockets: 128, keepAliveMsecs: 1e3 });
371
+ const tlsOptions = secureUpstream ? { ...upstreamCa ? { ca: upstreamCa } : {}, ...upstreamServerName ? { servername: upstreamServerName } : {} } : {};
346
372
  const exchanges = /* @__PURE__ */ new Set();
347
373
  function exchangeHarnessCookie() {
348
374
  const url = new URL(authenticatedUrl(targetOrigin + "/"));
@@ -362,7 +388,7 @@ function createProxy(options) {
362
388
  path: url.pathname + url.search,
363
389
  method: "GET",
364
390
  headers: { host: target.host, accept: "text/html" },
365
- ...secureUpstream ? { rejectUnauthorized: false } : {}
391
+ ...tlsOptions
366
392
  }, (res) => {
367
393
  res.resume();
368
394
  const lines = res.headers["set-cookie"] || [];
@@ -378,6 +404,9 @@ function createProxy(options) {
378
404
  }
379
405
  let harnessCache = { value: "", at: 0 };
380
406
  let harnessInflight = null;
407
+ function invalidateHarnessCookie(cookie) {
408
+ if (harnessCache.value === cookie) harnessCache = { value: "", at: 0 };
409
+ }
381
410
  function harnessCookie() {
382
411
  if (harnessCache.value && Date.now() - harnessCache.at < HARNESS_COOKIE_TTL) return Promise.resolve(harnessCache.value);
383
412
  if (harnessInflight) return harnessInflight;
@@ -411,6 +440,15 @@ function createProxy(options) {
411
440
  }
412
441
  function forward(req, res, cookie) {
413
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
+ };
414
452
  const proxyReq = transport.request({
415
453
  agent: upstreamAgent,
416
454
  host: connectHost(target.hostname),
@@ -418,8 +456,14 @@ function createProxy(options) {
418
456
  method: req.method,
419
457
  path: req.url,
420
458
  headers: upstreamHeaders(req, cookie),
421
- ...secureUpstream ? { rejectUnauthorized: false } : {}
459
+ ...tlsOptions
422
460
  }, (proxyRes) => {
461
+ if (headerTimer) clearTimeout(headerTimer);
462
+ headerTimer = void 0;
463
+ if (res.destroyed) {
464
+ proxyRes.destroy();
465
+ return;
466
+ }
423
467
  const headers = {};
424
468
  for (const [key, value] of Object.entries(proxyRes.headers)) {
425
469
  if (!RESPONSE_DROP.has(key)) headers[key] = value;
@@ -427,19 +471,51 @@ function createProxy(options) {
427
471
  const location = proxyRes.headers["location"];
428
472
  if (location !== void 0) headers.location = rewriteLocation(location);
429
473
  const status = proxyRes.statusCode || 502;
474
+ if (status === 401) invalidateHarnessCookie(cookie);
430
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();
431
492
  proxyRes.pipe(res);
432
- proxyRes.on("error", () => res.destroy());
493
+ proxyRes.on("error", () => {
494
+ clearTimers();
495
+ res.destroy();
496
+ });
433
497
  const elapsed = Date.now() - started;
434
498
  if (status >= 400 || elapsed > 1e3) {
435
499
  log.info(req.method + " " + String(req.url || "/").split("?")[0] + " " + status + " " + elapsed + "ms");
436
500
  }
437
501
  });
502
+ if (upstreamHeaderTimeoutMs) {
503
+ headerTimer = setTimeout(() => {
504
+ timedOut = true;
505
+ proxyReq.destroy(new Error("upstream response headers timeout"));
506
+ }, upstreamHeaderTimeoutMs);
507
+ }
438
508
  proxyReq.on("error", (err) => {
509
+ clearTimers();
510
+ if (res.destroyed) return;
439
511
  log.warn("upstream error: " + err.message);
512
+ if (timedOut && !res.headersSent) return text(res, 504, "dsh-frontgate: upstream timeout\n");
440
513
  sendUpstreamError(res);
441
514
  });
442
- res.on("close", () => proxyReq.destroy());
515
+ res.on("close", () => {
516
+ clearTimers();
517
+ proxyReq.destroy();
518
+ });
443
519
  req.pipe(proxyReq);
444
520
  }
445
521
  function forwardUpgrade(req, socket, head, cookie) {
@@ -462,7 +538,7 @@ function createProxy(options) {
462
538
  lines.push("X-Forwarded-Host: " + String(req.headers.host || ""));
463
539
  lines.push("", "");
464
540
  const payload = lines.join("\r\n");
465
- const backend = secureUpstream ? tls.connect({ host, port, rejectUnauthorized: false }) : net.connect(port, host);
541
+ const backend = secureUpstream ? tls.connect({ host, port, ...tlsOptions }) : net.connect(port, host);
466
542
  let closed = false;
467
543
  const teardown = (message) => {
468
544
  if (closed) return;
@@ -473,6 +549,19 @@ function createProxy(options) {
473
549
  };
474
550
  const start = () => {
475
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
+ });
476
565
  backend.write(payload);
477
566
  if (head && head.length) backend.write(head);
478
567
  backend.pipe(socket);
@@ -537,10 +626,11 @@ function loginPage(title, next, error) {
537
626
  var MAX_TRACKED_CLIENTS = 1024;
538
627
  var CHECK_CONCURRENCY = 2;
539
628
  function createHandler(options) {
540
- const { runtime, harnessCookie, forward, log } = options;
541
- const { title, passwordHash, secret, pv, sessionSeconds, maxAttempts, lockMs, trustProxy, useTls, publicOrigin, secureCookie } = runtime;
629
+ const { runtime, harnessCookie, validSession, revokeSession, forward, log } = options;
630
+ const { title, passwordHash, secret, pv, sessionSeconds, maxAttempts, maxPendingLoginsPerIp, lockMs, trustProxy, useTls, publicOrigin, secureCookie } = runtime;
542
631
  const failures = /* @__PURE__ */ new Map();
543
632
  const clientsInFlight = /* @__PURE__ */ new Set();
633
+ const pendingForms = /* @__PURE__ */ new Map();
544
634
  let checksInFlight = 0;
545
635
  function lockRemaining(ip) {
546
636
  const entry = failures.get(ip);
@@ -567,26 +657,45 @@ function createHandler(options) {
567
657
  }
568
658
  function sessionOf(req) {
569
659
  const token = cookieValue(req.headers.cookie, COOKIE);
570
- return Boolean(token) && verifyToken(secret, pv, token);
660
+ return Boolean(token) && validSession(token);
571
661
  }
572
662
  async function handleLogin(req, res, search) {
573
663
  const next = safeNext(new URLSearchParams(search).get("next") || "/");
574
664
  if (req.method === "POST") {
575
- if (!sameOrigin(req, publicOrigin, useTls)) return html(res, 403, loginPage(title, next, "Origin check failed."));
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
+ }
576
670
  const declared = Number(req.headers["content-length"] || 0);
577
- if (declared > MAX_FORM) return text(res, 413, "request body too large\n");
671
+ if (declared > MAX_FORM) {
672
+ req.resume();
673
+ return text(res, 413, "request body too large\n", { connection: "close" });
674
+ }
578
675
  const ip = clientIp(req, trustProxy);
579
676
  const wait = lockRemaining(ip);
580
677
  if (wait > 0) {
581
678
  req.resume();
582
- return text(res, 429, "too many attempts\n", { "retry-after": String(Math.ceil(wait / 1e3)) });
679
+ return text(res, 429, "too many attempts\n", { "retry-after": String(Math.ceil(wait / 1e3)), connection: "close" });
583
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);
584
687
  let form;
585
688
  try {
586
689
  form = await readForm(req);
587
690
  } 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");
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);
590
699
  }
591
700
  const remaining = lockRemaining(ip);
592
701
  if (remaining > 0) return text(res, 429, "too many attempts\n", { "retry-after": String(Math.ceil(remaining / 1e3)) });
@@ -629,16 +738,27 @@ function createHandler(options) {
629
738
  if (path === "/__gate/login") return handleLogin(req, res, search);
630
739
  if (path === "/__gate/logout") {
631
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
+ }
632
750
  res.setHeader("set-cookie", clearCookie(secureCookie));
633
751
  return redirect(res, "/__gate/login");
634
752
  }
635
753
  if (path.startsWith("/__gate/")) return text(res, 404, "not found\n");
636
- if (!sessionOf(req)) {
754
+ const token = cookieValue(req.headers.cookie, COOKIE);
755
+ const unauthorized = () => {
637
756
  if (path === "/api" || path.startsWith("/api/")) {
638
757
  return json(res, 401, { error: "unauthorized", login: "/__gate/login" });
639
758
  }
640
759
  return redirect(res, "/__gate/login?next=" + encodeURIComponent(raw));
641
- }
760
+ };
761
+ if (!token || !validSession(token)) return unauthorized();
642
762
  if (!sameOrigin(req, publicOrigin, useTls)) {
643
763
  log.warn("cross-origin request blocked: " + String(req.method) + " " + path);
644
764
  if (path === "/api" || path.startsWith("/api/")) return json(res, 403, { error: "forbidden" });
@@ -648,9 +768,12 @@ function createHandler(options) {
648
768
  try {
649
769
  cookie = await harnessCookie();
650
770
  } catch (err) {
771
+ if (res.destroyed) return;
651
772
  log.warn("harness cookie exchange failed: " + err.message);
652
773
  return json(res, 503, { error: "harness_unavailable" });
653
774
  }
775
+ if (req.aborted || res.destroyed || !res.writable) return;
776
+ if (!validSession(token)) return unauthorized();
654
777
  forward(req, res, cookie);
655
778
  };
656
779
  }
@@ -660,6 +783,7 @@ import { chmodSync, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFi
660
783
  import { randomBytes as randomBytes2 } from "node:crypto";
661
784
  import { dirname, join } from "node:path";
662
785
  import { homedir } from "node:os";
786
+ var MAX_REVOKED_SESSIONS = 1e4;
663
787
  function readState(path, mustExist = false) {
664
788
  let data;
665
789
  try {
@@ -675,6 +799,9 @@ function readState(path, mustExist = false) {
675
799
  if (!validPasswordHash(state.passwordHash) || typeof state.secret !== "string" || state.secret.length < 64) {
676
800
  throw new Error("invalid credentials");
677
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
+ }
678
805
  return state;
679
806
  } catch (err) {
680
807
  throw new Error("dsh-frontgate: invalid state file " + path + "; restore it from a backup before restarting", { cause: err });
@@ -738,6 +865,46 @@ function hashFor(plain, state) {
738
865
  return state.passwordHash && verifyPasswordSync(plain, state.passwordHash) ? state.passwordHash : hashPassword(plain);
739
866
  }
740
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
+
741
908
  // src/index.ts
742
909
  var name = "dsh-frontgate";
743
910
  var inject = ["webServer", "connection"];
@@ -750,6 +917,10 @@ function apply(ctx, config = {}) {
750
917
  const maxAttempts = intOr(cfg.maxAttempts, DEFAULTS.maxAttempts, 1, 50);
751
918
  const lockMs = intOr(cfg.lockMinutes, DEFAULTS.lockMinutes, 1, 1440) * 6e4;
752
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);
753
924
  const useTls = Boolean(cfg.tlsCertFile || cfg.tlsKeyFile);
754
925
  if (useTls && !(cfg.tlsCertFile && cfg.tlsKeyFile)) {
755
926
  throw new Error("dsh-frontgate: tlsCertFile and tlsKeyFile must be set together");
@@ -783,10 +954,17 @@ function apply(ctx, config = {}) {
783
954
  generatedPassword = randomPassword();
784
955
  passwordHash = hashPassword(generatedPassword);
785
956
  }
786
- const secret = typeof state.secret === "string" && state.secret.length >= 64 ? state.secret : randomBytes3(32).toString("hex");
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");
787
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
+ };
788
966
  if (state.passwordHash !== passwordHash || state.secret !== secret) {
789
- writeState(statePath, { passwordHash, secret, createdAt: state.createdAt || (/* @__PURE__ */ new Date()).toISOString() });
967
+ writeState(statePath, persistedState);
790
968
  }
791
969
  if (generatedPassword) {
792
970
  logger.info("no password configured - generated a random one and saved its hash to " + statePath);
@@ -798,11 +976,19 @@ function apply(ctx, config = {}) {
798
976
  if (listenPort !== 0 && listenPort === Number(target.port)) {
799
977
  throw new Error("dsh-frontgate: listenPort " + listenPort + " collides with the harness listener " + targetOrigin);
800
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
+ });
801
983
  const proxy = createProxy({
802
984
  targetOrigin,
803
985
  publicProto,
804
986
  trustProxy: Boolean(cfg.trustProxy),
805
987
  exchangeTimeoutMs,
988
+ upstreamHeaderTimeoutMs,
989
+ upstreamIdleTimeoutMs,
990
+ upstreamCa: cfg.upstreamCaFile ? readFileSync2(String(cfg.upstreamCaFile)) : void 0,
991
+ upstreamServerName: String(cfg.upstreamServerName || ""),
806
992
  authenticatedUrl: (baseUrl) => ctx.connection.authenticatedUrl(baseUrl),
807
993
  log: logger
808
994
  });
@@ -814,6 +1000,7 @@ function apply(ctx, config = {}) {
814
1000
  pv,
815
1001
  sessionSeconds,
816
1002
  maxAttempts,
1003
+ maxPendingLoginsPerIp,
817
1004
  lockMs,
818
1005
  trustProxy: Boolean(cfg.trustProxy),
819
1006
  useTls,
@@ -821,6 +1008,8 @@ function apply(ctx, config = {}) {
821
1008
  secureCookie: publicProto === "https"
822
1009
  },
823
1010
  harnessCookie: proxy.harnessCookie,
1011
+ validSession: sessions.valid,
1012
+ revokeSession: sessions.revoke,
824
1013
  forward: proxy.forward,
825
1014
  log: logger
826
1015
  });
@@ -835,10 +1024,45 @@ function apply(ctx, config = {}) {
835
1024
  const serverOptions = { connectionsCheckingInterval: 5e3, headersTimeout: 1e4, requestTimeout: 3e4 };
836
1025
  const server = tlsOptions ? https2.createServer({ ...tlsOptions, ...serverOptions }, requestListener) : http2.createServer(serverOptions, requestListener);
837
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
+ }
838
1061
  server.on("upgrade", (req, socket, head) => {
839
1062
  socket.on("error", () => socket.destroy());
840
1063
  try {
841
- if (!sessionOf(req)) {
1064
+ const token = cookieValue(req.headers.cookie, COOKIE);
1065
+ if (!token || !sessions.valid(token)) {
842
1066
  socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
843
1067
  return;
844
1068
  }
@@ -847,7 +1071,35 @@ function apply(ctx, config = {}) {
847
1071
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
848
1072
  return;
849
1073
  }
850
- proxy.harnessCookie().then((cookie) => proxy.forwardUpgrade(req, socket, head, cookie)).catch((err) => {
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;
851
1103
  logger.warn("websocket blocked: " + err.message);
852
1104
  socket.end("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
853
1105
  });
@@ -861,11 +1113,9 @@ function apply(ctx, config = {}) {
861
1113
  socket.destroy();
862
1114
  });
863
1115
  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
1116
  const shutdown = () => {
1117
+ shuttingDown = true;
1118
+ for (const socket of upgrades.keys()) socket.destroy();
869
1119
  proxy.destroy();
870
1120
  const withConnections = server;
871
1121
  if (typeof withConnections.closeAllConnections === "function") withConnections.closeAllConnections();
@@ -888,7 +1138,7 @@ function apply(ctx, config = {}) {
888
1138
  logger.warn("use a dedicated hostname or IP: browser cookies are shared across ports");
889
1139
  }
890
1140
  if (cfg.trustProxy) {
891
- logger.warn("trustProxy is enabled; restrict access to this listener to trusted proxies");
1141
+ logger.warn("trustProxy is enabled; restrict access to this listener to trusted proxies; TCP peer limits apply to the proxy");
892
1142
  }
893
1143
  const bound = ctx.webServer.config?.host;
894
1144
  if (typeof bound === "string" && !isLoopbackHost(bound)) {
@@ -33,6 +33,18 @@ export interface Config {
33
33
  lockMinutes: number;
34
34
  /** Maximum time to exchange the harness launch token, in milliseconds. */
35
35
  exchangeTimeoutMs: number;
36
+ /** Maximum simultaneous TCP connections from one peer; 0 disables this limit. */
37
+ maxConnectionsPerPeer: number;
38
+ /** Maximum login forms being received from one client IP. */
39
+ maxPendingLoginsPerIp: number;
40
+ /** Deadline for upstream response headers, including time queued for a socket; 0 disables. */
41
+ upstreamHeaderTimeoutMs: number;
42
+ /** Maximum idle time after upstream response headers; 0 disables. */
43
+ upstreamIdleTimeoutMs: number;
44
+ /** Optional CA certificate for a private HTTPS harness. */
45
+ upstreamCaFile: string;
46
+ /** Optional TLS identity when connecting to an HTTPS harness by IP. */
47
+ upstreamServerName: string;
36
48
  }
37
49
  /** Defaults applied by the loader and by direct calls to apply(). */
38
50
  export declare const DEFAULTS: Config;
@@ -15,6 +15,7 @@ export interface GateRuntime {
15
15
  pv: string;
16
16
  sessionSeconds: number;
17
17
  maxAttempts: number;
18
+ maxPendingLoginsPerIp: number;
18
19
  lockMs: number;
19
20
  trustProxy: boolean;
20
21
  useTls: boolean;
@@ -25,6 +26,8 @@ export interface GateRuntime {
25
26
  export interface GateOptions {
26
27
  runtime: GateRuntime;
27
28
  harnessCookie(): Promise<string>;
29
+ validSession(token: string): boolean;
30
+ revokeSession(token: string): boolean;
28
31
  forward(req: IncomingMessage, res: ServerResponse, cookie: string): void;
29
32
  log: LogSink;
30
33
  }
@@ -11,6 +11,10 @@ export interface ProxyOptions {
11
11
  trustProxy: boolean;
12
12
  /** Maximum time spent exchanging a launch token, including connection time. */
13
13
  exchangeTimeoutMs: number;
14
+ upstreamHeaderTimeoutMs: number;
15
+ upstreamIdleTimeoutMs: number;
16
+ upstreamCa?: Buffer;
17
+ upstreamServerName: string;
14
18
  /** Absolute entry URL with the harness launch token attached. */
15
19
  authenticatedUrl(baseUrl: string): string;
16
20
  log: LogSink;
@@ -0,0 +1,9 @@
1
+ import type { State } from './state.ts';
2
+ /** Store only a digest, never the bearer token itself. */
3
+ export declare const sessionDigest: (token: string) => string;
4
+ export interface SessionStore {
5
+ valid(token: string): boolean;
6
+ /** Persist revocation before reporting logout success. */
7
+ revoke(token: string): boolean;
8
+ }
9
+ export declare function createSessionStore(path: string, state: State, pv: string, onRevoke: (digest: string) => void): SessionStore;
@@ -1,8 +1,15 @@
1
+ /** Bound persistent logout revocations; expired entries are pruned on logout. */
2
+ export declare const MAX_REVOKED_SESSIONS = 10000;
3
+ export interface RevokedSession {
4
+ digest: string;
5
+ expires: number;
6
+ }
1
7
  /** Persisted state file contents. */
2
8
  export interface State {
3
9
  passwordHash?: string;
4
10
  secret?: string;
5
11
  createdAt?: string;
12
+ revokedSessions?: RevokedSession[];
6
13
  }
7
14
  /** Read existing state without replacing malformed or inaccessible credentials. */
8
15
  export declare function readState(path: string, mustExist?: boolean): State;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lornatang/dsh-frontgate",
3
- "version": "0.1.1",
4
- "description": "Password gate and reverse proxy for the DeepSeek Harness Web UI: scrypt login, signed session cookie, Host and Origin rewrite, launch-token exchange, WebSocket pass-through.",
3
+ "version": "0.1.2",
4
+ "description": "Password protection and reverse proxy for the DeepSeek Harness Web UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",