@riceawa/dsh-lan-gateway 0.5.2 → 0.5.3

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,167 +6,112 @@
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.1-2b7fff?style=flat-square" alt="version 0.5.1" />
9
+ <img src="https://img.shields.io/badge/version-0.5.3-2b7fff?style=flat-square" alt="version 0.5.3" />
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
+ <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
12
13
  </p>
13
14
 
14
- > 把 DeepSeek Harness 的 Web GUI 安全地开放到局域网 / 公网。
15
- > 附带**不安全源 UUID shim**:网关以纯 HTTP 局域网地址服务页面时,浏览器不提供
16
- > `crypto.randomUUID`,本插件的 client bundle 会在页面加载早期自动补上
17
- > `getRandomValues` 版实现,让工作区(含其他设备打开的工作区)在网关下正常打开。
18
- > 附带**TLS 支持**:可用自动生成并持久化的**自签名证书**,或挂载**自己申请/签发的
19
- > PEM 证书**,让网关以 HTTPS 服务(自签名证书首次访问会看到浏览器警告,属预期行为)。
20
-
21
- `dsh` 的 web CLI 会硬拒绝 `--host 0.0.0.0`(避免把远程代码执行暴露到网络),所以本插件
22
- 让 dsh 继续只绑 `127.0.0.1`,自己另起一个 `0.0.0.0` 反向代理网关转发到 loopback 端口,
23
- 改写 `Host`/`Origin`。**默认拒绝(fail-closed):所有来源——loopback、LAN、公网——都必须
24
- 先通过网关的登录页并出示 HMAC 会话 cookie**;“LAN 免密”改为显式 `lanPasswordless` opt-in
25
- 且默认关闭。对 dsh ≥ 0.1.2-rc.1(修复了 QVD-2026-57410 的浏览器会话认证底座),网关在进程内
26
- 中继**一条共享的上游会话**,上游自身的授权仍然把关每个请求——网关只决定谁能骑上这条
27
- 共享会话。
28
-
29
- ## 特性
30
-
31
- - **双端一体**:host 端是反向代理网关(登录 / HMAC cookie / 会话撤销 / Origin 围栏 /
32
- TLS / 上游会话中继);client 端是不安全源 UUID shim + **官方设置页卡片**(DSH Settings
33
- → Plugins → 可配置插件,网关的端口 / 网段 / 认证 / TLS 全部可视化调整,保存即热生效)。
34
- - **默认全来源登录(QVD-2026-57410 加固)**:不再“只对公网来源认证”;loopback/LAN 同样
35
- 要求会话。唯一豁免是显式 `lanPasswordless: true`(仅豁免网关登录,上游会话中继仍生效)。
36
- - **共享上游会话中继**:dsh 0.1.2 起不再信任 loopback Host,要求出示绑定权威来源的
37
- `dsh-auth-*` cookie;插件在进程内用启动令牌走一遍浏览器等价换取,把这一条会话中继到
38
- 每个转发请求上(“单密码 = 单操作者”语义不变)。底座不支持时自动降级为无中继转发。
39
- - **fail-closed 启动守卫**:未设密码拒启;`authRequired:false`(v0.4 及更早)拒启并给迁移
40
- 文案;明文监听需显式 `allowInsecurePlaintext:true`(或 TLS / 声明 `trustedTerminator`)。
41
- - **TLS 双模式**:`self-signed` 自动生成自签名证书(首次启动生成并持久化到
42
- `~/.dsh/lan-gateway/tls/`,重启复用;`lan_gateway tls-regenerate` 可换新证书),或
43
- `custom` 直接挂载你自己的 PEM 证书与私钥(如 Let's Encrypt / 自建 CA 签发)。
44
- - **会话撤销**:cookie 携带撤销 epoch;改密 / 清密 / `rotate-secret` 都会递增 epoch,使
45
- 所有已签发 cookie **与已建立的 WebSocket** 立即失效。清空密码会直接停止监听。
46
- - **默认关闭(安全)**:bundle patch 里 `enabled: false`,只有运行 `lan_gateway enable`
47
- 后才监听网络端口。
48
- - **密钥不进配置**:密码哈希、cookie secret 存 `~/.dsh/lan-gateway/state.json`。
49
-
50
- ## 快速安装(推荐)
51
-
52
- 已发布到 npm(预构建,安装无需 `allowBuilds` 授权)。请把下面这段话发送给你的 agent:
15
+ `dsh web` 硬拒绝 `--host 0.0.0.0`,怕把远程代码执行暴露到网络。这个插件的做法是让 dsh 继续只绑 `127.0.0.1`,另起一个监听 `0.0.0.0` 的反向代理,转发到 loopback 端口并改写 `Host` / `Origin`。
53
16
 
54
- > 帮我安装 dsh 插件 `@riceawa/dsh-lan-gateway`,遵循
55
- > `https://github.com/rice-awa/dsh-lan-gateway/blob/main/INSTALL.md`
56
-
57
- ## 配套 skill
58
-
59
- 仓库还带一个 [lan-gateway](skills/lan-gateway.md) 技能:让 dsh 的 agent 在对话中
60
- 自动管理网关——开/关监听、设置或更换远程访问密码、轮换会话密钥、查看状态。装上后
61
- 直接说「设置网关密码为 …」「开启远程访问」即可,agent 会调用 `lan_gateway` 工具
62
- 完成(密码以参数传入,不写入配置、不回显)。安装方式见
63
- [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
17
+ 默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页拿到 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless`,默认关闭。底座是 dsh ≥ 0.1.2-rc.1 时(含 QVD-2026-57410 的上游修复),网关在进程内中继一条共享上游会话,上游自己的授权仍然把关每个请求,网关只决定谁能骑上这条会话。
64
18
 
65
- ## 移动端访问(推荐)
19
+ 插件还捎带两件事:
66
20
 
67
- 在手机 / 平板上通过网关访问 GUI 时,桌面布局体验不佳。推荐同时安装
68
- [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)(移动端 UI 适配),
69
- 与本插件配合使用:
21
+ - **不安全源 UUID shim**:网关以纯 HTTP 的局域网地址服务页面,浏览器视其为不安全源,不提供 `crypto.randomUUID`。client bundle 在页面加载早期补一个基于 `getRandomValues` 的实现,工作区才打得开。
22
+ - **TLS**:自动生成并持久化的自签名证书,或者挂载你自己签发的 PEM。自签名证书首次访问会有浏览器警告,正常现象。
70
23
 
71
- ```bash
72
- dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile
73
- ```
24
+ ## 安装
74
25
 
75
- ## 手动安装
26
+ 已发布到 npm,装的是预构建产物,不需要 `allowBuilds` 授权。把这段话丢给你的 agent 就行:
76
27
 
77
- ### 方式 A:npm 包(推荐,免 allowBuilds)
28
+ > 帮我安装 dsh 插件 `@riceawa/dsh-lan-gateway`,遵循
29
+ > `https://github.com/rice-awa/dsh-lan-gateway/blob/main/INSTALL.md`
78
30
 
79
- 已发布预构建产物到 npm,安装时**不需要**批准构建脚本:
31
+ 也可以自己敲:
80
32
 
81
33
  ```bash
82
- # 官方装配(重启后由 bundles 列表接管,生产态)
83
34
  dsh plugin --profile web add @riceawa/dsh-lan-gateway
84
35
  ```
85
36
 
86
- > `dsh plugin ... add` 把剩余参数转发给 profile 目录里的 pnpm,npm 包自带预构建
87
- > 的 `lib/`,不会触发 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`。若仍提示,把报错
88
- > 条目写进 `~/.dsh/profiles/web/pnpm-workspace.yaml` 的 `allowBuilds` 再重试,
89
- > 完整步骤见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
37
+ `dsh plugin ... add` 会把参数转发给 profile 目录里的 pnpm。npm 包自带 `lib/`,不会触发 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`。真报了错,就把报错条目写进 `~/.dsh/profiles/web/pnpm-workspace.yaml` 的 `allowBuilds` 重试,完整步骤见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
90
38
 
91
- ### 方式 B:从源码构建
39
+ 从源码构建:
92
40
 
93
41
  ```bash
94
42
  git clone https://github.com/rice-awa/dsh-lan-gateway.git
95
43
  cd dsh-lan-gateway
96
44
  pnpm install
97
- pnpm build # host(lib/index.js)
98
- pnpm build:client # client(lib/client.js,window.__ModuleLoader__ 格式)
99
- pnpm test # 72 项(网关单元 27 + start-guard 12 + 集成 17 + UUID shim 3 + x509 6 + TLS 7)
45
+ pnpm build # host → lib/index.js
46
+ pnpm build:client # client → lib/client.js
47
+ pnpm test # 93 项
100
48
  ```
101
49
 
102
- ## 使用
103
-
104
- ```bash
105
- # 开启网关。先满足启动条件(已设密码 + 加密入口),否则 enable 会给出迁移文案
106
- lan_gateway enable
107
-
108
- # 查看状态(端口 / 目标 / 密码 / 会话 epoch / 中继状态 / 入口加密方式 / 上次错误)
109
- lan_gateway status
50
+ 仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,装上之后可以直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写配置也不回显。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
110
51
 
111
- # 设置登录密码(≥8 位;改动会让所有已签发会话立即失效)
112
- lan_gateway set-password
52
+ 手机 / 平板上访问的话,再搭一个 [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile) 做移动端 UI 适配:
113
53
 
114
- # 轮换会话密钥(作废全部登录 cookie 与已建立的 WebSocket)
115
- lan_gateway rotate-secret
54
+ ```bash
55
+ dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile
56
+ ```
116
57
 
117
- # 换发自签名 TLS 证书(tlsMode=self-signed 时;换新密钥并热重启监听器)
118
- lan_gateway tls-regenerate
58
+ ## 用法
119
59
 
120
- # 关闭
121
- lan_gateway disable
60
+ ```bash
61
+ lan_gateway enable # 开启网关(需先满足启动条件,否则给出迁移文案)
62
+ lan_gateway status # 端口 / 目标 / 密码 / 会话 epoch / 中继状态 / 入口加密方式 / 上次错误
63
+ lan_gateway set-password # 设置登录密码(≥8 位,改动会让所有已签发会话立即失效)
64
+ lan_gateway rotate-secret # 轮换会话密钥,作废全部登录 cookie 与已建立的 WebSocket
65
+ lan_gateway tls-regenerate # 换发自签名证书(tlsMode=self-signed 时)
66
+ lan_gateway disable # 关闭
122
67
  ```
123
68
 
124
- > `lan_gateway` 是一个模型可调用的工具,上面的命令不必由你手动敲——**直接在 dsh 对话
125
- > 里说即可**,例如“设置网关密码为 ……”(模型会调用 `lan_gateway set-password`,密码以
126
- > 参数传入、不会回显)、“查看网关状态”、“开启 / 关闭网关”。在对话中设置密码时请直接
127
- > 把密码说给模型,它不会把密码写进任何配置文件。
69
+ `lan_gateway` 是模型可调用的工具,上面这些命令不必自己敲。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会落进任何配置文件。
128
70
 
129
- ## 配置项(bundle patch / `--patch` 覆盖,或官方设置页)
71
+ ## 配置
130
72
 
131
- 所有可调项都同时暴露为 `lan-gateway` 用户设置命名空间:打开 **DSH 的 Settings → Plugins
132
- → 可配置插件**,展开「LAN 网关」卡片即可修改,保存即生效(监听器会按新配置自动重启)。
133
- 下表即卡片字段 / 配置键:
73
+ 所有可调项都暴露为 `lan-gateway` 用户设置命名空间。打开 **DSH 的 Settings → Plugins → 可配置插件**,展开「LAN 网关」卡片就能改,保存即生效,监听器会按新配置自动重启。下表既是卡片字段,也是配置键:
134
74
 
135
75
  | 键 | 默认值 | 说明 |
136
76
  | --- | --- | --- |
137
77
  | `enabled` | `false` | 是否在启动时监听网络端口 |
138
78
  | `gatewayPort` | `3081` | 网关监听端口(`0.0.0.0`) |
139
79
  | `dshTargetPort` | 跟随 `ctx.webServer.port` | 转发到的 dsh loopback 端口 |
140
- | `lanCidrs` | RFC1918 + link-local(见下) | 视为 LAN 的网段;仅在 `lanPasswordless` 开启时用作豁免匹配集 |
141
- | `lanPasswordless` | `false` | 显式 opt-in:LAN/loopback 来源跳过网关登录页(上游会话中继仍把关) |
80
+ | `lanCidrs` | RFC1918 + link-local(见下) | 视为 LAN 的网段,仅在 `lanPasswordless` 开启时用作豁免匹配集 |
81
+ | `lanPasswordless` | `false` | LAN/loopback 来源跳过网关登录页(上游会话中继仍把关) |
142
82
  | `cookieMaxAgeDays` | `7` | 会话 cookie 有效期(天) |
143
- | `cookieName` | `dsh_gw_auth` | 会话 cookie 名(不进卡片) |
144
- | `tlsEnabled` | `false` | 是否以 HTTPS(TLS)提供服务 |
145
- | `tlsMode` | `self-signed` | 证书来源:`self-signed` 自动生成 / `custom` 用自己的证书 |
83
+ | `cookieName` | `dsh_gw_auth` | 会话 cookie 名,不进卡片 |
84
+ | `tlsEnabled` | `false` | 是否以 HTTPS 提供服务 |
85
+ | `tlsMode` | `self-signed` | `self-signed` 自动生成 / `custom` 用自己的证书 |
146
86
  | `tlsSelfSignedHosts` | `localhost` | 自签名证书的 SAN(逗号分隔的域名 / IP) |
147
87
  | `tlsCertPath` | — | `custom` 模式:PEM 证书(或证书链)绝对路径 |
148
88
  | `tlsKeyPath` | — | `custom` 模式:PEM 私钥绝对路径 |
149
89
  | `tlsCertMaxAgeDays` | `825` | 自签名证书有效期(天) |
150
- | `allowInsecurePlaintext` | `false` | 显式 opt-in:允许明文 HTTP 监听(见下「入口加密」) |
90
+ | `allowInsecurePlaintext` | `false` | 允许明文 HTTP 监听(见下「入口加密」) |
151
91
  | `trustedTerminator` | — | 声明一个受信 TLS 终止代理标识,视为加密入口(如 `nginx`) |
92
+ | `secureCookies` | 自动 | 会话 cookie 的 `Secure` 属性显式开关,默认按 `tlsEnabled` 或 `trustedTerminator` 推断(见下) |
152
93
 
153
- > v0.5.0 起 `authRequired` 被移除:认证恒为必需。若配置里残留 `authRequired: false`
154
- > (v0.4 及更早的写法),启停守卫会拒绝并提示迁移——不会静默降级回“免密”。
94
+ 默认 `lanCidrs`:`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`169.254.0.0/16`。IPv6 的 `fe80::/10`(link-local)与 `127.0.0.0/8`、`::1` 归为 LAN/loopback。
155
95
 
156
- ### 入口加密(防明文)
96
+ 配置里残留 `authRequired: false`(v0.4 及更早的写法)会被启停守卫拒绝并提示迁移,不会静默降级成免密。
157
97
 
158
- 网关默认**拒绝纯明文监听**,三种方式任选其一即可启动:
98
+ ### 入口加密
99
+
100
+ 网关默认拒绝纯明文监听,下面三条路任选一条才能启动:
101
+
102
+ 1. 启用 TLS,`tlsEnabled: true`。推荐,自签名或 custom 证书都行。
103
+ 2. 声明由可信反向代理终止 TLS:
159
104
 
160
- 1. 启用 TLS:`tlsEnabled: true`(推荐,自签名或 custom 证书均可);
161
- 2. 声明由可信反向代理(nginx 等)终止 TLS:
162
105
  ```yaml
163
106
  - id: dsh-lan-gateway
164
107
  config:
165
108
  enabled: true
166
109
  gatewayPort: 8080
167
- trustedTerminator: nginx # 由 nginx 以 HTTPS 对外,再转发回本端口
110
+ trustedTerminator: nginx # nginx 以 HTTPS 对外,再转发回本端口
168
111
  ```
169
- 3. 显式接受明文(风险自担,密码与会话将明文在网内传输):
112
+
113
+ 3. 显式接受明文,风险自担,密码与会话会在网内明文传输:
114
+
170
115
  ```yaml
171
116
  - id: dsh-lan-gateway
172
117
  config:
@@ -175,11 +120,7 @@ lan_gateway disable
175
120
  allowInsecurePlaintext: true
176
121
  ```
177
122
 
178
- 自签名证书在**首次启用 TLS 时生成一次**,持久化于 `~/.dsh/lan-gateway/tls/`
179
- (`selfsigned.crt` / `selfsigned.key`,0600),之后重启复用同一张证书;
180
- `lan_gateway tls-regenerate` 可随时换发新证书(新密钥)并热重启监听器。
181
-
182
- 或用自己的证书(例如 `/etc/letsencrypt/live/example.com/` 下签发的 PEM):
123
+ 用自己的证书(比如 Let's Encrypt 签发的 PEM):
183
124
 
184
125
  ```yaml
185
126
  - id: dsh-lan-gateway
@@ -190,162 +131,110 @@ lan_gateway disable
190
131
  tlsKeyPath: /etc/letsencrypt/live/example.com/privkey.pem
191
132
  ```
192
133
 
193
- 启用 TLS(或声明受信终止代理)后,登录 cookie 自动带 `Secure`;监听器自身是 HTTPS 时,
194
- 网关响应(登录页 / 重定向 / 拒绝)带 HSTS。自签名证书首次访问会看到浏览器警告,属预期行为。
134
+ 自签名证书在首次启用 TLS 时生成一次,落到 `~/.dsh/lan-gateway/tls/`(`selfsigned.crt` / `selfsigned.key`,0600),之后重启复用。要换新证书用 `lan_gateway tls-regenerate`,它会换掉密钥并热重启监听器。
195
135
 
196
- 默认 `lanCidrs`:`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`169.254.0.0/16`;
197
- IPv6 的 `fe80::/10`(link-local)与 `127.0.0.0/8` / `::1` 归类为 LAN/loopback。
136
+ 监听器自身是 HTTPS 时,网关的响应(登录页 / 重定向 / 拒绝)带 HSTS。
198
137
 
199
- ## 安全模型
138
+ ### 关于 `Secure` cookie
200
139
 
201
- - **来源分级**:仅依据 `socket.remoteAddress`(IPv4-mapped IPv6 会先解包)把请求分为
202
- loopback / lan / internet 三档,绝不信任 `X-Forwarded-For`。**分级本身不授予任何访问**
203
- ——默认每一档都必须出示有效网关会话,否则 302 到 `/__login`。
204
- - **LAN 免密是显式 opt-in**:`lanPasswordless: true` 只让命中 `lanCidrs`(或 loopback)的
205
- 来源跳过**网关自己的登录页**;上游 dsh 若 ≥ 0.1.2-rc.1,插件在进程内中继一条共享上游
206
- 会话(见下),上游授权仍把关每个请求。底座没有浏览器会话认证时该开关**拒绝启用**——
207
- 否则就是把 QVD-2026-57410 原样装回去。
208
- - **共享上游会话中继(dsh ≥ 0.1.2-rc.1)**:dsh 已不再信任“回环 Host”,要求请求出示
209
- HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上
210
- 做浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦以 401 拒绝就丢弃
211
- 这条会话并重新换取。**这仍是“单密码 = 单操作者”**:通过网关登录的人都骑同一条上游
212
- 会话,上游持钥,才是真正的授权主体。
213
- - **登录页**:`/__login` 由网关独占、不转发;密码以 scrypt(每写一次重新加盐)校验,
214
- 登录尝试按来源限流(5 次 / 分钟)。
215
- - **会话 cookie**:`payload.signature` 结构(HMAC-SHA256),携带**撤销 epoch**;
216
- `HttpOnly; SameSite=Strict`,过期即失效。改密 / 清密 / `rotate-secret` 递增 epoch,
217
- 作废全部已签发 cookie **并断开已建立的 WebSocket**,客户端重新登录。清空密码会停止
218
- 监听(网关必须有密码才能运行)。
219
- - **管理面不外泄**:`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发——远程
220
- 访问者无法借网关改写 Host 触及本机 loopback 的配置接口;原生 `/lan-gateway/config`
221
- 仅对回环 Host 且同源的请求应答(本地用户 / 本机能读 `~/.dsh` 的进程)。远程管理走
222
- `lan_gateway` 工具。
223
- - **CSRF / 跨站围栏(HTTP 与 WebSocket)**:因为网关把 Origin 改写回 loopback、会蒙蔽
224
- dsh 自身的 CSRF 防线,网关在改写前对每个转发请求自检:`sec-fetch-site: cross-site`
225
- 直接拒绝;Origin 必须匹配访问者实际使用的网关权威来源;状态变更方法与 WebSocket
226
- 升级请求**必须携带同源 Origin**,否则 403。
227
- - **密码未设置时拒启**:无论来源如何,未设密码一律拒绝监听——杜绝把远程代码执行门户
228
- 开放给任何非本机来源。
229
- - **WebSocket**:`/api` 升级请求同样过登录校验、同源 Origin 校验,再拼接转发给 dsh,
230
- 并纳入会话撤销(epoch 变化即断开)。
231
-
232
- ## 0.5.2:共享会话中继修复
233
-
234
- 0.5.0 / 0.5.1 在 **dsh ≥ 0.1.2-rc.1** 上时,网关自己的登录能过,但每一个转发请求都被上游
235
- 401 拒绝,浏览器只看到:
140
+ 启用 TLS 或声明受信终止代理后,登录 cookie 自动带 `Secure`。但「声明了受信代理」只说明前面有个代理,不说明浏览器到代理这一段是加密的。
236
141
 
237
- ```
238
- dsh web authentication required; reopen the URL printed by dsh web.
239
- ```
142
+ 如果那个代理只做明文鉴权、浏览器以 `http://` 访问(代理再以明文转发回本端口),自动推断会把 `Secure` 加上,而浏览器拒收明文 http 上的 Secure cookie。结果是密码校验通过、cookie 存不下,每次都被弹回 `/__login`,无限循环。这种部署要显式关掉:
240
143
 
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 服务端跑完整换取链路(集成测试注入的是假会话对象,正好绕过了这段)。
144
+ ```yaml
145
+ - id: dsh-lan-gateway
146
+ config:
147
+ enabled: true
148
+ gatewayPort: 8080
149
+ trustedTerminator: nginx
150
+ secureCookies: false # 浏览器 → nginx 是明文 http,不能带 Secure
151
+ ```
247
152
 
248
- 同一失效域还顺带修掉一个启动竞态:`listenerKey` 现在把「中继是否可用」计入重启判据。
249
- 监听器若早于 `connection` 服务启动(因此没有中继),会在服务挂载后自动重启,而不是一直
250
- 匿名转发、状态里却写着 relay active。
153
+ `lan_gateway status` 会如实报出实际生效的属性,以及声明的代理属于 TLS 还是明文入口。设置页里对应「自动 / 始终 Secure / 不加 Secure」三档。
251
154
 
252
- ## 版本兼容(0.5.0 的加载失败与修复)
155
+ 注意 `secureCookies: false` 说的是浏览器到入口那一段是明文,网关登录密码和会话 cookie 会在这一段明文传输。这跟 `allowInsecurePlaintext` 描述的不是同一段链路:后者指代理到网关之间不加密,前者指浏览器到代理之间不加密。只有当代理本身已经对用户完成鉴权、且你能接受这段明文时,才这么配。
253
156
 
254
- 0.5.0 及更早版本装在 **dsh ≥ 0.1.2-rc.1** 上会让整个 plugin tree 起不来:
157
+ ## 安全模型
255
158
 
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
- ```
159
+ - **来源分级只认 `socket.remoteAddress`**(IPv4-mapped IPv6 会先解包),分 loopback / lan / internet 三档,绝不信任 `X-Forwarded-For`。分级本身不授予任何访问,每一档默认都要出示有效网关会话,否则 302 到 `/__login`。
160
+ - **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback 的来源跳过网关自己的登录页;底座 ≥ 0.1.2-rc.1 时上游会话仍把关每个请求。底座没有浏览器会话认证时这个开关拒绝启用,否则等于把 QVD-2026-57410 原样装回去。
161
+ - **共享上游会话中继**(dsh ≥ 0.1.2-rc.1)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的人骑同一条上游会话,持钥的上游才是真正的授权主体。
162
+ - **登录页**。`/__login` 由网关独占、不转发。密码以 scrypt 校验,每写一次重新加盐;登录尝试按来源限流(5 次 / 分钟)。
163
+ - **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch,`HttpOnly; SameSite=Strict`。改密、清密、`rotate-secret` 都会递增 epoch,作废全部已签发 cookie 并断开已建立的 WebSocket,客户端需要重新登录。清空密码会直接停止监听。
164
+ - **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发,远程访问者没法借网关改写 Host 去够到本机 loopback 的配置接口。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
165
+ - **CSRF 围栏**(HTTP 与 WebSocket)。网关把 Origin 改写回 loopback,会蒙蔽 dsh 自身的 CSRF 防线,所以在改写前对每个转发请求自检:`sec-fetch-site: cross-site` 直接拒;Origin 必须匹配访问者实际使用的网关权威来源;状态变更方法与 WebSocket 升级请求必须携带同源 Origin,否则 403。
166
+ - **没设密码就拒绝监听**,跟来源无关。
260
167
 
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
- 的底座上都能加载。
168
+ ## 登录页
267
169
 
268
- 升级:`pnpm add @riceawa/dsh-lan-gateway@0.5.1`(或重新 `pnpm install` 走 link/github 安装)。
170
+ 远程来源打开 `http://<主机>:3081/` 时先看到网关自带的登录表单,输入正确密码后签发会话 cookie 并跳回 `/`。
269
171
 
270
- ## 从 v0.4(及更早)升级
172
+ <p align="center">
173
+ <img src="assets/login-screenshot.webp" alt="网关登录页截图" width="320" />
174
+ </p>
271
175
 
272
- 1. **把底座升级到 dsh ≥ 0.1.2-rc.1**(含 QVD-2026-57410 上游修复)。低于该版本时本插件
273
- 仍可运行,但 `lanPasswordless` 会拒绝启用(fail-closed)。
274
- 2. 若配置里写过 `authRequired: false`:删除它。现在认证恒为必需;LAN 想免密改为显式
275
- `lanPasswordless: true`。
276
- 3. 若以明文(无 TLS)运行且从未设置过密码:升级后 `enable` 会拒绝。请启用 TLS /
277
- 声明 `trustedTerminator` / 显式 `allowInsecurePlaintext: true` 之一,并先
278
- `lan_gateway set-password`。
279
- 4. 曾以“免密 + 伪造 Host”形态暴露过受影响的 dsh 实例:按“可能已失陷”处置——轮换模型 /
280
- 系统凭据,并检查是否有未知登录会话。
176
+ ## UUID shim
281
177
 
282
- 一步到位迁移示例(HTTPS 自签名 + LAN 免密,本机已跑通):
178
+ 网关以 `http://<LAN-IP>:3081` 服务页面,浏览器视其为不安全源,`crypto.randomUUID()`(仅安全源可用)为 `undefined`,于是每次 RPC id 铸造都抛 `crypto.randomUUID is not a function`,工作区打不开。
283
179
 
284
- ```yaml
285
- - id: dsh-lan-gateway
286
- config:
287
- enabled: true
288
- tlsEnabled: true
289
- tlsMode: self-signed
290
- # 证书 SAN 覆盖所有接入方式:回环 / 主机名 / LAN IP / Tailscale IP。
291
- # Tailscale 走 100.64.0.0/10(CGNAT),按默认 lanCidrs 归为 internet,
292
- # 仍需密码登录,不会因 lanPasswordless 而豁免。
293
- tlsSelfSignedHosts: localhost,my-host,192.168.1.20,100.99.1.2
294
- lanPasswordless: true # LAN/loopback 免登录;需 dsh ≥ 0.1.2-rc.1 上游会话底座
295
- ```
180
+ client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomValues()` 的 `randomUUID`(RFC 4122 v4,`getRandomValues` 在所有源都可用)。它在浏览器求值时就执行,早于任何官方代码铸造 id,所以对官方所有调用点(含以后新增的)一律生效,不用改 DSH 源码。安全源和 Node ≥ 19 下是 no-op,不影响任何行为。
296
181
 
297
- 首次以 `https://<主机名|LAN-IP|Tailscale-IP>:3081` 访问会看到自签名证书警告(预期)。
298
- 证书在首次启用 TLS 时生成一次并持久化到 `~/.dsh/lan-gateway/tls/`;若已有一张旧证书,
299
- **必须用 `lan_gateway tls-regenerate` 重新签发**,新的 `tlsSelfSignedHosts` 才会进入 SAN。
182
+ ## 开发
300
183
 
301
- ## 登录页截图(预期)
184
+ ```bash
185
+ pnpm test # 93 项
186
+ pnpm typecheck # tsc 双端(host + client)
187
+ ```
302
188
 
303
- 远程来源打开 `http://<主机>:3081/` 时,先看到网关自带的登录表单(`/__login`),
304
- 输入正确密码后签发会话 cookie 并跳回 `/`。
189
+ ```
190
+ ✓ tests/gateway.test.ts (27) 分类 / HMAC cookie / epoch / 密码状态 / 限流
191
+ ✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
192
+ Secure cookie 属性推断(含 null 清除路径)
193
+ ✓ tests/integration/gateway.test.ts (17) 真实网关端到端:全来源登录 / LAN 豁免 /
194
+ 跨站 403 / 升级拒绝 / cookie 属性 / epoch 撤销 / 会话中继
195
+ ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
196
+ ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
197
+ ✓ tests/tls.test.ts ( 7) 证书持久化 / 重生成 / 自定义证书加载
198
+ ✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
199
+ invalidate 重获取 / 日志播报 / 保住已持有会话
200
+ ✓ tests/settings-card.test.ts ( 6) 设置页字段编解码:三态 auto ↔ false 不可混淆
201
+ ```
305
202
 
306
- <p align="center">
307
- <img src="assets/login-screenshot.webp" alt="网关登录页截图" width="320" />
308
- </p>
203
+ ### 发布
309
204
 
310
- ## UUID shim 说明(v0.2.0 新增)
205
+ 推 `v*` tag 触发 [`.github/workflows/release.yml`](.github/workflows/release.yml):校验 tag 与 `package.json` 版本一致,跑 typecheck 和 test,发 npm,然后建 GitHub Release 并附上 `pnpm pack` 的 tgz。npm 侧走 Trusted Publishing(GitHub OIDC),仓库里不需要 `NPM_TOKEN` secret。`lib/` 被 gitignore,但 `prepack` 会构建,所以发布产物里始终有编译结果。
311
206
 
312
- **问题**:网关以 `http://<LAN-IP>:3081` 服务页面,浏览器视其为不安全源,
313
- `crypto.randomUUID()`(secure-context-only)为 `undefined` → 每次 RPC id 铸造抛
314
- `crypto.randomUUID is not a function` → 打不开工作区。
207
+ 首次启用要在 npm 包页面的 Settings → Trusted Publisher 配一次:
315
208
 
316
- **原理**:client bundle 在**模块级**(一被浏览器求值、早于任何官方代码铸造 id)给
317
- `Crypto` 原型补一个 `crypto.getRandomValues()` 版 `randomUUID`(RFC 4122 v4;
318
- `getRandomValues` 在所有源都可用)。安全源 / Node ≥19 下为 no-op,不影响任何行为。
209
+ | 字段 | 值 |
210
+ | --- | --- |
211
+ | Organization or user | `rice-awa` |
212
+ | Repository | `dsh-lan-gateway` |
213
+ | Workflow filename | `release.yml` |
214
+ | Environment | 留空 |
319
215
 
320
- **覆盖范围**:对官方所有 `crypto.randomUUID()` 调用点(含未来新增)一律生效,
321
- 无需改动 DSH 源码。
216
+ 配好之前 tag 推送会在 `npm publish` 一步以 403 失败(fail-closed,不会留下半个 Release)。改完不用重新打 tag,重跑那次 run 即可。
322
217
 
323
- ## 测试
218
+ 本地手动发布走 checkout 里的 `.npmrc` token(该文件不入库):
324
219
 
325
220
  ```bash
326
- pnpm test
327
- # ✓ tests/gateway.test.ts (27) 分类 / HMAC cookie / epoch / 密码状态 / 限流
328
- # ✓ tests/start-guard.test.ts (12) fail-closed 启动守卫 / 配置路由回环围栏
329
- # ✓ tests/integration/gateway.test.ts (17) 真实网关端到端:全来源登录 / LAN 豁免 /
330
- # 跨站 403 / 升级拒绝 / cookie 属性 / epoch 撤销 / 会话中继
331
- # ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
332
- # ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
333
- # ✓ tests/tls.test.ts ( 7) 证书持久化 / 重生成 / 自定义证书加载
334
- # ✓ tests/upstream-session.test.ts ( 4) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
335
- # invalidate 重获取
221
+ pnpm install --frozen-lockfile
222
+ pnpm typecheck && pnpm test
223
+ npm publish --access public # prepack 自动构建 lib/
224
+ git tag -a v0.5.3 -m "…" && git push origin v0.5.3
225
+ gh release create v0.5.3 --generate-notes ./*.tgz # 可选:Release + tgz 附件
336
226
  ```
337
227
 
338
- ## 安全评估与修复记录
228
+ ## 安全评估
339
229
 
340
- 0.5.0 的默认拒绝模型源自针对 QVD-2026-57410(DSH Web API 的 Host 信任缺陷)的加固,
341
- 相关文档收在 [docs/security/](docs/security/):
230
+ 0.5.0 的默认拒绝模型源自针对 QVD-2026-57410(DSH Web API 的 Host 信任缺陷)的加固,相关文档在 [docs/security/](docs/security/):
342
231
 
343
- - [LAN 网关安全评估](docs/security/SECURITY-AUDIT.md)——0.4.0 时代 F1–F5 审计快照与 13 个
344
- 隔离观察(顶部标注 0.5.0 的修复状态)。
345
- - [QVD-2026-57410 修复方案](docs/security/qvd-2026-57410-fix-plan.md)——方案全文 + §15
346
- 实施状态(0.5.0 落地差异)。
347
- - [上游研究](docs/security/qvd-2026-57410-research.md)——公开通告 / 上游提交与版本核对。
232
+ - [LAN 网关安全评估](docs/security/SECURITY-AUDIT.md):0.4.0 时代的 F1–F5 审计快照与 13 个隔离观察,顶部标注了 0.5.0 的修复状态。
233
+ - [QVD-2026-57410 修复方案](docs/security/qvd-2026-57410-fix-plan.md):方案全文 + §15 实施状态。
234
+ - [上游研究](docs/security/qvd-2026-57410-research.md):公开通告、上游提交与版本核对。
348
235
 
349
236
  ## 许可
350
237
 
351
238
  [MIT](./LICENSE)
239
+
240
+ 各版本改了什么见 [CHANGELOG.md](CHANGELOG.md)。
package/lib/client.js CHANGED
@@ -50,6 +50,11 @@ window.__ModuleLoader__.load({
50
50
  "hint.allowInsecurePlaintext": "危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输",
51
51
  "field.trustedTerminator": "受信 TLS 终止代理",
52
52
  "hint.trustedTerminator": "可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明",
53
+ "field.secureCookies": "会话 cookie 的 Secure 属性",
54
+ "hint.secureCookies": "自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页",
55
+ "opt.auto": "自动",
56
+ "opt.true": "始终 Secure",
57
+ "opt.false": "不加 Secure(明文浏览器入口)",
53
58
  "field.cookieMaxAgeDays": "会话有效期(天)",
54
59
  "hint.cookieMaxAgeDays": "登录 cookie 的存活天数(默认 7)",
55
60
  "field.tlsEnabled": "启用 TLS(HTTPS)",
@@ -96,6 +101,11 @@ window.__ModuleLoader__.load({
96
101
  "hint.allowInsecurePlaintext": "Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear",
97
102
  "field.trustedTerminator": "Trusted TLS terminator",
98
103
  "hint.trustedTerminator": "Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared",
104
+ "field.secureCookies": "Session cookie Secure attribute",
105
+ "hint.secureCookies": "Auto = Secure when TLS or a trusted terminator is declared. Set false when the trusted proxy only authenticates over plaintext and browsers reach it over http — otherwise browsers drop the Secure cookie and every login bounces back to the login page",
106
+ "opt.auto": "Auto",
107
+ "opt.true": "Always Secure",
108
+ "opt.false": "No Secure (plaintext browser ingress)",
99
109
  "field.cookieMaxAgeDays": "Session lifetime (days)",
100
110
  "hint.cookieMaxAgeDays": "Login cookie lifetime (default 7)",
101
111
  "field.tlsEnabled": "Enable TLS (HTTPS)",
@@ -115,6 +125,12 @@ window.__ModuleLoader__.load({
115
125
  function labels() {
116
126
  return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? LABELS.zh : LABELS.en;
117
127
  }
128
+ /** The three states of a tri-state field, in display order. */
129
+ const TRISTATE_OPTIONS = [
130
+ "auto",
131
+ "true",
132
+ "false"
133
+ ];
118
134
  const FIELDS = [
119
135
  {
120
136
  field: "enabled",
@@ -176,6 +192,10 @@ window.__ModuleLoader__.load({
176
192
  field: "trustedTerminator",
177
193
  kind: "text",
178
194
  optional: true
195
+ },
196
+ {
197
+ field: "secureCookies",
198
+ kind: "tristate"
179
199
  }
180
200
  ];
181
201
  function formatValue(def, value) {
@@ -184,6 +204,7 @@ window.__ModuleLoader__.load({
184
204
  case "number": return typeof value === "number" ? String(value) : "";
185
205
  case "cidrs": return Array.isArray(value) ? value.join(", ") : "";
186
206
  case "select": return typeof value === "string" ? value : def.options?.[0] ?? "";
207
+ case "tristate": return value === true ? "true" : value === false ? "false" : "auto";
187
208
  case "text": return typeof value === "string" ? value : "";
188
209
  }
189
210
  }
@@ -219,6 +240,17 @@ window.__ModuleLoader__.load({
219
240
  kind: "set",
220
241
  value: trimmed
221
242
  } : void 0;
243
+ case "tristate":
244
+ if (trimmed === "auto") return { kind: "clear" };
245
+ if (trimmed === "true") return {
246
+ kind: "set",
247
+ value: true
248
+ };
249
+ if (trimmed === "false") return {
250
+ kind: "set",
251
+ value: false
252
+ };
253
+ return;
222
254
  case "text": return trimmed === "" ? def.optional ? { kind: "clear" } : void 0 : {
223
255
  kind: "set",
224
256
  value: trimmed
@@ -348,38 +380,42 @@ window.__ModuleLoader__.load({
348
380
  children: hint
349
381
  })]
350
382
  });
351
- case "select": return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
352
- style: styles.field,
353
- children: [
354
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
355
- style: styles.label,
356
- htmlFor: `lan-gw-${field}`,
357
- children: label
358
- }),
359
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
360
- id: `lan-gw-${field}`,
361
- style: styles.input,
362
- value: text,
363
- disabled: saving,
364
- onChange: (e) => stage(field, e.target.value),
365
- children: def.options?.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
366
- value: option,
367
- children: option
368
- }, option))
369
- }),
370
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
371
- style: styles.hint,
372
- children: hint
373
- }),
374
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
375
- type: "button",
376
- style: styles.reset,
377
- disabled: saving || !drafts[field],
378
- onClick: () => resetField(def),
379
- children: t.reset
380
- })
381
- ]
382
- });
383
+ case "select":
384
+ case "tristate": {
385
+ const options = def.kind === "tristate" ? TRISTATE_OPTIONS : def.options ?? [];
386
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
387
+ style: styles.field,
388
+ children: [
389
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
390
+ style: styles.label,
391
+ htmlFor: `lan-gw-${field}`,
392
+ children: label
393
+ }),
394
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
395
+ id: `lan-gw-${field}`,
396
+ style: styles.input,
397
+ value: text,
398
+ disabled: saving,
399
+ onChange: (e) => stage(field, e.target.value),
400
+ children: options.map((option) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
401
+ value: option,
402
+ children: def.kind === "tristate" ? t[`opt.${option}`] : option
403
+ }, option))
404
+ }),
405
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
406
+ style: styles.hint,
407
+ children: hint
408
+ }),
409
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
410
+ type: "button",
411
+ style: styles.reset,
412
+ disabled: saving || !drafts[field],
413
+ onClick: () => resetField(def),
414
+ children: t.reset
415
+ })
416
+ ]
417
+ });
418
+ }
383
419
  default: return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
384
420
  style: styles.field,
385
421
  children: [