@riceawa/dsh-lan-gateway 0.7.0 → 0.7.1

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
@@ -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.7.0-2b7fff?style=flat-square" alt="version 0.7.0" />
9
+ <img src="https://img.shields.io/badge/version-0.7.1-2b7fff?style=flat-square" alt="version 0.7.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
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -70,7 +70,7 @@ lan_gateway disable # 关闭
70
70
 
71
71
  ## 配置
72
72
 
73
- 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页 →「已安装」→ `@riceawa/dsh-lan-gateway`:本插件的配置就渲染在这个包自己的页面上——挂的是 dsh 给「bundle 自己的配置」声明的 `plugins.bundle.config` 槽(按**包名** `@riceawa/dsh-lan-gateway` 挂载,与 dsh-mnemon、dshmarket 等插件同一套机制),页面标题、图标与面包屑由 Plugins 页自己绘制。保存即生效,监听器会按新配置自动重启。宿主机上用 `127.0.0.1` / `localhost` 打开网关地址时该页面同样可用(网关只对宿主机本机浏览器放行它的管理前缀,见「安全模型」);用局域网 IP、域名或从别的机器访问时页面读不到配置,改用 `lan_gateway` 工具。注册只在底座确实 served 本插件条目时发生——没有 Loader 条目(写入必然 409)时页面不会出现。下表既是卡片字段,也是配置键;页面顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
73
+ 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页 →「已安装」→ `@riceawa/dsh-lan-gateway`:本插件的配置就渲染在这个包自己的页面上——挂的是 dsh 给「bundle 自己的配置」声明的 `plugins.bundle.config` 槽(按**包名** `@riceawa/dsh-lan-gateway` 挂载,与 dsh-mnemon、dshmarket 等插件同一套机制),页面标题、图标与面包屑由 Plugins 页自己绘制。保存即生效,监听器会按新配置自动重启。宿主机上用 `127.0.0.1` / `localhost` 打开网关地址时该页面同样可用(网关只对宿主机本机浏览器放行它的管理前缀,见「安全模型」),桌面版(`dsh-app://app`)里也照常读写(见 0.7.1);用局域网 IP、域名或从别的机器访问时页面读不到配置,改用 `lan_gateway` 工具。注册只在底座确实 served 本插件条目时发生——没有 Loader 条目(写入必然 409)时页面不会出现。下表既是卡片字段,也是配置键;页面顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
74
74
 
75
75
  | 键 | 默认值 | 说明 |
76
76
  | --- | --- | --- |
@@ -100,7 +100,7 @@ lan_gateway disable # 关闭
100
100
  卡片顶部就是「登录密码」栏:一个「已设置 / 未设置」状态,两个密码框(新密码、再次输入),点「修改密码」直接覆盖原密码。
101
101
 
102
102
  - **不回显旧密码。** 界面只报告密码是否存在;卡片读不到旧密码。`state.json` 里只有 scrypt 哈希与盐,`/lan-gateway/password` 也从不把哈希、盐或长度返回给浏览器。
103
- - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、写操作必须带匹配的 `Origin`)。经网关访问时,只有宿主机本机浏览器(回环来源 + 回环地址)能被放行到这道围栏,远程浏览器仍是一律 403。能通过这两道门的本地用户本来就能读 `~/.dsh`。
103
+ - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、带 `Origin` 时必须与 Host 匹配;不带 `Origin` 的写操作放行——桌面版的 `dsh-app://app` 桥会剥掉这个头,dsh 自己的路由规则也不要求它)。经网关访问时,只有宿主机本机浏览器(回环来源 + 回环地址)能被放行到这道围栏,远程浏览器仍是一律 403。能通过这两道门的本地用户本来就能读 `~/.dsh`。
104
104
  - **改完即生效。** 写盘后递增会话代次:旧密码立即失效,所有已登录会话与已建立的 WebSocket 全部作废,各来源需要重新登录。
105
105
  - **只设置,不清空。** 清空密码会按设计停掉监听器(没有密码不允许监听),卡片不做这件事;要清空请用 `lan_gateway set-password` 并留空密码。
106
106
  - **密码不是配置键。** 它不写进 profile 条目,也不会出现在 `--dump-config` 里;保存配置字段动不到它,改密码也不会碰你尚未保存的字段草稿。
@@ -204,13 +204,13 @@ client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomVa
204
204
  ## 开发
205
205
 
206
206
  ```bash
207
- pnpm test # 216 项
207
+ pnpm test # 219 项
208
208
  pnpm typecheck # tsc 双端(host + client)
209
209
  ```
210
210
 
211
211
  ```
212
212
  ✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
213
- ✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
213
+ ✓ tests/start-guard.test.ts (21) fail-closed 启动守卫 / 配置路由回环围栏(含桌面桥形状)/
214
214
  Secure cookie 属性推断(含 null 清除路径)
215
215
  ✓ tests/request-policy.test.ts (50) 判定缝纯函数:路径归一化 / 归属前缀 / 同站与登录围栏 /
216
216
  回环权威(Host)判定 / 两个方向的头部变换
@@ -219,15 +219,16 @@ pnpm typecheck # tsc 双端(host + client)
219
219
  ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
220
220
  ✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
221
221
  ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
222
- ✓ tests/settings-card.test.ts (19) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
222
+ ✓ tests/settings-card.test.ts (18) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
223
223
  卡片注册槽与条目 id 契约 / 密码草稿闸门(长度与确认)/
224
224
  密码状态徽标三态(缺字段 = 未知,不是「未设置」)/
225
225
  「网关自己拒绝」与其它失败的文案分流
226
226
  ✓ tests/integration/gateway.test.ts (30) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
227
227
  cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继 /
228
228
  管理面只对本机浏览器放行(来源与 Host 两项都要满足)
229
- ✓ tests/integration/management-plane.test.ts (24) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
230
- 清空后继承 / 拒绝不可启动配置 / 卡片改密路由(覆盖原密码 /
229
+ ✓ tests/integration/management-plane.test.ts (26) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
230
+ 清空后继承 / 拒绝不可启动配置 / 桌面桥形状的保存(无 Origin)/
231
+ 卡片改密路由(覆盖原密码 /
231
232
  递增代次 / 不返回哈希 / 不给清空 / 无 settings 也生效)
232
233
  ✓ tests/integration/session-races.test.ts ( 8) 改密落在登录与握手途中的竞态 / 上游非 101 应答
233
234
  ```
package/lib/index.d.ts CHANGED
@@ -240,8 +240,24 @@ declare function resolveSecureCookies(cfg: Pick<Config, 'secureCookies' | 'tlsEn
240
240
  * gateway refuses to relay this prefix, so the only way in is the native
241
241
  * loopback listener itself (a genuine local user, or a local process that could
242
242
  * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
243
- * cross-site fetches are refused, an Origin must match the Host the browser
244
- * used, and a state-changing method must carry that Origin. Exported for tests.
243
+ * cross-site fetches are refused, and an Origin that *is* attached must match
244
+ * the Host this request named. These are exactly dsh's own rules for a request
245
+ * that may reach its API (`isTrustedApiRequest`), Origin-optional included.
246
+ *
247
+ * **Why an absent Origin is accepted, on a write too.** The Desktop app answers
248
+ * its UI from the `dsh-app://app` origin and forwards every non-asset path to
249
+ * this loopback server through a bridge that strips `host`/`origin`/`cookie`/
250
+ * `sec-fetch-site` and re-attaches dsh's own host session cookie
251
+ * (`forwardWebRequest` in `@deepseek-ai/dsh-desktop-host`). A read from that
252
+ * bridge was always fine; demanding an Origin on a state change made every save
253
+ * from the Desktop card a 403 — the one place a local operator goes looking for
254
+ * this setting — while dsh's own routes accept the shape. Nothing is given up:
255
+ * a non-browser client on this host sets Host *and* Origin freely, so that rule
256
+ * never fenced it, and a browser cannot suppress the markers that do the work —
257
+ * a cross-site request carries `sec-fetch-site: cross-site`, or an Origin that
258
+ * does not name this Host (a cross-origin redirect or a sandboxed frame makes
259
+ * it the literal `null`, which fails the match just as well), and both stay
260
+ * refused. Exported for tests.
245
261
  */
246
262
  declare function isTrustedConfigRequest(req: IncomingMessage): boolean;
247
263
  /**
package/lib/index.js CHANGED
@@ -2125,8 +2125,24 @@ function tlsStatusLine(cfg) {
2125
2125
  * gateway refuses to relay this prefix, so the only way in is the native
2126
2126
  * loopback listener itself (a genuine local user, or a local process that could
2127
2127
  * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
2128
- * cross-site fetches are refused, an Origin must match the Host the browser
2129
- * used, and a state-changing method must carry that Origin. Exported for tests.
2128
+ * cross-site fetches are refused, and an Origin that *is* attached must match
2129
+ * the Host this request named. These are exactly dsh's own rules for a request
2130
+ * that may reach its API (`isTrustedApiRequest`), Origin-optional included.
2131
+ *
2132
+ * **Why an absent Origin is accepted, on a write too.** The Desktop app answers
2133
+ * its UI from the `dsh-app://app` origin and forwards every non-asset path to
2134
+ * this loopback server through a bridge that strips `host`/`origin`/`cookie`/
2135
+ * `sec-fetch-site` and re-attaches dsh's own host session cookie
2136
+ * (`forwardWebRequest` in `@deepseek-ai/dsh-desktop-host`). A read from that
2137
+ * bridge was always fine; demanding an Origin on a state change made every save
2138
+ * from the Desktop card a 403 — the one place a local operator goes looking for
2139
+ * this setting — while dsh's own routes accept the shape. Nothing is given up:
2140
+ * a non-browser client on this host sets Host *and* Origin freely, so that rule
2141
+ * never fenced it, and a browser cannot suppress the markers that do the work —
2142
+ * a cross-site request carries `sec-fetch-site: cross-site`, or an Origin that
2143
+ * does not name this Host (a cross-origin redirect or a sandboxed frame makes
2144
+ * it the literal `null`, which fails the match just as well), and both stay
2145
+ * refused. Exported for tests.
2130
2146
  */
2131
2147
  function isTrustedConfigRequest(req) {
2132
2148
  const host = req.headers?.host;
@@ -2141,8 +2157,6 @@ function isTrustedConfigRequest(req) {
2141
2157
  if (req.headers?.["sec-fetch-site"] === "cross-site") return false;
2142
2158
  const origin = req.headers?.origin;
2143
2159
  if (origin !== void 0 && !originMatchesHost(origin, host)) return false;
2144
- const method = req.method ?? "GET";
2145
- if (!READ_ONLY_METHODS.has(method) && origin === void 0) return false;
2146
2160
  return true;
2147
2161
  }
2148
2162
  /**
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 configuration page on the dsh Plugins page (the bundle's own `plugins.bundle.config` slot) for live adjustment of port, CIDRs, auth, TLS, and the login password (the card overwrites the stored credential without ever displaying the old one; the password stays a scrypt hash in state.json, out of the config schema). 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.7.0",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
package/src/index.ts CHANGED
@@ -61,10 +61,7 @@ import {
61
61
  } from './config-fields.ts'
62
62
  import { LanGateway } from './gateway.ts'
63
63
  import { readBody } from './login.ts'
64
- import {
65
- isLoopbackHost,
66
- READ_ONLY_METHODS,
67
- } from './request-policy.ts'
64
+ import { isLoopbackHost } from './request-policy.ts'
68
65
  import {
69
66
  loadState,
70
67
  saveState,
@@ -451,8 +448,24 @@ function tlsStatusLine(cfg: Config): string {
451
448
  * gateway refuses to relay this prefix, so the only way in is the native
452
449
  * loopback listener itself (a genuine local user, or a local process that could
453
450
  * already read `~/.dsh`). Host must be loopback (also blocks DNS rebinding),
454
- * cross-site fetches are refused, an Origin must match the Host the browser
455
- * used, and a state-changing method must carry that Origin. Exported for tests.
451
+ * cross-site fetches are refused, and an Origin that *is* attached must match
452
+ * the Host this request named. These are exactly dsh's own rules for a request
453
+ * that may reach its API (`isTrustedApiRequest`), Origin-optional included.
454
+ *
455
+ * **Why an absent Origin is accepted, on a write too.** The Desktop app answers
456
+ * its UI from the `dsh-app://app` origin and forwards every non-asset path to
457
+ * this loopback server through a bridge that strips `host`/`origin`/`cookie`/
458
+ * `sec-fetch-site` and re-attaches dsh's own host session cookie
459
+ * (`forwardWebRequest` in `@deepseek-ai/dsh-desktop-host`). A read from that
460
+ * bridge was always fine; demanding an Origin on a state change made every save
461
+ * from the Desktop card a 403 — the one place a local operator goes looking for
462
+ * this setting — while dsh's own routes accept the shape. Nothing is given up:
463
+ * a non-browser client on this host sets Host *and* Origin freely, so that rule
464
+ * never fenced it, and a browser cannot suppress the markers that do the work —
465
+ * a cross-site request carries `sec-fetch-site: cross-site`, or an Origin that
466
+ * does not name this Host (a cross-origin redirect or a sandboxed frame makes
467
+ * it the literal `null`, which fails the match just as well), and both stay
468
+ * refused. Exported for tests.
456
469
  */
457
470
  export function isTrustedConfigRequest(req: IncomingMessage): boolean {
458
471
  const host = req.headers?.host
@@ -467,8 +480,6 @@ export function isTrustedConfigRequest(req: IncomingMessage): boolean {
467
480
  if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
468
481
  const origin = req.headers?.origin
469
482
  if (origin !== undefined && !originMatchesHost(origin, host)) return false
470
- const method = req.method ?? 'GET'
471
- if (!READ_ONLY_METHODS.has(method) && origin === undefined) return false
472
483
  return true
473
484
  }
474
485