@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 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((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`));
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
- /** Config fields that require a listener restart when they change. */
1324
- function listenerKey(cfg) {
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.1",
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
- "scripts": {
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
- /** Config fields that require a listener restart when they change. */
245
- function listenerKey(cfg: Config): string {
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
  }
@@ -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((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`))
132
+ .find(isUpstreamSessionCookie)
115
133
  if (raw === undefined) {
116
134
  resolve(undefined)
117
135
  return