@riceawa/dsh-lan-gateway 0.5.0 → 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 +41 -1
- package/lib/index.js +37 -9
- package/package.json +15 -11
- package/src/index.ts +20 -7
- package/src/upstream-session.ts +19 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<img src="https://img.shields.io/badge/DeepSeek%20Harness-4d6bfe?logo=deepseek&logoColor=fff&style=flat-square" alt="DeepSeek Harness" />
|
|
9
|
-
<img src="https://img.shields.io/badge/version-0.5.
|
|
9
|
+
<img src="https://img.shields.io/badge/version-0.5.1-2b7fff?style=flat-square" alt="version 0.5.1" />
|
|
10
10
|
<img src="https://img.shields.io/badge/TLS-8b5cf6?logo=lock&logoColor=fff&style=flat-square" alt="TLS" />
|
|
11
11
|
<img src="https://img.shields.io/github/license/rice-awa/dsh-lan-gateway?style=flat-square" alt="MIT license" />
|
|
12
12
|
</p>
|
|
@@ -229,6 +229,44 @@ 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
|
+
|
|
252
|
+
## 版本兼容(0.5.0 的加载失败与修复)
|
|
253
|
+
|
|
254
|
+
0.5.0 及更早版本装在 **dsh ≥ 0.1.2-rc.1** 上会让整个 plugin tree 起不来:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
Error: dsh: plugin tree failed to load: ...
|
|
258
|
+
SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not provide an export named 'settingsNamespace'
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
`@deepseek-ai/dsh-settings` 自 `0.1.2-rc.1` 起删掉了 `settingsNamespace()` 这个品牌化辅助
|
|
262
|
+
函数(命名空间改为 `register()` 内部校验的普通字符串字面量),旧版插件在 ESM 链接期就失败;
|
|
263
|
+
cordis 的 include 一旦失败会连坐整棵树,所以表现是**所有插件都起不来**,而报错点看着像隔壁
|
|
264
|
+
插件的名字。0.5.1 移除了该导入——运行时行为不变(新旧版本的 `register()` 都按同一个
|
|
265
|
+
`NAMESPACE_PATTERN` 校验并原样接受这个字面量),因此 0.5.1 在 `0.1.0-rc.6` 到 `0.1.5-rc.2`
|
|
266
|
+
的底座上都能加载。
|
|
267
|
+
|
|
268
|
+
升级:`pnpm add @riceawa/dsh-lan-gateway@0.5.1`(或重新 `pnpm install` 走 link/github 安装)。
|
|
269
|
+
|
|
232
270
|
## 从 v0.4(及更早)升级
|
|
233
271
|
|
|
234
272
|
1. **把底座升级到 dsh ≥ 0.1.2-rc.1**(含 QVD-2026-57410 上游修复)。低于该版本时本插件
|
|
@@ -293,6 +331,8 @@ pnpm test
|
|
|
293
331
|
# ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
|
|
294
332
|
# ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
|
|
295
333
|
# ✓ tests/tls.test.ts ( 7) 证书持久化 / 重生成 / 自定义证书加载
|
|
334
|
+
# ✓ tests/upstream-session.test.ts ( 4) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
|
|
335
|
+
# invalidate 重获取
|
|
296
336
|
```
|
|
297
337
|
|
|
298
338
|
## 安全评估与修复记录
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { X509Certificate, createHmac, createSign, generateKeyPairSync, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
|
-
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
4
3
|
import http from "node:http";
|
|
5
4
|
import https from "node:https";
|
|
6
5
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -1156,6 +1155,22 @@ function nameValueOnly(setCookie) {
|
|
|
1156
1155
|
const semi = setCookie.indexOf(";");
|
|
1157
1156
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim();
|
|
1158
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
|
+
}
|
|
1159
1174
|
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
1160
1175
|
function maxAgeSeconds(setCookie) {
|
|
1161
1176
|
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie);
|
|
@@ -1193,7 +1208,7 @@ function exchange(url, authority, port) {
|
|
|
1193
1208
|
resolve(void 0);
|
|
1194
1209
|
return;
|
|
1195
1210
|
}
|
|
1196
|
-
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find(
|
|
1211
|
+
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies]).find(isUpstreamSessionCookie);
|
|
1197
1212
|
if (raw === void 0) {
|
|
1198
1213
|
resolve(void 0);
|
|
1199
1214
|
return;
|
|
@@ -1264,8 +1279,13 @@ var UpstreamSessionRelay = class {
|
|
|
1264
1279
|
const name = "dsh-lan-gateway";
|
|
1265
1280
|
/** Requires the web server service (binds before this row's apply runs) and the tool registry. */
|
|
1266
1281
|
const inject = ["webServer", "tools"];
|
|
1267
|
-
/**
|
|
1268
|
-
|
|
1282
|
+
/**
|
|
1283
|
+
* The `lan-gateway` user-settings namespace, mirroring the composition schema.
|
|
1284
|
+
* A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
|
|
1285
|
+
* helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
|
|
1286
|
+
* shape works against both that release line and the older branded one.
|
|
1287
|
+
*/
|
|
1288
|
+
const NS = "lan-gateway";
|
|
1269
1289
|
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
1270
1290
|
const OPTIONAL_CONFIG_KEYS = /* @__PURE__ */ new Set([
|
|
1271
1291
|
"dshTargetPort",
|
|
@@ -1316,8 +1336,15 @@ function resolveTls(cfg) {
|
|
|
1316
1336
|
});
|
|
1317
1337
|
return material;
|
|
1318
1338
|
}
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
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) {
|
|
1321
1348
|
return JSON.stringify([
|
|
1322
1349
|
cfg.gatewayPort,
|
|
1323
1350
|
cfg.dshTargetPort,
|
|
@@ -1332,7 +1359,8 @@ function listenerKey(cfg) {
|
|
|
1332
1359
|
cfg.tlsSelfSignedHosts,
|
|
1333
1360
|
cfg.tlsCertMaxAgeDays,
|
|
1334
1361
|
cfg.allowInsecurePlaintext,
|
|
1335
|
-
cfg.trustedTerminator
|
|
1362
|
+
cfg.trustedTerminator,
|
|
1363
|
+
relayAvailable
|
|
1336
1364
|
]);
|
|
1337
1365
|
}
|
|
1338
1366
|
/** One-line TLS description for status output. */
|
|
@@ -1422,7 +1450,7 @@ function apply(ctx, config) {
|
|
|
1422
1450
|
}, state);
|
|
1423
1451
|
await next.listen();
|
|
1424
1452
|
gateway = next;
|
|
1425
|
-
startedWith = listenerKey(cfg);
|
|
1453
|
+
startedWith = listenerKey(cfg, makeRelay !== void 0);
|
|
1426
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]"}`);
|
|
1427
1455
|
};
|
|
1428
1456
|
const stopGateway = async () => {
|
|
@@ -1444,7 +1472,7 @@ function apply(ctx, config) {
|
|
|
1444
1472
|
if (gateway === void 0) {
|
|
1445
1473
|
if (shouldRun) await startGateway(cfg);
|
|
1446
1474
|
} else if (!shouldRun) await stopGateway();
|
|
1447
|
-
else if (startedWith !== listenerKey(cfg)) {
|
|
1475
|
+
else if (startedWith !== listenerKey(cfg, makeRelay !== void 0)) {
|
|
1448
1476
|
await stopGateway();
|
|
1449
1477
|
await startGateway(cfg);
|
|
1450
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",
|
|
@@ -42,18 +42,22 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
46
|
-
"@deepseek-ai/dsh-settings": "^0.1.
|
|
47
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
48
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
46
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1 || ^0.1.5-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-rc.1 || ^0.1.5-rc.2",
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@deepseek-ai/cordis": "4.0.
|
|
52
|
-
"@deepseek-ai/dsh-
|
|
53
|
-
"@deepseek-ai/dsh-client-
|
|
54
|
-
"@deepseek-ai/dsh-
|
|
55
|
-
"@deepseek-ai/dsh-
|
|
56
|
-
"@deepseek-ai/
|
|
51
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
52
|
+
"@deepseek-ai/dsh-brand": "0.1.5-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-scope": "0.1.5-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-settings": "0.1.5-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "0.1.5-rc.2",
|
|
59
|
+
"@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
|
|
60
|
+
"@deepseek-ai/schemastery": "3.18.2",
|
|
57
61
|
"@types/node": "^22.0.0",
|
|
58
62
|
"@types/react": "~18.3.1",
|
|
59
63
|
"react": "^18.2.0",
|
package/src/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
42
42
|
import { randomBytes } from 'node:crypto'
|
|
43
43
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
44
44
|
import z from '@deepseek-ai/schemastery'
|
|
45
|
-
import {
|
|
45
|
+
import type { SettingsScope } from '@deepseek-ai/dsh-settings'
|
|
46
46
|
import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
|
|
47
47
|
import { LanGateway } from './gateway.ts'
|
|
48
48
|
import { readBody } from './login.ts'
|
|
@@ -160,8 +160,13 @@ export interface Config {
|
|
|
160
160
|
trustedTerminator?: string
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The `lan-gateway` user-settings namespace, mirroring the composition schema.
|
|
165
|
+
* A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
|
|
166
|
+
* helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
|
|
167
|
+
* shape works against both that release line and the older branded one.
|
|
168
|
+
*/
|
|
169
|
+
const NS = 'lan-gateway'
|
|
165
170
|
|
|
166
171
|
/** Optional config keys: an empty submitted value clears them back to the composition layer. */
|
|
167
172
|
const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath', 'trustedTerminator'])
|
|
@@ -236,8 +241,15 @@ function resolveTls(cfg: Config): TlsMaterial | undefined {
|
|
|
236
241
|
return material
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
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 {
|
|
241
253
|
return JSON.stringify([
|
|
242
254
|
cfg.gatewayPort,
|
|
243
255
|
cfg.dshTargetPort,
|
|
@@ -253,6 +265,7 @@ function listenerKey(cfg: Config): string {
|
|
|
253
265
|
cfg.tlsCertMaxAgeDays,
|
|
254
266
|
cfg.allowInsecurePlaintext,
|
|
255
267
|
cfg.trustedTerminator,
|
|
268
|
+
relayAvailable,
|
|
256
269
|
])
|
|
257
270
|
}
|
|
258
271
|
|
|
@@ -353,7 +366,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
353
366
|
}, state)
|
|
354
367
|
await next.listen()
|
|
355
368
|
gateway = next
|
|
356
|
-
startedWith = listenerKey(cfg)
|
|
369
|
+
startedWith = listenerKey(cfg, makeRelay !== undefined)
|
|
357
370
|
ctx.logger.info(
|
|
358
371
|
`dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
|
|
359
372
|
+ ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
|
|
@@ -382,7 +395,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
382
395
|
if (shouldRun) await startGateway(cfg)
|
|
383
396
|
} else if (!shouldRun) {
|
|
384
397
|
await stopGateway()
|
|
385
|
-
} else if (startedWith !== listenerKey(cfg)) {
|
|
398
|
+
} else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
|
|
386
399
|
await stopGateway()
|
|
387
400
|
await startGateway(cfg)
|
|
388
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
|