@riceawa/dsh-lan-gateway 0.5.4 → 0.6.0
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 +37 -21
- package/lib/client.js +190 -146
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +129 -8
- package/lib/index.js +1424 -968
- package/package.json +26 -14
- package/src/auth.ts +7 -14
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +93 -145
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +401 -157
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +16 -5
- package/src/tls.ts +23 -0
- package/src/upstream-session.ts +24 -8
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.
|
|
9
|
+
<img src="https://img.shields.io/badge/version-0.6.0-2b7fff?style=flat-square" alt="version 0.6.0" />
|
|
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>
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
`dsh web` 明确拒绝 `--host 0.0.0.0`,以免把远程代码执行暴露到网络。本插件的做法是让 dsh 继续只绑 `127.0.0.1`,另起一个反向代理监听未指定地址(双栈,IPv4 与 IPv6 客户端都可接入),转发到 loopback 端口并改写 `Host` / `Origin`。
|
|
16
16
|
|
|
17
|
-
默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页取得 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless
|
|
17
|
+
默认拒绝:loopback、LAN、公网三种来源都要先在网关登录页取得 HMAC 会话 cookie,LAN 免密需要显式打开 `lanPasswordless`,默认关闭。底座要求 dsh ≥ 0.1.7(本插件按该版本的 settings API 写入配置;含 QVD-2026-57410 的上游修复),网关在进程内中继一条共享上游会话,上游自身的授权仍然把关每个请求,网关只决定谁可以使用这条会话。
|
|
18
18
|
|
|
19
19
|
插件另外提供两项功能:
|
|
20
20
|
|
|
@@ -70,7 +70,7 @@ lan_gateway disable # 关闭
|
|
|
70
70
|
|
|
71
71
|
## 配置
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页,在官方插件列表里点开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。卡片挂在 Plugins 页的 `plugins.item` 槽上,且只在底座确实 served 本插件条目时注册——没有 Loader 条目(写入必然 409)时卡片不会出现。下表既是卡片字段,也是配置键:
|
|
74
74
|
|
|
75
75
|
| 键 | 默认值 | 说明 |
|
|
76
76
|
| --- | --- | --- |
|
|
@@ -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
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。
|
|
@@ -131,7 +131,7 @@ lan_gateway disable # 关闭
|
|
|
131
131
|
tlsKeyPath: /etc/letsencrypt/live/example.com/privkey.pem
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
自签名证书在首次启用 TLS 时生成一次,写入 `~/.dsh/lan-gateway/tls/`(`selfsigned.crt` / `selfsigned.key`,0600),之后重启复用。更换证书使用 `lan_gateway tls-regenerate`,它会换掉密钥并热重启监听器。已到期的证书在启动时自动换发并记一条 warning
|
|
134
|
+
自签名证书在首次启用 TLS 时生成一次,写入 `~/.dsh/lan-gateway/tls/`(`selfsigned.crt` / `selfsigned.key`,0600),之后重启复用。更换证书使用 `lan_gateway tls-regenerate`,它会换掉密钥并热重启监听器。已到期的证书在启动时自动换发并记一条 warning。修改 `tlsSelfSignedHosts` 或 `tlsCertMaxAgeDays` 只影响**下次换发**:已有证书沿用至到期,不会在保存时被悄悄替换。
|
|
135
135
|
|
|
136
136
|
### 证书有效期
|
|
137
137
|
|
|
@@ -158,13 +158,19 @@ lan_gateway disable # 关闭
|
|
|
158
158
|
|
|
159
159
|
`lan_gateway status` 会如实报告实际生效的属性,以及声明的代理属于 TLS 还是明文入口。设置页里对应「自动 / 始终 Secure / 不加 Secure」三档。
|
|
160
160
|
|
|
161
|
+
### 登录限流
|
|
162
|
+
|
|
163
|
+
登录尝试以 TCP 源地址为键限流,每分钟 5 次。`X-Forwarded-For` 与 `Forwarded` 在转发前一律删除,也不参与限流取键——它们可由客户端任意伪造,用它们作为键等于把限流关掉。
|
|
164
|
+
|
|
165
|
+
代价是声明 `trustedTerminator` 之后,所有浏览器请求都来自代理这一个地址,5 次/分钟变成**整个部署共享**的额度,而非每客户端 5 次。这一耦合没有便宜的修法:要么接受它(把上限视为抗暴力破解的下限而非每用户配额),要么让代理直连、由网关自己面对客户端。当前不接受按 `X-Forwarded-For` 分桶。
|
|
166
|
+
|
|
161
167
|
注意 `secureCookies: false` 说的是浏览器到入口这一段是明文,网关登录密码和会话 cookie 会在这一段明文传输。这与 `allowInsecurePlaintext` 描述的不是同一段链路:后者指代理到网关之间不加密,前者指浏览器到代理之间不加密。只有当代理本身已经对用户完成鉴权、且可以接受这段明文时,才应这样配置。
|
|
162
168
|
|
|
163
169
|
## 安全模型
|
|
164
170
|
|
|
165
171
|
- **来源分级只认 `socket.remoteAddress`**(IPv4-mapped IPv6 会先解包),分 loopback / lan / internet 三档,绝不信任 `X-Forwarded-For`。分级本身不授予任何访问,每一档默认都要出示有效网关会话,否则 302 到 `/__login`。
|
|
166
|
-
- **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback
|
|
167
|
-
- **共享上游会话中继**(dsh ≥ 0.1.
|
|
172
|
+
- **LAN 免密是显式 opt-in**。`lanPasswordless: true` 只让命中 `lanCidrs` 或 loopback 的来源跳过网关自己的登录页;受支持的底座(dsh ≥ 0.1.7)上游会话仍把关每个请求。底座没有浏览器会话认证时这个开关拒绝启用,否则等同于把 QVD-2026-57410 原样恢复。
|
|
173
|
+
- **共享上游会话中继**(dsh ≥ 0.1.7)。dsh 不再信任回环 Host,要求出示 HMAC 签名的 `dsh-auth-*` cookie。插件经 `connection` 服务拿到启动令牌,在回环传输上做一次浏览器等价的令牌换取,取得 cookie 后中继到每个转发请求;上游一旦 401 就丢弃这条会话并重新换取。这仍是「单密码 = 单操作者」:通过网关登录的用户共用同一条上游会话,持钥的上游才是真正的授权主体。
|
|
168
174
|
- **登录页**。`/__login` 由网关独占、不转发。密码以 scrypt 校验,每写一次重新加盐;登录尝试按来源限流(5 次 / 分钟)。
|
|
169
175
|
- **会话 cookie** 是 `payload.signature` 结构(HMAC-SHA256),带撤销 epoch 与逐会话 id,`HttpOnly; SameSite=Strict`。登出撤销的是当前这条会话:它建立的 WebSocket 一并断开,其他设备不受影响。改密、清密、`rotate-secret` 递增 epoch,作废全部已签发 cookie 并断开全部已建立的 WebSocket,客户端需要重新登录。清空密码会直接停止监听。
|
|
170
176
|
- **管理面不外泄**。`/lan-gateway/*`(含配置路由)由网关独占、一律 403 不转发,远程访问者无法借网关改写 Host 触及本机 loopback 的配置接口。原生 `/lan-gateway/config` 只应答回环 Host 且同源的请求。远程管理走 `lan_gateway` 工具。
|
|
@@ -193,17 +199,23 @@ pnpm typecheck # tsc 双端(host + client)
|
|
|
193
199
|
```
|
|
194
200
|
|
|
195
201
|
```
|
|
196
|
-
✓ tests/gateway.test.ts
|
|
197
|
-
✓ tests/start-guard.test.ts
|
|
198
|
-
|
|
199
|
-
✓ tests/
|
|
200
|
-
|
|
201
|
-
✓ tests/
|
|
202
|
-
|
|
203
|
-
✓ tests/
|
|
204
|
-
✓ tests/
|
|
205
|
-
|
|
206
|
-
✓ tests/settings-card.test.ts
|
|
202
|
+
✓ tests/gateway.test.ts (40) 分类 / HMAC cookie / epoch / 逐会话撤销 / 密码状态 / 限流
|
|
203
|
+
✓ tests/start-guard.test.ts (19) fail-closed 启动守卫 / 配置路由回环围栏 /
|
|
204
|
+
Secure cookie 属性推断(含 null 清除路径)
|
|
205
|
+
✓ tests/request-policy.test.ts (46) 判定缝纯函数:路径归一化 / 归属前缀 / 同站与登录围栏 /
|
|
206
|
+
两个方向的头部变换
|
|
207
|
+
✓ tests/upstream-session.test.ts ( 8) 真实回环令牌换取:cookie 名匹配 / 拒绝后重换 /
|
|
208
|
+
invalidate 重获取 / 日志播报 / 保住已持有会话
|
|
209
|
+
✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
|
|
210
|
+
✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
|
|
211
|
+
✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
|
|
212
|
+
✓ tests/settings-card.test.ts (10) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
|
|
213
|
+
卡片注册槽与条目 id 契约
|
|
214
|
+
✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
|
|
215
|
+
cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
|
|
216
|
+
✓ tests/integration/management-plane.test.ts (15) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
|
|
217
|
+
清空后继承 / 拒绝不可启动配置
|
|
218
|
+
✓ tests/integration/session-races.test.ts ( 8) 改密落在登录与握手途中的竞态 / 上游非 101 应答
|
|
207
219
|
```
|
|
208
220
|
|
|
209
221
|
### 发布
|
|
@@ -227,8 +239,8 @@ pnpm typecheck # tsc 双端(host + client)
|
|
|
227
239
|
pnpm install --frozen-lockfile
|
|
228
240
|
pnpm typecheck && pnpm test
|
|
229
241
|
npm publish --access public # prepack 自动构建 lib/
|
|
230
|
-
git tag -a v0.
|
|
231
|
-
gh release create v0.
|
|
242
|
+
git tag -a v0.6.0 -m "…" && git push origin v0.6.0
|
|
243
|
+
gh release create v0.6.0 --generate-notes ./*.tgz # 可选:Release + tgz 附件
|
|
232
244
|
```
|
|
233
245
|
|
|
234
246
|
## 安全评估
|
|
@@ -240,6 +252,10 @@ gh release create v0.5.4 --generate-notes ./*.tgz # 可选:Release + tgz 附
|
|
|
240
252
|
- [上游研究](docs/security/qvd-2026-57410-research.md):公开通告、上游提交与版本核对。
|
|
241
253
|
- [0.5.4 复审与修复清单](docs/security/audit-2026-09-19-fix-list.md):0.5.3 的实现细节复审(G1–G12)与修复记录。
|
|
242
254
|
|
|
255
|
+
架构层面的复审另见 [docs/review/](docs/review/):
|
|
256
|
+
|
|
257
|
+
- [架构复审(2026-09-19)](docs/review/architecture-review-2026-09-19.md):针对 0.5.4 的 C1–C6 结构候选与 D1–D14 缺陷清单,0.5.5 已全部关闭。文档记录的是复审当时的状态,不是待办列表。
|
|
258
|
+
|
|
243
259
|
## 许可
|
|
244
260
|
|
|
245
261
|
[MIT](./LICENSE)
|
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,13 +148,143 @@ 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, rendered by the official DSH Plugins page
|
|
155
|
+
* through its `plugins.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 only platform service it needs is
|
|
160
|
+
* the `slots` service 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
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The card's title in the browser's language, for the Plugins page's list
|
|
271
|
+
* entry. A thunk so the label follows the page's locale without re-registering.
|
|
272
|
+
* @returns the localized card title.
|
|
273
|
+
*/
|
|
274
|
+
function cardTitle() {
|
|
275
|
+
return labels().title;
|
|
276
|
+
}
|
|
260
277
|
/**
|
|
261
278
|
* Render the LAN gateway card. Self-loading: fetches the config route on
|
|
262
279
|
* mount, posts the edited config on save.
|
|
263
|
-
*
|
|
264
|
-
*
|
|
280
|
+
*
|
|
281
|
+
* `view` swaps between the card's one-liner and its page body, so the branch
|
|
282
|
+
* sits after the hooks: the Plugins page re-renders one contribution under the
|
|
283
|
+
* other view when the card is opened.
|
|
284
|
+
* @param props - the view the Plugins page is asking for.
|
|
285
|
+
* @returns the one-liner, the card, or nothing while the route is unreachable.
|
|
265
286
|
*/
|
|
266
|
-
function LanGatewayCard(
|
|
287
|
+
function LanGatewayCard(props) {
|
|
267
288
|
const t = labels();
|
|
268
289
|
const [open, setOpen] = (0, react.useState)(false);
|
|
269
290
|
const [route, setRoute] = (0, react.useState)(null);
|
|
@@ -284,7 +305,29 @@ window.__ModuleLoader__.load({
|
|
|
284
305
|
cancelled = true;
|
|
285
306
|
};
|
|
286
307
|
}, []);
|
|
287
|
-
if (
|
|
308
|
+
if (props.view === "summary") return t.description;
|
|
309
|
+
if (loadFailed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
310
|
+
style: styles.card,
|
|
311
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
312
|
+
style: styles.header,
|
|
313
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
314
|
+
style: styles.headerTop,
|
|
315
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
316
|
+
style: styles.name,
|
|
317
|
+
children: t.title
|
|
318
|
+
})
|
|
319
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
320
|
+
style: styles.description,
|
|
321
|
+
children: t.loadFailed
|
|
322
|
+
})]
|
|
323
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
324
|
+
style: styles.body,
|
|
325
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
326
|
+
style: styles.hint,
|
|
327
|
+
children: t.readOnly
|
|
328
|
+
})
|
|
329
|
+
})]
|
|
330
|
+
});
|
|
288
331
|
if (route === null) return null;
|
|
289
332
|
const { config } = route;
|
|
290
333
|
const draftOf = (field) => drafts[field] ?? formatValue(FIELDS.find((f) => f.field === field), config[field]);
|
|
@@ -316,16 +359,18 @@ window.__ModuleLoader__.load({
|
|
|
316
359
|
setSaving(true);
|
|
317
360
|
setFailed(null);
|
|
318
361
|
try {
|
|
319
|
-
const
|
|
320
|
-
for (const
|
|
321
|
-
const
|
|
362
|
+
const patch = {};
|
|
363
|
+
for (const [field, text] of Object.entries(drafts)) {
|
|
364
|
+
const def = FIELDS.find((f) => f.field === field);
|
|
365
|
+
if (def === void 0) continue;
|
|
366
|
+
const write = parseValue(def, text ?? "");
|
|
322
367
|
if (write === void 0) continue;
|
|
323
|
-
|
|
368
|
+
patch[field] = write.kind === "clear" ? null : write.value;
|
|
324
369
|
}
|
|
325
370
|
const response = await fetch("/lan-gateway/config", {
|
|
326
371
|
method: "POST",
|
|
327
372
|
headers: { "content-type": "application/json" },
|
|
328
|
-
body: JSON.stringify(
|
|
373
|
+
body: JSON.stringify(patch)
|
|
329
374
|
});
|
|
330
375
|
const body = await response.json().catch(() => ({}));
|
|
331
376
|
if (!response.ok) {
|
|
@@ -442,7 +487,7 @@ window.__ModuleLoader__.load({
|
|
|
442
487
|
}
|
|
443
488
|
};
|
|
444
489
|
const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`;
|
|
445
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("
|
|
490
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
446
491
|
style: open ? {
|
|
447
492
|
...styles.card,
|
|
448
493
|
...styles.cardOpen
|
|
@@ -725,10 +770,9 @@ window.__ModuleLoader__.load({
|
|
|
725
770
|
* installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
|
|
726
771
|
* module scope. With TLS enabled the origin is secure and the shim is a
|
|
727
772
|
* no-op.
|
|
728
|
-
* 2. Settings card: registers the LAN gateway card into the official
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
* adjustable from the GUI.
|
|
773
|
+
* 2. Settings card: registers the LAN gateway card into the official Plugins
|
|
774
|
+
* page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
|
|
775
|
+
* from the GUI.
|
|
732
776
|
*/
|
|
733
777
|
/** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
|
|
734
778
|
function uuidFromRandomValues() {
|
|
@@ -771,25 +815,25 @@ window.__ModuleLoader__.load({
|
|
|
771
815
|
}
|
|
772
816
|
installRandomUuidShim();
|
|
773
817
|
const name = "dsh-lan-gateway";
|
|
774
|
-
/**
|
|
775
|
-
|
|
818
|
+
/**
|
|
819
|
+
* The profile entry id this plugin's bundle patch composes it under, and the
|
|
820
|
+
* settings namespace dsh ≥ 0.1.7 addresses every write by.
|
|
821
|
+
*/
|
|
822
|
+
const ENTRY_ID = "dsh-lan-gateway";
|
|
823
|
+
/** The slots service the card rides, and the settings mirror the gate reads. */
|
|
824
|
+
const inject = ["slots", "configForms"];
|
|
776
825
|
/**
|
|
777
826
|
* Mount the settings card and the UUID shim.
|
|
778
827
|
* @param ctx - the browser plugin context.
|
|
779
828
|
*/
|
|
780
829
|
function apply(ctx) {
|
|
781
830
|
installRandomUuidShim();
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
name: "settings.plugin.item",
|
|
789
|
-
key: "lan-gateway",
|
|
790
|
-
...legacyListOptions
|
|
791
|
-
}, LanGatewayCard);
|
|
792
|
-
});
|
|
831
|
+
ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({
|
|
832
|
+
name: "plugins.item",
|
|
833
|
+
id: ENTRY_ID,
|
|
834
|
+
order: 30,
|
|
835
|
+
label: () => cardTitle()
|
|
836
|
+
}, LanGatewayCard))));
|
|
793
837
|
}
|
|
794
838
|
//#endregion
|
|
795
839
|
exports.apply = apply;
|