@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 +10 -0
- package/README.md +34 -6
- package/lib/index.js +280 -30
- package/lib/types/config.d.ts +12 -0
- package/lib/types/gate.d.ts +3 -0
- package/lib/types/proxy.d.ts +4 -0
- package/lib/types/sessions.d.ts +9 -0
- package/lib/types/state.d.ts +7 -0
- package/package.json +2 -2
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:
|
|
91
|
+
- '30190:30010'
|
|
78
92
|
```
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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", () =>
|
|
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", () =>
|
|
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,
|
|
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) &&
|
|
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))
|
|
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)
|
|
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 (
|
|
589
|
-
return text(res,
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
package/lib/types/config.d.ts
CHANGED
|
@@ -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;
|
package/lib/types/gate.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/types/proxy.d.ts
CHANGED
|
@@ -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;
|
package/lib/types/state.d.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Password
|
|
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",
|