@riceawa/dsh-lan-gateway 0.5.1 → 0.5.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/README.md +22 -0
- package/lib/index.js +30 -6
- package/package.json +10 -9
- package/src/index.ts +12 -4
- package/src/upstream-session.ts +19 -1
package/README.md
CHANGED
|
@@ -229,6 +229,26 @@ IPv6 的 `fe80::/10`(link-local)与 `127.0.0.0/8` / `::1` 归类为 LAN/loop
|
|
|
229
229
|
- **WebSocket**:`/api` 升级请求同样过登录校验、同源 Origin 校验,再拼接转发给 dsh,
|
|
230
230
|
并纳入会话撤销(epoch 变化即断开)。
|
|
231
231
|
|
|
232
|
+
## 0.5.2:共享会话中继修复
|
|
233
|
+
|
|
234
|
+
0.5.0 / 0.5.1 在 **dsh ≥ 0.1.2-rc.1** 上时,网关自己的登录能过,但每一个转发请求都被上游
|
|
235
|
+
401 拒绝,浏览器只看到:
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
dsh web authentication required; reopen the URL printed by dsh web.
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
问题出在共享上游会话中继的 cookie 匹配:插件用 `dsh-auth-=` 这个前缀去找上游签发的会话
|
|
242
|
+
cookie,而 dsh 实际签发的名字是 `dsh-auth-<base64url(sha256(authority))>` —— 前缀后面永远
|
|
243
|
+
跟哈希而不是 `=`,匹配必然为空。令牌换取本身是好的(`GET /?token=…` 确实发出、也拿到了
|
|
244
|
+
`Set-Cookie`),但那条 cookie 在这一步被丢弃,转发请求全部以匿名身份发出。0.5.2 改为
|
|
245
|
+
「名字以 `dsh-auth-` 开头且后面还有内容」,并新增 `tests/upstream-session.test.ts`:用真实
|
|
246
|
+
回环 HTTP 服务端跑完整换取链路(集成测试注入的是假会话对象,正好绕过了这段)。
|
|
247
|
+
|
|
248
|
+
同一失效域还顺带修掉一个启动竞态:`listenerKey` 现在把「中继是否可用」计入重启判据。
|
|
249
|
+
监听器若早于 `connection` 服务启动(因此没有中继),会在服务挂载后自动重启,而不是一直
|
|
250
|
+
匿名转发、状态里却写着 relay active。
|
|
251
|
+
|
|
232
252
|
## 版本兼容(0.5.0 的加载失败与修复)
|
|
233
253
|
|
|
234
254
|
0.5.0 及更早版本装在 **dsh ≥ 0.1.2-rc.1** 上会让整个 plugin tree 起不来:
|
|
@@ -311,6 +331,8 @@ pnpm test
|
|
|
311
331
|
# ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
|
|
312
332
|
# ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
|
|
313
333
|
# ✓ tests/tls.test.ts ( 7) 证书持久化 / 重生成 / 自定义证书加载
|
|
334
|
+
# ✓ tests/upstream-session.test.ts ( 4) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
|
|
335
|
+
# invalidate 重获取
|
|
314
336
|
```
|
|
315
337
|
|
|
316
338
|
## 安全评估与修复记录
|
package/lib/index.js
CHANGED
|
@@ -1155,6 +1155,22 @@ function nameValueOnly(setCookie) {
|
|
|
1155
1155
|
const semi = setCookie.indexOf(";");
|
|
1156
1156
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
1157
1157
|
}
|
|
1158
|
+
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
1159
|
+
function cookieNameOf(setCookie) {
|
|
1160
|
+
const eq = setCookie.indexOf("=");
|
|
1161
|
+
return eq === -1 ? "" : setCookie.slice(0, eq).trim();
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
1165
|
+
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
1166
|
+
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
1167
|
+
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
1168
|
+
* and silently relays every request anonymously.
|
|
1169
|
+
*/
|
|
1170
|
+
function isUpstreamSessionCookie(setCookie) {
|
|
1171
|
+
const name = cookieNameOf(setCookie);
|
|
1172
|
+
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > 9;
|
|
1173
|
+
}
|
|
1158
1174
|
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
1159
1175
|
function maxAgeSeconds(setCookie) {
|
|
1160
1176
|
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
@@ -1192,7 +1208,7 @@ function exchange(url, authority, port) {
|
|
|
1192
1208
|
resolve(void 0);
|
|
1193
1209
|
return;
|
|
1194
1210
|
}
|
|
1195
|
-
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find(
|
|
1211
|
+
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find(isUpstreamSessionCookie);
|
|
1196
1212
|
if (raw === void 0) {
|
|
1197
1213
|
resolve(void 0);
|
|
1198
1214
|
return;
|
|
@@ -1320,8 +1336,15 @@ function resolveTls(cfg) {
|
|
|
1320
1336
|
});
|
|
1321
1337
|
return material;
|
|
1322
1338
|
}
|
|
1323
|
-
/**
|
|
1324
|
-
|
|
1339
|
+
/**
|
|
1340
|
+
* Config fields that require a listener restart when they change, plus whether
|
|
1341
|
+
* a shared upstream session relay is available at all. The relay flag belongs
|
|
1342
|
+
* in the key: a listener that started before the `connection` service appeared
|
|
1343
|
+
* was built without a relay and must restart once the service attaches,
|
|
1344
|
+
* otherwise it silently forwards every request anonymously (the upstream 401s)
|
|
1345
|
+
* while the status line still claims the relay is active.
|
|
1346
|
+
*/
|
|
1347
|
+
function listenerKey(cfg, relayAvailable) {
|
|
1325
1348
|
return JSON.stringify([
|
|
1326
1349
|
cfg.gatewayPort,
|
|
1327
1350
|
cfg.dshTargetPort,
|
|
@@ -1336,7 +1359,8 @@ function listenerKey(cfg) {
|
|
|
1336
1359
|
cfg.tlsSelfSignedHosts,
|
|
1337
1360
|
cfg.tlsCertMaxAgeDays,
|
|
1338
1361
|
cfg.allowInsecurePlaintext,
|
|
1339
|
-
cfg.trustedTerminator
|
|
1362
|
+
cfg.trustedTerminator,
|
|
1363
|
+
relayAvailable
|
|
1340
1364
|
]);
|
|
1341
1365
|
}
|
|
1342
1366
|
/** One-line TLS description for status output. */
|
|
@@ -1426,7 +1450,7 @@ function apply(ctx, config) {
|
|
|
1426
1450
|
}, state);
|
|
1427
1451
|
await next.listen();
|
|
1428
1452
|
gateway = next;
|
|
1429
|
-
startedWith = listenerKey(cfg);
|
|
1453
|
+
startedWith = listenerKey(cfg, makeRelay !== void 0);
|
|
1430
1454
|
ctx.logger.info(`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== void 0 ? " (TLS)" : ""} -> 127.0.0.1:${dshPort}${encryptedIngress ? "" : " (plaintext, explicit allowInsecurePlaintext)"}${makeRelay !== void 0 ? " [shared upstream session relay]" : " [no upstream session relay: base has no browser-session auth]"}`);
|
|
1431
1455
|
};
|
|
1432
1456
|
const stopGateway = async () => {
|
|
@@ -1448,7 +1472,7 @@ function apply(ctx, config) {
|
|
|
1448
1472
|
if (gateway === void 0) {
|
|
1449
1473
|
if (shouldRun) await startGateway(cfg);
|
|
1450
1474
|
} else if (!shouldRun) await stopGateway();
|
|
1451
|
-
else if (startedWith !== listenerKey(cfg)) {
|
|
1475
|
+
else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
|
|
1452
1476
|
await stopGateway();
|
|
1453
1477
|
await startGateway(cfg);
|
|
1454
1478
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riceawa/dsh-lan-gateway",
|
|
3
3
|
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
"skills",
|
|
23
23
|
"cordis.patch.yml"
|
|
24
24
|
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"build:client": "tsdown",
|
|
30
|
+
"prepack": "tsdown"
|
|
31
|
+
},
|
|
25
32
|
"dsh": {
|
|
26
33
|
"bundle": {
|
|
27
34
|
"patch": "./cordis.patch.yml"
|
|
@@ -79,11 +86,5 @@
|
|
|
79
86
|
"bugs": {
|
|
80
87
|
"url": "https://github.com/rice-awa/dsh-lan-gateway/issues"
|
|
81
88
|
},
|
|
82
|
-
"homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme"
|
|
83
|
-
|
|
84
|
-
"build": "tsdown",
|
|
85
|
-
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
86
|
-
"test": "vitest run",
|
|
87
|
-
"build:client": "tsdown"
|
|
88
|
-
}
|
|
89
|
-
}
|
|
89
|
+
"homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme"
|
|
90
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -241,8 +241,15 @@ function resolveTls(cfg: Config): TlsMaterial | undefined {
|
|
|
241
241
|
return material
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
244
|
+
/**
|
|
245
|
+
* Config fields that require a listener restart when they change, plus whether
|
|
246
|
+
* a shared upstream session relay is available at all. The relay flag belongs
|
|
247
|
+
* in the key: a listener that started before the `connection` service appeared
|
|
248
|
+
* was built without a relay and must restart once the service attaches,
|
|
249
|
+
* otherwise it silently forwards every request anonymously (the upstream 401s)
|
|
250
|
+
* while the status line still claims the relay is active.
|
|
251
|
+
*/
|
|
252
|
+
function listenerKey(cfg: Config, relayAvailable: boolean): string {
|
|
246
253
|
return JSON.stringify([
|
|
247
254
|
cfg.gatewayPort,
|
|
248
255
|
cfg.dshTargetPort,
|
|
@@ -258,6 +265,7 @@ function listenerKey(cfg: Config): string {
|
|
|
258
265
|
cfg.tlsCertMaxAgeDays,
|
|
259
266
|
cfg.allowInsecurePlaintext,
|
|
260
267
|
cfg.trustedTerminator,
|
|
268
|
+
relayAvailable,
|
|
261
269
|
])
|
|
262
270
|
}
|
|
263
271
|
|
|
@@ -358,7 +366,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
358
366
|
}, state)
|
|
359
367
|
await next.listen()
|
|
360
368
|
gateway = next
|
|
361
|
-
startedWith = listenerKey(cfg)
|
|
369
|
+
startedWith = listenerKey(cfg, makeRelay !== undefined)
|
|
362
370
|
ctx.logger.info(
|
|
363
371
|
`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
|
|
364
372
|
+ ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
|
|
@@ -387,7 +395,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
387
395
|
if (shouldRun) await startGateway(cfg)
|
|
388
396
|
} else if (!shouldRun) {
|
|
389
397
|
await stopGateway()
|
|
390
|
-
} else if (startedWith !== listenerKey(cfg)) {
|
|
398
|
+
} else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
|
|
391
399
|
await stopGateway()
|
|
392
400
|
await startGateway(cfg)
|
|
393
401
|
}
|
package/src/upstream-session.ts
CHANGED
|
@@ -65,6 +65,24 @@ function nameValueOnly(setCookie: string): string {
|
|
|
65
65
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
69
|
+
function cookieNameOf(setCookie: string): string {
|
|
70
|
+
const eq = setCookie.indexOf('=')
|
|
71
|
+
return eq === -1 ? '' : setCookie.slice(0, eq).trim()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
76
|
+
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
77
|
+
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
78
|
+
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
79
|
+
* and silently relays every request anonymously.
|
|
80
|
+
*/
|
|
81
|
+
function isUpstreamSessionCookie(setCookie: string): boolean {
|
|
82
|
+
const name = cookieNameOf(setCookie)
|
|
83
|
+
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
69
87
|
function maxAgeSeconds(setCookie: string): number | undefined {
|
|
70
88
|
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie)
|
|
@@ -111,7 +129,7 @@ function exchange(
|
|
|
111
129
|
return
|
|
112
130
|
}
|
|
113
131
|
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
|
|
114
|
-
.find(
|
|
132
|
+
.find(isUpstreamSessionCookie)
|
|
115
133
|
if (raw === undefined) {
|
|
116
134
|
resolve(undefined)
|
|
117
135
|
return
|