@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5
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 +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/README.md
CHANGED
|
@@ -6,35 +6,35 @@
|
|
|
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.5-2b7fff?style=flat-square" alt="version 0.5.5" />
|
|
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>
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
|
-
`dsh web`
|
|
15
|
+
`dsh web` 明确拒绝 `--host 0.0.0.0`,以免把远程代码执行暴露到网络。本插件的做法是让 dsh 继续只绑 `127.0.0.1`,另起一个反向代理监听未指定地址(双栈,IPv4 与 IPv6 客户端都可接入),转发到 loopback 端口并改写 `Host` / `Origin`。
|
|
16
16
|
|
|
17
|
-
默认拒绝:loopback、LAN
|
|
17
|
+
默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页取得 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless`,默认关闭。底座为 dsh ≥ 0.1.2-rc.1 时(含 QVD-2026-57410 的上游修复),网关在进程内中继一条共享上游会话,上游自身的授权仍然把关每个请求,网关只决定谁可以使用这条会话。
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
插件另外提供两项功能:
|
|
20
20
|
|
|
21
|
-
- **不安全源 UUID shim**:网关以纯 HTTP 的局域网地址服务页面,浏览器视其为不安全源,不提供 `crypto.randomUUID`。client bundle 在页面加载早期补一个基于 `getRandomValues`
|
|
22
|
-
- **TLS
|
|
21
|
+
- **不安全源 UUID shim**:网关以纯 HTTP 的局域网地址服务页面,浏览器视其为不安全源,不提供 `crypto.randomUUID`。client bundle 在页面加载早期补一个基于 `getRandomValues` 的实现,工作区才能正常打开。
|
|
22
|
+
- **TLS**:自动生成并持久化的自签名证书,或者挂载自行签发的 PEM。自签名证书首次访问会有浏览器警告,属预期行为。
|
|
23
23
|
|
|
24
24
|
## 安装
|
|
25
25
|
|
|
26
|
-
已发布到 npm
|
|
26
|
+
已发布到 npm,安装的是预构建产物,不需要 `allowBuilds` 授权。可将下面这段话交给你的 agent:
|
|
27
27
|
|
|
28
28
|
> 帮我安装 dsh 插件 `@riceawa/dsh-lan-gateway`,遵循
|
|
29
29
|
> `https://github.com/rice-awa/dsh-lan-gateway/blob/main/INSTALL.md`
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
也可以手动执行:
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
dsh plugin --profile web add @riceawa/dsh-lan-gateway
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
`dsh plugin ... add` 会把参数转发给 profile 目录里的 pnpm。npm 包自带 `lib/`,不会触发 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED
|
|
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完整安装流程)。
|
|
38
38
|
|
|
39
39
|
从源码构建:
|
|
40
40
|
|
|
@@ -44,12 +44,12 @@ cd dsh-lan-gateway
|
|
|
44
44
|
pnpm install
|
|
45
45
|
pnpm build # host → lib/index.js
|
|
46
46
|
pnpm build:client # client → lib/client.js
|
|
47
|
-
pnpm test #
|
|
47
|
+
pnpm test # 117 项
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
仓库里还有一个 [lan-gateway](skills/lan-gateway.md)
|
|
50
|
+
仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
如需在手机 / 平板上访问,可另外安装 [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile) 做移动端 UI 适配:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
55
|
dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile
|
|
@@ -59,23 +59,23 @@ dsh plugin --profile web add github:mexiaosqwq/dsh-web-mobile
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
lan_gateway enable # 开启网关(需先满足启动条件,否则给出迁移文案)
|
|
62
|
-
lan_gateway status # 端口 / 目标 / 密码 / 会话 epoch / 中继状态 / 入口加密方式 / 上次错误
|
|
62
|
+
lan_gateway status # 端口 / 目标 / 密码 / 会话 epoch / 已登出会话数 / 中继状态 / 入口加密方式 / 上次错误
|
|
63
63
|
lan_gateway set-password # 设置登录密码(≥8 位,改动会让所有已签发会话立即失效)
|
|
64
64
|
lan_gateway rotate-secret # 轮换会话密钥,作废全部登录 cookie 与已建立的 WebSocket
|
|
65
65
|
lan_gateway tls-regenerate # 换发自签名证书(tlsMode=self-signed 时)
|
|
66
66
|
lan_gateway disable # 关闭
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
`lan_gateway`
|
|
69
|
+
`lan_gateway` 是模型可调用的工具,上述命令无需手动执行。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会写入任何配置文件。
|
|
70
70
|
|
|
71
71
|
## 配置
|
|
72
72
|
|
|
73
|
-
所有可调项都暴露为 `lan-gateway` 用户设置命名空间。打开 **DSH 的 Settings → Plugins → 可配置插件**,展开「LAN
|
|
73
|
+
所有可调项都暴露为 `lan-gateway` 用户设置命名空间。打开 **DSH 的 Settings → Plugins → 可配置插件**,展开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。下表既是卡片字段,也是配置键:
|
|
74
74
|
|
|
75
75
|
| 键 | 默认值 | 说明 |
|
|
76
76
|
| --- | --- | --- |
|
|
77
77
|
| `enabled` | `false` | 是否在启动时监听网络端口 |
|
|
78
|
-
| `gatewayPort` | `3081` |
|
|
78
|
+
| `gatewayPort` | `3081` | 网关监听端口(未指定地址 / 双栈) |
|
|
79
79
|
| `dshTargetPort` | 跟随 `ctx.webServer.port` | 转发到的 dsh loopback 端口 |
|
|
80
80
|
| `lanCidrs` | RFC1918 + link-local(见下) | 视为 LAN 的网段,仅在 `lanPasswordless` 开启时用作豁免匹配集 |
|
|
81
81
|
| `lanPasswordless` | `false` | LAN/loopback 来源跳过网关登录页(上游会话中继仍把关) |
|
|
@@ -83,12 +83,12 @@ lan_gateway disable # 关闭
|
|
|
83
83
|
| `cookieName` | `dsh_gw_auth` | 会话 cookie 名,不进卡片 |
|
|
84
84
|
| `tlsEnabled` | `false` | 是否以 HTTPS 提供服务 |
|
|
85
85
|
| `tlsMode` | `self-signed` | `self-signed` 自动生成 / `custom` 用自己的证书 |
|
|
86
|
-
| `tlsSelfSignedHosts` | `localhost` | 自签名证书的 SAN(逗号分隔的域名 / IP
|
|
86
|
+
| `tlsSelfSignedHosts` | `localhost` | 自签名证书的 SAN(逗号分隔的域名 / IP)。仅影响下次换发,见下「证书有效期」 |
|
|
87
87
|
| `tlsCertPath` | — | `custom` 模式:PEM 证书(或证书链)绝对路径 |
|
|
88
88
|
| `tlsKeyPath` | — | `custom` 模式:PEM 私钥绝对路径 |
|
|
89
|
-
| `tlsCertMaxAgeDays` | `825` |
|
|
89
|
+
| `tlsCertMaxAgeDays` | `825` | 自签名证书有效期(天),见下「证书有效期」 |
|
|
90
90
|
| `allowInsecurePlaintext` | `false` | 允许明文 HTTP 监听(见下「入口加密」) |
|
|
91
|
-
| `trustedTerminator` | — | 声明一个受信 TLS 终止代理标识,视为加密入口(如 `nginx
|
|
91
|
+
| `trustedTerminator` | — | 声明一个受信 TLS 终止代理标识,视为加密入口(如 `nginx`)。注意登录限流的键,见下「登录限流」 |
|
|
92
92
|
| `secureCookies` | 自动 | 会话 cookie 的 `Secure` 属性显式开关,默认按 `tlsEnabled` 或 `trustedTerminator` 推断(见下) |
|
|
93
93
|
|
|
94
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。
|
|
@@ -97,9 +97,9 @@ lan_gateway disable # 关闭
|
|
|
97
97
|
|
|
98
98
|
### 入口加密
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
网关默认拒绝纯明文监听,以下三种方式任选其一方可启动:
|
|
101
101
|
|
|
102
|
-
1. 启用 TLS,`tlsEnabled: true`。推荐,自签名或 custom
|
|
102
|
+
1. 启用 TLS,`tlsEnabled: true`。推荐,自签名或 custom 证书均可。
|
|
103
103
|
2. 声明由可信反向代理终止 TLS:
|
|
104
104
|
|
|
105
105
|
```yaml
|
|
@@ -120,7 +120,7 @@ lan_gateway disable # 关闭
|
|
|
120
120
|
allowInsecurePlaintext: true
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
使用自有证书(例如 Let's Encrypt 签发的 PEM):
|
|
124
124
|
|
|
125
125
|
```yaml
|
|
126
126
|
- id: dsh-lan-gateway
|
|
@@ -131,15 +131,21 @@ lan_gateway disable # 关闭
|
|
|
131
131
|
tlsKeyPath: /etc/letsencrypt/live/example.com/privkey.pem
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
自签名证书在首次启用 TLS
|
|
134
|
+
自签名证书在首次启用 TLS 时生成一次,写入 `~/.dsh/lan-gateway/tls/`(`selfsigned.crt` / `selfsigned.key`,0600),之后重启复用。更换证书使用 `lan_gateway tls-regenerate`,它会换掉密钥并热重启监听器。已到期的证书在启动时自动换发并记一条 warning。修改 `tlsSelfSignedHosts` 或 `tlsCertMaxAgeDays` 只影响**下次换发**:已有证书沿用至到期,不会在保存时被悄悄替换。
|
|
135
|
+
|
|
136
|
+
### 证书有效期
|
|
137
|
+
|
|
138
|
+
默认有效期 825 天。常见的 398 天上限只约束链到平台预装根 CA 的证书,Apple 明确豁免「由用户或管理员添加的根 CA」签发的证书([support.apple.com/en-us/102028](https://support.apple.com/en-us/102028)),而自签证书必然是这样一条根——没有任何系统预装它。825 天则是 Apple 对 TLS 服务器证书给出的上限([support.apple.com/en-us/103769](https://support.apple.com/en-us/103769)),默认值取在该上限之内。
|
|
139
|
+
|
|
140
|
+
自签证书要么被点击通过、要么被手工信任,因此缩短有效期没有收益,每次到期反而要重新信任一次。需要更短的窗口时用 `tlsCertMaxAgeDays` 自行调整。
|
|
135
141
|
|
|
136
142
|
监听器自身是 HTTPS 时,网关的响应(登录页 / 重定向 / 拒绝)带 HSTS。
|
|
137
143
|
|
|
138
144
|
### 关于 `Secure` cookie
|
|
139
145
|
|
|
140
|
-
启用 TLS 或声明受信终止代理后,登录 cookie 自动带 `Secure
|
|
146
|
+
启用 TLS 或声明受信终止代理后,登录 cookie 自动带 `Secure`。但「声明了受信代理」只说明网关前方存在一个代理,不说明浏览器到代理这一段是加密的。
|
|
141
147
|
|
|
142
|
-
|
|
148
|
+
如果该代理只做明文鉴权、浏览器以 `http://` 访问(代理再以明文转发回本端口),自动推断会把 `Secure` 加上,而浏览器拒收明文 http 上的 Secure cookie。结果是密码校验通过、cookie 无法保存,每次都被重定向回 `/__login`,无限循环。这种部署需要显式关闭:
|
|
143
149
|
|
|
144
150
|
```yaml
|
|
145
151
|
- id: dsh-lan-gateway
|
|
@@ -150,24 +156,30 @@ lan_gateway disable # 关闭
|
|
|
150
156
|
secureCookies: false # 浏览器 → nginx 是明文 http,不能带 Secure
|
|
151
157
|
```
|
|
152
158
|
|
|
153
|
-
`lan_gateway status`
|
|
159
|
+
`lan_gateway status` 会如实报告实际生效的属性,以及声明的代理属于 TLS 还是明文入口。设置页里对应「自动 / 始终 Secure / 不加 Secure」三档。
|
|
160
|
+
|
|
161
|
+
### 登录限流
|
|
162
|
+
|
|
163
|
+
登录尝试以 TCP 源地址为键限流,每分钟 5 次。`X-Forwarded-For` 与 `Forwarded` 在转发前一律删除,也不参与限流取键——它们可由客户端任意伪造,用它们作为键等于把限流关掉。
|
|
164
|
+
|
|
165
|
+
代价是声明 `trustedTerminator` 之后,所有浏览器请求都来自代理这一个地址,5 次/分钟变成**整个部署共享**的额度,而非每客户端 5 次。这一耦合没有便宜的修法:要么接受它(把上限视为抗暴力破解的下限而非每用户配额),要么让代理直连、由网关自己面对客户端。当前不接受按 `X-Forwarded-For` 分桶。
|
|
154
166
|
|
|
155
|
-
注意 `secureCookies: false`
|
|
167
|
+
注意 `secureCookies: false` 说的是浏览器到入口这一段是明文,网关登录密码和会话 cookie 会在这一段明文传输。这与 `allowInsecurePlaintext` 描述的不是同一段链路:后者指代理到网关之间不加密,前者指浏览器到代理之间不加密。只有当代理本身已经对用户完成鉴权、且可以接受这段明文时,才应这样配置。
|
|
156
168
|
|
|
157
169
|
## 安全模型
|
|
158
170
|
|
|
159
171
|
- **来源分级只认 `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
|
|
161
|
-
- **共享上游会话中继**(dsh ≥ 0.1.2-rc.1)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 =
|
|
172
|
+
- **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback 的来源跳过网关自己的登录页;底座 ≥ 0.1.2-rc.1 时上游会话仍把关每个请求。底座没有浏览器会话认证时这个开关拒绝启用,否则等同于把 QVD-2026-57410 原样恢复。
|
|
173
|
+
- **共享上游会话中继**(dsh ≥ 0.1.2-rc.1)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的用户共用同一条上游会话,持钥的上游才是真正的授权主体。
|
|
162
174
|
- **登录页**。`/__login` 由网关独占、不转发。密码以 scrypt 校验,每写一次重新加盐;登录尝试按来源限流(5 次 / 分钟)。
|
|
163
|
-
- **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch,`HttpOnly; SameSite=Strict
|
|
164
|
-
- **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403
|
|
175
|
+
- **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch 与逐会话 id,`HttpOnly; SameSite=Strict`。登出撤销的是当前这条会话:它建立的 WebSocket 一并断开,其他设备不受影响。改密、清密、`rotate-secret` 递增 epoch,作废全部已签发 cookie 并断开全部已建立的 WebSocket,客户端需要重新登录。清空密码会直接停止监听。
|
|
176
|
+
- **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发,远程访问者无法借网关改写 Host 触及本机 loopback 的配置接口。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
|
|
165
177
|
- **CSRF 围栏**(HTTP 与 WebSocket)。网关把 Origin 改写回 loopback,会蒙蔽 dsh 自身的 CSRF 防线,所以在改写前对每个转发请求自检:`sec-fetch-site: cross-site` 直接拒;Origin 必须匹配访问者实际使用的网关权威来源;状态变更方法与 WebSocket 升级请求必须携带同源 Origin,否则 403。
|
|
166
|
-
-
|
|
178
|
+
- **未设置密码时拒绝监听**,与来源无关。
|
|
167
179
|
|
|
168
180
|
## 登录页
|
|
169
181
|
|
|
170
|
-
远程来源打开 `http://<主机>:3081/` 时先看到网关自带的登录表单,输入正确密码后签发会话 cookie
|
|
182
|
+
远程来源打开 `http://<主机>:3081/` 时先看到网关自带的登录表单,输入正确密码后签发会话 cookie 并跳转回 `/`。
|
|
171
183
|
|
|
172
184
|
<p align="center">
|
|
173
185
|
<img src="assets/login-screenshot.webp" alt="网关登录页截图" width="320" />
|
|
@@ -175,26 +187,26 @@ lan_gateway disable # 关闭
|
|
|
175
187
|
|
|
176
188
|
## UUID shim
|
|
177
189
|
|
|
178
|
-
网关以 `http://<LAN-IP>:3081` 服务页面,浏览器视其为不安全源,`crypto.randomUUID()`(仅安全源可用)为 `undefined`,于是每次 RPC id 铸造都抛 `crypto.randomUUID is not a function
|
|
190
|
+
网关以 `http://<LAN-IP>:3081` 服务页面,浏览器视其为不安全源,`crypto.randomUUID()`(仅安全源可用)为 `undefined`,于是每次 RPC id 铸造都抛 `crypto.randomUUID is not a function`,工作区无法打开。
|
|
179
191
|
|
|
180
|
-
client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomValues()` 的 `randomUUID`(RFC 4122 v4,`getRandomValues` 在所有源都可用)。它在浏览器求值时就执行,早于任何官方代码铸造 id
|
|
192
|
+
client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomValues()` 的 `randomUUID`(RFC 4122 v4,`getRandomValues` 在所有源都可用)。它在浏览器求值时就执行,早于任何官方代码铸造 id,所以对官方所有调用点(含以后新增的)一律生效,无需修改 DSH 源码。安全源和 Node ≥ 19 下是 no-op,不影响任何行为。
|
|
181
193
|
|
|
182
194
|
## 开发
|
|
183
195
|
|
|
184
196
|
```bash
|
|
185
|
-
pnpm test #
|
|
197
|
+
pnpm test # 117 项
|
|
186
198
|
pnpm typecheck # tsc 双端(host + client)
|
|
187
199
|
```
|
|
188
200
|
|
|
189
201
|
```
|
|
190
|
-
✓ tests/gateway.test.ts (
|
|
202
|
+
✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
|
|
191
203
|
✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
|
|
192
204
|
Secure cookie 属性推断(含 null 清除路径)
|
|
193
|
-
✓ tests/integration/gateway.test.ts (
|
|
194
|
-
|
|
205
|
+
✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
|
|
206
|
+
cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
|
|
195
207
|
✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
|
|
196
208
|
✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
|
|
197
|
-
✓ tests/tls.test.ts (
|
|
209
|
+
✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
|
|
198
210
|
✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
|
|
199
211
|
invalidate 重获取 / 日志播报 / 保住已持有会话
|
|
200
212
|
✓ tests/settings-card.test.ts ( 6) 设置页字段编解码:三态 auto ↔ false 不可混淆
|
|
@@ -221,8 +233,8 @@ pnpm typecheck # tsc 双端(host + client)
|
|
|
221
233
|
pnpm install --frozen-lockfile
|
|
222
234
|
pnpm typecheck && pnpm test
|
|
223
235
|
npm publish --access public # prepack 自动构建 lib/
|
|
224
|
-
git tag -a v0.5.
|
|
225
|
-
gh release create v0.5.
|
|
236
|
+
git tag -a v0.5.5 -m "…" && git push origin v0.5.5
|
|
237
|
+
gh release create v0.5.5 --generate-notes ./*.tgz # 可选:Release + tgz 附件
|
|
226
238
|
```
|
|
227
239
|
|
|
228
240
|
## 安全评估
|
|
@@ -232,6 +244,7 @@ gh release create v0.5.3 --generate-notes ./*.tgz # 可选:Release + tgz 附
|
|
|
232
244
|
- [LAN 网关安全评估](docs/security/SECURITY-AUDIT.md):0.4.0 时代的 F1–F5 审计快照与 13 个隔离观察,顶部标注了 0.5.0 的修复状态。
|
|
233
245
|
- [QVD-2026-57410 修复方案](docs/security/qvd-2026-57410-fix-plan.md):方案全文 + §15 实施状态。
|
|
234
246
|
- [上游研究](docs/security/qvd-2026-57410-research.md):公开通告、上游提交与版本核对。
|
|
247
|
+
- [0.5.4 复审与修复清单](docs/security/audit-2026-09-19-fix-list.md):0.5.3 的实现细节复审(G1–G12)与修复记录。
|
|
235
248
|
|
|
236
249
|
## 许可
|
|
237
250
|
|
package/lib/client.js
CHANGED
|
@@ -6,131 +6,19 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
8
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
|
-
//#region src/
|
|
10
|
-
/**
|
|
11
|
-
* The lan-gateway settings card shown in the official DSH Settings → Plugins
|
|
12
|
-
* page (the `settings.plugin.item` slot).
|
|
13
|
-
*
|
|
14
|
-
* ModLens-style: the card carries NO injected services. It reads and writes
|
|
15
|
-
* the loopback-only `/lan-gateway/config` host route (the browser never sees
|
|
16
|
-
* the settings seam or any secret), so the client bundle's only dependency is
|
|
17
|
-
* the `slots` service that every plugin already has.
|
|
18
|
-
*
|
|
19
|
-
* @module @riceawa/dsh-lan-gateway/client/card
|
|
20
|
-
*/
|
|
21
|
-
const LABELS = {
|
|
22
|
-
zh: {
|
|
23
|
-
title: "LAN 网关",
|
|
24
|
-
description: "远程访问开关、端口、TLS 证书、受信网段等网关设置",
|
|
25
|
-
unsaved: "未保存",
|
|
26
|
-
save: "保存",
|
|
27
|
-
saving: "保存中…",
|
|
28
|
-
discard: "放弃",
|
|
29
|
-
reset: "重置",
|
|
30
|
-
overridden: "已覆盖",
|
|
31
|
-
readOnly: "网关设置当前不可用(读不到配置路由)。",
|
|
32
|
-
saveFailed: "保存未生效,请检查输入后重试。",
|
|
33
|
-
loadFailed: "加载网关配置失败。",
|
|
34
|
-
emptyMeansClear: "留空 = 使用默认",
|
|
35
|
-
running: "运行中",
|
|
36
|
-
stopped: "已停止",
|
|
37
|
-
tls: "TLS",
|
|
38
|
-
lastError: "上次错误",
|
|
39
|
-
"field.enabled": "启用网关",
|
|
40
|
-
"hint.enabled": "启动时监听 0.0.0.0 网关端口",
|
|
41
|
-
"field.gatewayPort": "网关端口",
|
|
42
|
-
"hint.gatewayPort": "绑定到 0.0.0.0 的监听端口(默认 3081)",
|
|
43
|
-
"field.dshTargetPort": "dsh 目标端口",
|
|
44
|
-
"hint.dshTargetPort": "留空则自动跟随 dsh web 端口(默认 3080)",
|
|
45
|
-
"field.lanCidrs": "免密 LAN 网段",
|
|
46
|
-
"hint.lanCidrs": "逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16",
|
|
47
|
-
"field.lanPasswordless": "LAN 免登录",
|
|
48
|
-
"hint.lanPasswordless": "LAN/回环来源跳过网关登录页,但仍共用同一上游会话(需 dsh ≥ 0.1.2)",
|
|
49
|
-
"field.allowInsecurePlaintext": "允许明文 HTTP",
|
|
50
|
-
"hint.allowInsecurePlaintext": "危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输",
|
|
51
|
-
"field.trustedTerminator": "受信 TLS 终止代理",
|
|
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(明文浏览器入口)",
|
|
58
|
-
"field.cookieMaxAgeDays": "会话有效期(天)",
|
|
59
|
-
"hint.cookieMaxAgeDays": "登录 cookie 的存活天数(默认 7)",
|
|
60
|
-
"field.tlsEnabled": "启用 TLS(HTTPS)",
|
|
61
|
-
"hint.tlsEnabled": "以 HTTPS 提供网关服务",
|
|
62
|
-
"field.tlsMode": "证书来源",
|
|
63
|
-
"hint.tlsMode": "self-signed = 自动生成自签名证书;custom = 使用自己的证书",
|
|
64
|
-
"field.tlsSelfSignedHosts": "自签名证书域名/IP",
|
|
65
|
-
"hint.tlsSelfSignedHosts": "逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5",
|
|
66
|
-
"field.tlsCertPath": "证书文件路径(custom)",
|
|
67
|
-
"hint.tlsCertPath": "PEM 格式证书(或证书链)的绝对路径",
|
|
68
|
-
"field.tlsKeyPath": "私钥文件路径(custom)",
|
|
69
|
-
"hint.tlsKeyPath": "与证书配套的 PEM 私钥绝对路径",
|
|
70
|
-
"field.tlsCertMaxAgeDays": "自签名证书有效期(天)",
|
|
71
|
-
"hint.tlsCertMaxAgeDays": "默认 825(约 27 个月)"
|
|
72
|
-
},
|
|
73
|
-
en: {
|
|
74
|
-
title: "LAN Gateway",
|
|
75
|
-
description: "Remote-access switch, port, TLS certificate, trusted CIDRs and more",
|
|
76
|
-
unsaved: "Unsaved",
|
|
77
|
-
save: "Save",
|
|
78
|
-
saving: "Saving…",
|
|
79
|
-
discard: "Discard",
|
|
80
|
-
reset: "Reset",
|
|
81
|
-
overridden: "overridden",
|
|
82
|
-
readOnly: "Gateway settings unavailable (config route unreachable).",
|
|
83
|
-
saveFailed: "The save did not land — check the inputs and retry.",
|
|
84
|
-
loadFailed: "Failed to load gateway configuration.",
|
|
85
|
-
emptyMeansClear: "Empty = default",
|
|
86
|
-
running: "Running",
|
|
87
|
-
stopped: "Stopped",
|
|
88
|
-
tls: "TLS",
|
|
89
|
-
lastError: "Last error",
|
|
90
|
-
"field.enabled": "Enable gateway",
|
|
91
|
-
"hint.enabled": "Listen on the gateway port at boot",
|
|
92
|
-
"field.gatewayPort": "Gateway port",
|
|
93
|
-
"hint.gatewayPort": "Port bound on 0.0.0.0 (default 3081)",
|
|
94
|
-
"field.dshTargetPort": "dsh target port",
|
|
95
|
-
"hint.dshTargetPort": "Leave empty to follow the dsh web port (default 3080)",
|
|
96
|
-
"field.lanCidrs": "Password-free LAN CIDRs",
|
|
97
|
-
"hint.lanCidrs": "Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16",
|
|
98
|
-
"field.lanPasswordless": "LAN skip login",
|
|
99
|
-
"hint.lanPasswordless": "LAN/loopback sources skip the gateway login page but still ride one shared upstream session (needs dsh >= 0.1.2)",
|
|
100
|
-
"field.allowInsecurePlaintext": "Allow plaintext HTTP",
|
|
101
|
-
"hint.allowInsecurePlaintext": "Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear",
|
|
102
|
-
"field.trustedTerminator": "Trusted TLS terminator",
|
|
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)",
|
|
109
|
-
"field.cookieMaxAgeDays": "Session lifetime (days)",
|
|
110
|
-
"hint.cookieMaxAgeDays": "Login cookie lifetime (default 7)",
|
|
111
|
-
"field.tlsEnabled": "Enable TLS (HTTPS)",
|
|
112
|
-
"hint.tlsEnabled": "Serve the gateway over HTTPS",
|
|
113
|
-
"field.tlsMode": "Certificate source",
|
|
114
|
-
"hint.tlsMode": "self-signed = auto-generated certificate; custom = your own files",
|
|
115
|
-
"field.tlsSelfSignedHosts": "Self-signed hosts (SANs)",
|
|
116
|
-
"hint.tlsSelfSignedHosts": "Comma separated DNS/IP names, e.g. localhost, 192.168.1.5",
|
|
117
|
-
"field.tlsCertPath": "Certificate path (custom)",
|
|
118
|
-
"hint.tlsCertPath": "Absolute path to a PEM certificate (or chain)",
|
|
119
|
-
"field.tlsKeyPath": "Private key path (custom)",
|
|
120
|
-
"hint.tlsKeyPath": "Absolute path to the matching PEM private key",
|
|
121
|
-
"field.tlsCertMaxAgeDays": "Self-signed validity (days)",
|
|
122
|
-
"hint.tlsCertMaxAgeDays": "Default 825 (about 27 months)"
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
function labels() {
|
|
126
|
-
return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? LABELS.zh : LABELS.en;
|
|
127
|
-
}
|
|
9
|
+
//#region src/config-fields.ts
|
|
128
10
|
/** The three states of a tri-state field, in display order. */
|
|
129
11
|
const TRISTATE_OPTIONS = [
|
|
130
12
|
"auto",
|
|
131
13
|
"true",
|
|
132
14
|
"false"
|
|
133
15
|
];
|
|
16
|
+
/**
|
|
17
|
+
* The editable settings, in display order. Adding a config key means adding it
|
|
18
|
+
* here (the host whitelist and the card's controls both follow), to the
|
|
19
|
+
* `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
|
|
20
|
+
* behavior.
|
|
21
|
+
*/
|
|
134
22
|
const FIELDS = [
|
|
135
23
|
{
|
|
136
24
|
field: "enabled",
|
|
@@ -198,6 +86,9 @@ window.__ModuleLoader__.load({
|
|
|
198
86
|
kind: "tristate"
|
|
199
87
|
}
|
|
200
88
|
];
|
|
89
|
+
new Set(FIELDS.map((def) => def.field));
|
|
90
|
+
new Set(FIELDS.filter((def) => def.optional === true).map((def) => def.field));
|
|
91
|
+
/** Render a stored value as draft text. */
|
|
201
92
|
function formatValue(def, value) {
|
|
202
93
|
switch (def.kind) {
|
|
203
94
|
case "boolean": return value === true ? "true" : "false";
|
|
@@ -257,6 +148,124 @@ window.__ModuleLoader__.load({
|
|
|
257
148
|
};
|
|
258
149
|
}
|
|
259
150
|
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/client/lan-gateway-card.tsx
|
|
153
|
+
/**
|
|
154
|
+
* The lan-gateway settings card shown in the official DSH Settings → Plugins
|
|
155
|
+
* page (the `settings.plugin.item` slot).
|
|
156
|
+
*
|
|
157
|
+
* ModLens-style: the card carries NO injected services. It reads and writes
|
|
158
|
+
* the loopback-only `/lan-gateway/config` host route (the browser never sees
|
|
159
|
+
* the settings seam or any secret), so the client bundle's only dependency is
|
|
160
|
+
* the `slots` service that every plugin already has.
|
|
161
|
+
*
|
|
162
|
+
* @module @riceawa/dsh-lan-gateway/client/card
|
|
163
|
+
*/
|
|
164
|
+
const LABELS = {
|
|
165
|
+
zh: {
|
|
166
|
+
title: "LAN 网关",
|
|
167
|
+
description: "远程访问开关、端口、TLS 证书、受信网段等网关设置",
|
|
168
|
+
unsaved: "未保存",
|
|
169
|
+
save: "保存",
|
|
170
|
+
saving: "保存中…",
|
|
171
|
+
discard: "放弃",
|
|
172
|
+
reset: "重置",
|
|
173
|
+
readOnly: "网关设置只能在宿主机本机打开 dsh web 时修改:配置路由仅监听回环地址,经网关远程访问的浏览器会被拒绝。远程请改用 lan_gateway 工具。",
|
|
174
|
+
saveFailed: "保存未生效,请检查输入后重试。",
|
|
175
|
+
loadFailed: "无法读取网关配置",
|
|
176
|
+
emptyMeansClear: "留空 = 使用默认",
|
|
177
|
+
running: "运行中",
|
|
178
|
+
stopped: "已停止",
|
|
179
|
+
tls: "TLS",
|
|
180
|
+
lastError: "上次错误",
|
|
181
|
+
"field.enabled": "启用网关",
|
|
182
|
+
"hint.enabled": "启动时监听 0.0.0.0 网关端口",
|
|
183
|
+
"field.gatewayPort": "网关端口",
|
|
184
|
+
"hint.gatewayPort": "绑定到 0.0.0.0 的监听端口(默认 3081)",
|
|
185
|
+
"field.dshTargetPort": "dsh 目标端口",
|
|
186
|
+
"hint.dshTargetPort": "留空则自动跟随 dsh web 端口(默认 3080)",
|
|
187
|
+
"field.lanCidrs": "免密 LAN 网段",
|
|
188
|
+
"hint.lanCidrs": "逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16",
|
|
189
|
+
"field.lanPasswordless": "LAN 免登录",
|
|
190
|
+
"hint.lanPasswordless": "LAN/回环来源跳过网关登录页,但仍共用同一上游会话(需 dsh ≥ 0.1.2)",
|
|
191
|
+
"field.allowInsecurePlaintext": "允许明文 HTTP",
|
|
192
|
+
"hint.allowInsecurePlaintext": "危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输",
|
|
193
|
+
"field.trustedTerminator": "受信 TLS 终止代理",
|
|
194
|
+
"hint.trustedTerminator": "可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明。注意:登录限流以 TCP 源地址为键,代理之后所有浏览器共用一个额度(5 次/分钟)",
|
|
195
|
+
"field.secureCookies": "会话 cookie 的 Secure 属性",
|
|
196
|
+
"hint.secureCookies": "自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页",
|
|
197
|
+
"opt.auto": "自动",
|
|
198
|
+
"opt.true": "始终 Secure",
|
|
199
|
+
"opt.false": "不加 Secure(明文浏览器入口)",
|
|
200
|
+
"field.cookieMaxAgeDays": "会话有效期(天)",
|
|
201
|
+
"hint.cookieMaxAgeDays": "登录 cookie 的存活天数(默认 7)",
|
|
202
|
+
"field.tlsEnabled": "启用 TLS(HTTPS)",
|
|
203
|
+
"hint.tlsEnabled": "以 HTTPS 提供网关服务",
|
|
204
|
+
"field.tlsMode": "证书来源",
|
|
205
|
+
"hint.tlsMode": "self-signed = 自动生成自签名证书;custom = 使用自己的证书",
|
|
206
|
+
"field.tlsSelfSignedHosts": "自签名证书域名/IP",
|
|
207
|
+
"hint.tlsSelfSignedHosts": "逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5。仅影响下次换发:已有证书沿用至到期,改动不会立刻生效",
|
|
208
|
+
"field.tlsCertPath": "证书文件路径(custom)",
|
|
209
|
+
"hint.tlsCertPath": "PEM 格式证书(或证书链)的绝对路径",
|
|
210
|
+
"field.tlsKeyPath": "私钥文件路径(custom)",
|
|
211
|
+
"hint.tlsKeyPath": "与证书配套的 PEM 私钥绝对路径",
|
|
212
|
+
"field.tlsCertMaxAgeDays": "自签名证书有效期(天)",
|
|
213
|
+
"hint.tlsCertMaxAgeDays": "默认 825(约 27 个月)。仅影响下次换发:已有证书沿用至到期"
|
|
214
|
+
},
|
|
215
|
+
en: {
|
|
216
|
+
title: "LAN Gateway",
|
|
217
|
+
description: "Remote-access switch, port, TLS certificate, trusted CIDRs and more",
|
|
218
|
+
unsaved: "Unsaved",
|
|
219
|
+
save: "Save",
|
|
220
|
+
saving: "Saving…",
|
|
221
|
+
discard: "Discard",
|
|
222
|
+
reset: "Reset",
|
|
223
|
+
readOnly: "Gateway settings can only be changed where dsh web runs locally: the config route listens on loopback only, so a browser reaching dsh through the gateway is refused. Use the lan_gateway tool remotely.",
|
|
224
|
+
saveFailed: "The save did not land — check the inputs and retry.",
|
|
225
|
+
loadFailed: "Cannot read the gateway configuration",
|
|
226
|
+
emptyMeansClear: "Empty = default",
|
|
227
|
+
running: "Running",
|
|
228
|
+
stopped: "Stopped",
|
|
229
|
+
tls: "TLS",
|
|
230
|
+
lastError: "Last error",
|
|
231
|
+
"field.enabled": "Enable gateway",
|
|
232
|
+
"hint.enabled": "Listen on the gateway port at boot",
|
|
233
|
+
"field.gatewayPort": "Gateway port",
|
|
234
|
+
"hint.gatewayPort": "Port bound on 0.0.0.0 (default 3081)",
|
|
235
|
+
"field.dshTargetPort": "dsh target port",
|
|
236
|
+
"hint.dshTargetPort": "Leave empty to follow the dsh web port (default 3080)",
|
|
237
|
+
"field.lanCidrs": "Password-free LAN CIDRs",
|
|
238
|
+
"hint.lanCidrs": "Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16",
|
|
239
|
+
"field.lanPasswordless": "LAN skip login",
|
|
240
|
+
"hint.lanPasswordless": "LAN/loopback sources skip the gateway login page but still ride one shared upstream session (needs dsh >= 0.1.2)",
|
|
241
|
+
"field.allowInsecurePlaintext": "Allow plaintext HTTP",
|
|
242
|
+
"hint.allowInsecurePlaintext": "Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear",
|
|
243
|
+
"field.trustedTerminator": "Trusted TLS terminator",
|
|
244
|
+
"hint.trustedTerminator": "Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared. Note: login rate limiting keys on the TCP source address, so behind a proxy every browser shares one budget (5/min)",
|
|
245
|
+
"field.secureCookies": "Session cookie Secure attribute",
|
|
246
|
+
"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",
|
|
247
|
+
"opt.auto": "Auto",
|
|
248
|
+
"opt.true": "Always Secure",
|
|
249
|
+
"opt.false": "No Secure (plaintext browser ingress)",
|
|
250
|
+
"field.cookieMaxAgeDays": "Session lifetime (days)",
|
|
251
|
+
"hint.cookieMaxAgeDays": "Login cookie lifetime (default 7)",
|
|
252
|
+
"field.tlsEnabled": "Enable TLS (HTTPS)",
|
|
253
|
+
"hint.tlsEnabled": "Serve the gateway over HTTPS",
|
|
254
|
+
"field.tlsMode": "Certificate source",
|
|
255
|
+
"hint.tlsMode": "self-signed = auto-generated certificate; custom = your own files",
|
|
256
|
+
"field.tlsSelfSignedHosts": "Self-signed hosts (SANs)",
|
|
257
|
+
"hint.tlsSelfSignedHosts": "Comma separated DNS/IP names, e.g. localhost, 192.168.1.5. Applies to the next issuance only: an existing certificate is reused until it expires",
|
|
258
|
+
"field.tlsCertPath": "Certificate path (custom)",
|
|
259
|
+
"hint.tlsCertPath": "Absolute path to a PEM certificate (or chain)",
|
|
260
|
+
"field.tlsKeyPath": "Private key path (custom)",
|
|
261
|
+
"hint.tlsKeyPath": "Absolute path to the matching PEM private key",
|
|
262
|
+
"field.tlsCertMaxAgeDays": "Self-signed validity (days)",
|
|
263
|
+
"hint.tlsCertMaxAgeDays": "Default 825 (about 27 months). Applies to the next issuance only: an existing certificate is reused until it expires"
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
function labels() {
|
|
267
|
+
return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? LABELS.zh : LABELS.en;
|
|
268
|
+
}
|
|
260
269
|
/**
|
|
261
270
|
* Render the LAN gateway card. Self-loading: fetches the config route on
|
|
262
271
|
* mount, posts the edited config on save.
|
|
@@ -284,7 +293,28 @@ window.__ModuleLoader__.load({
|
|
|
284
293
|
cancelled = true;
|
|
285
294
|
};
|
|
286
295
|
}, []);
|
|
287
|
-
if (loadFailed) return
|
|
296
|
+
if (loadFailed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
297
|
+
style: styles.card,
|
|
298
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
299
|
+
style: styles.header,
|
|
300
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
301
|
+
style: styles.headerTop,
|
|
302
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
303
|
+
style: styles.name,
|
|
304
|
+
children: t.title
|
|
305
|
+
})
|
|
306
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
307
|
+
style: styles.description,
|
|
308
|
+
children: t.loadFailed
|
|
309
|
+
})]
|
|
310
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
311
|
+
style: styles.body,
|
|
312
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
313
|
+
style: styles.hint,
|
|
314
|
+
children: t.readOnly
|
|
315
|
+
})
|
|
316
|
+
})]
|
|
317
|
+
});
|
|
288
318
|
if (route === null) return null;
|
|
289
319
|
const { config } = route;
|
|
290
320
|
const draftOf = (field) => drafts[field] ?? formatValue(FIELDS.find((f) => f.field === field), config[field]);
|
|
@@ -316,16 +346,18 @@ window.__ModuleLoader__.load({
|
|
|
316
346
|
setSaving(true);
|
|
317
347
|
setFailed(null);
|
|
318
348
|
try {
|
|
319
|
-
const
|
|
320
|
-
for (const
|
|
321
|
-
const
|
|
349
|
+
const patch = {};
|
|
350
|
+
for (const [field, text] of Object.entries(drafts)) {
|
|
351
|
+
const def = FIELDS.find((f) => f.field === field);
|
|
352
|
+
if (def === void 0) continue;
|
|
353
|
+
const write = parseValue(def, text ?? "");
|
|
322
354
|
if (write === void 0) continue;
|
|
323
|
-
|
|
355
|
+
patch[field] = write.kind === "clear" ? null : write.value;
|
|
324
356
|
}
|
|
325
357
|
const response = await fetch("/lan-gateway/config", {
|
|
326
358
|
method: "POST",
|
|
327
359
|
headers: { "content-type": "application/json" },
|
|
328
|
-
body: JSON.stringify(
|
|
360
|
+
body: JSON.stringify(patch)
|
|
329
361
|
});
|
|
330
362
|
const body = await response.json().catch(() => ({}));
|
|
331
363
|
if (!response.ok) {
|