@fonlan/dsh-web-auth 0.1.10 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -12
- package/lib/client/index.d.ts +11 -10
- package/lib/client.js +59 -44
- package/lib/handlers.d.ts +25 -3
- package/lib/handlers.js +46 -14
- package/lib/index.js +25 -7
- package/lib/listen-addresses.d.ts +85 -0
- package/lib/listen-addresses.js +223 -0
- package/lib/profile-patch.d.ts +20 -7
- package/lib/profile-patch.js +73 -9
- package/package.json +11 -12
- package/lib/settings.d.ts +0 -62
- package/lib/settings.js +0 -87
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
- **登录页 + HttpOnly 签名 Cookie**(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
|
|
8
8
|
- **首次设置**:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
|
|
9
|
-
- **GUI 改密**:设置 →
|
|
9
|
+
- **GUI 改密**:设置 → 侧栏「访问认证」页面,需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
|
|
10
10
|
- **退出登录**:设置卡片按钮,或访问 `/logout`
|
|
11
11
|
- **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
|
|
12
|
-
- **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto
|
|
13
|
-
-
|
|
12
|
+
- **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任本机对端的 `X-Forwarded-For`
|
|
13
|
+
- **监听地址切换**:设置卡片可在**本机网卡的具体地址**之间切换(含 `127.0.0.1` 仅本机、以及每张网卡的 IP),热生效、无需重启进程(REST → host 半区应用 patch → HMR 重绑 webserver)。**不再提供 `0.0.0.0`**:dsh `0.2.1-alpha.2` 起通配符地址会在解析配置阶段直接让启动失败,写进去即硬砖(见下文「通配符与启动失败」)
|
|
14
14
|
- **远程设置解锁(可选)**:`unlockRemoteSettings` 组合配置在启动页注入 `__DSH_TRANSPORT__.ownsHost` 标志,让反代域名下的设置面(模型页、插件配置、常规设置等)完整可用,默认关闭(见下文安全权衡)
|
|
15
15
|
- **启动令牌收口**:未登录浏览器带旧 `?token=` 链接访问一律重定向回登录页,只有插件自身的服务端 mint 走回环放行通道——既不需要手工引导,也不会再撞上 DSH 的 `401` 死页面(`0.1.7` 起)
|
|
16
16
|
- 密码以 **scrypt 加盐哈希**存储(`$DSH_HOME/web-auth/password.hash`,0600),永不回显、不落明文配置
|
|
@@ -29,6 +29,8 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
29
29
|
|
|
30
30
|
> 安装后**需要重启 dsh web 进程**才生效(当前会话会短暂断连重连)。
|
|
31
31
|
|
|
32
|
+
**宿主兼容性**:`0.2.1` 面向 dsh `^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-rc.2`,含两处按宿主演进做的适配:取值域去掉通配符(宿主 `0.2.1-alpha.2` 起拒绝),设置页不再依赖已被移除的客户端 `settingsScope` 服务(改走本插件 REST)。在更早的 `0.1.x` 宿主上本插件同样可用,但请勿把监听地址设成 `0.0.0.0`——宿主一旦升级到 `0.2.1-alpha.2`,该值会让 dsh 无法启动(见下文「通配符与启动失败」)。
|
|
33
|
+
|
|
32
34
|
## 激活(首次设置密码)
|
|
33
35
|
|
|
34
36
|
重启后打开 dsh web,会被重定向到登录页:
|
|
@@ -36,7 +38,7 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
36
38
|
1. 未设置过密码时,登录页显示「设置访问密码」——输入 **≥8 位**的新密码并确认
|
|
37
39
|
2. 设置成功后立即签发会话并跳回原页面;此后每次访问都要输密码
|
|
38
40
|
|
|
39
|
-
改密入口在 **设置 →
|
|
41
|
+
改密入口在 **设置 → 侧栏「访问认证」**(需先登录)。
|
|
40
42
|
|
|
41
43
|
## 行为细节
|
|
42
44
|
|
|
@@ -55,22 +57,58 @@ dsh plugin --profile web update @fonlan/dsh-web-auth
|
|
|
55
57
|
| 连续失败 | 按 IP 限速,提示「尝试过于频繁」 |
|
|
56
58
|
| 会话过期 | 跳回登录页;滑动续期:剩余不足 24h 自动刷新 |
|
|
57
59
|
| 修改密码 | 校验旧密码 → 轮换签名密钥 → **全员下线**,跳转登录页 |
|
|
58
|
-
| 切换监听地址(设置卡片) |
|
|
60
|
+
| 切换监听地址(设置卡片) | 校验取值(必须是具体 IPv4/IPv6 字面量,**通配符被拒绝**)→ `POST /api/web-auth/listen` → host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
|
|
59
61
|
| `/logout` | 清除 Cookie,跳转登录页 |
|
|
60
62
|
|
|
61
63
|
> **无需再处理 DSH 自己的启动令牌**:DSH 的浏览器会话认证(`dsh web` 启动时打印的 `http://127.0.0.1:3080/?token=…`)用 cookie 绑定 Host。插件会把所有已认证请求的 Host 改写为回环地址,而 DSH 的 cookie 必须与「DSH 看到的 Host」一致——`0.1.5` 起插件会在登录/会话期间**自动在服务端完成 DSH 令牌交换**并把 `dsh-auth` cookie 转交给浏览器(含持续续期),所以外部设备、新浏览器都不需要再手工访问带 token 的 URL。`0.1.7` 起这条路径进一步收紧:未登录浏览器带 `token` 访问会被重定向到 `/login` 并丢弃该参数(旧令牌在 dsh 重启后即失效,放行只会展示 DSH 自己的 `401` 页面),登录本身就已经完成 mint,不再需要任何手工引导步骤。
|
|
62
64
|
|
|
63
65
|
### 切换监听地址
|
|
64
66
|
|
|
65
|
-
dsh 默认只监听 `127.0.0.1`。在 **设置 →
|
|
67
|
+
dsh 默认只监听 `127.0.0.1`。在 **设置 → 侧栏「访问认证」** 页面底部可以把它切换为本机任意网卡上的**具体地址**(列表由 host 半区枚举 `os.networkInterfaces()` 生成),或切回 `127.0.0.1` 仅本机——无需重启 dsh 进程:
|
|
66
68
|
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
+
- 页面先 `GET /api/web-auth/status` 取当前地址与候选列表(`allowed`),提交时 `POST /api/web-auth/listen`;host 半区把取值写进当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
|
|
70
|
+
- 写入前统一过 `isLocalListenHost`:必须是 IPv4/IPv6 **字面量**、不是通配符、并且**确实是本机网卡拥有的地址**,否则直接拒绝(`400 invalid-host`,响应里附上合法候选列表),不会写坏文件。下拉列表即由同一枚举生成,页面不可能提供一个写不进去的值。
|
|
71
|
+
- dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(patch 文件在)。
|
|
69
72
|
- 切换瞬间所有连接(包括当前页面与 WebSocket)会断开重连,属正常现象;卡片会轮询状态确认新地址生效。
|
|
70
|
-
-
|
|
73
|
+
- **绑定具体网卡地址后 `127.0.0.1` 不再监听**:本机也必须改用该地址访问(`http://<网卡地址>:3080`)。想同时保留本机与局域网入口,请让 dsh 继续监听 `127.0.0.1`,另在本机放一层反代监听局域网/公网并指向它——这也是 dsh 现在推荐的暴露方式。
|
|
74
|
+
- 注意:若你正通过**局域网 IP** 访问并切回 `127.0.0.1`,切回后只能在本机访问,请改用 `127.0.0.1` 重新打开页面。
|
|
71
75
|
|
|
72
76
|
> 该能力依赖 webserver 行位于 profile/home patch 层(默认安装即如此)。若你的 webserver 配置来自自定义 `--patch` overlay,请直接编辑 overlay 文件。
|
|
73
77
|
|
|
78
|
+
### 写入值导致启动失败(三类,及恢复方式)
|
|
79
|
+
|
|
80
|
+
监听地址是**组合配置**,写在 patch 文件里、由 loader 在启动时读取。因此一个写坏的取值不是「绑定失败」而已——它会让**下一次启动整体失败**,而本插件自己也在 waiting、无法自救。`0.2.0` 把所有已知的三类都堵在写入之前:
|
|
81
|
+
|
|
82
|
+
| 类别 | 写坏后会看到 | 插件现在如何拦截 |
|
|
83
|
+
|---|---|---|
|
|
84
|
+
| 通配符 `0.0.0.0` / `::` | 解析配置阶段报 `host "0.0.0.0" is an unspecified (wildcard) address, which is not supported …` | 通配符一律拒绝(`400 invalid-host` / 写盘前抛错) |
|
|
85
|
+
| 本机没有的地址(如 macOS 上的 `127.0.0.2`、过期 DHCP 地址) | `listen EADDRNOTAVAIL: address not available …` | 只接受**本机网卡实际拥有**的地址(`os.networkInterfaces()` 枚举,`127.0.0.1` 恒定可用) |
|
|
86
|
+
| 把行追加到模板的空序列 `[]` 之后 | `failed to parse overlay …: end of the stream or a document separator is expected` | 识别模板 `[]` 并**替换**它(非空 flow 文档直接拒绝编辑) |
|
|
87
|
+
|
|
88
|
+
三类症状长这样(`webserver` 未激活,所有依赖它的插件排队等 `webServer`):
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
dsh: startup failed: 2 required plugins did not activate
|
|
92
|
+
webserver (required) @deepseek-ai/dsh-host-webserver → 上面某一类报错
|
|
93
|
+
Plugins waiting for services:
|
|
94
|
+
web-runtime / web-auth / better-sidebar / task-kanban / … (missing webServer)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
恢复方式都是手工改 patch 文件(插件此时不会加载):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# 1) 看当前写死的 host
|
|
101
|
+
grep -n -A3 '^- id: webserver' ~/.dsh/profiles/web/cordis.patch.yml
|
|
102
|
+
# 2) 改成 127.0.0.1(或本机某张网卡的实际地址)后保存
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
若第一行是 `[]` 之类的空序列而下面又跟了 `- id:` 行,把那个 `[]` 行整行删掉即为合法文档。
|
|
106
|
+
|
|
107
|
+
> **DHCP 提醒**:绑定具体网卡地址后,地址由 DHCP 变化(或网卡消失)时,下一次启动同样会 `EADDRNOTAVAIL`。长期部署建议让 dsh 监听 `127.0.0.1`,把对外暴露交给本机反代(反代再指向回环),这样地址变化不影响 dsh 启动。
|
|
108
|
+
|
|
109
|
+
`0.2.0` 起插件的所有写路径都会在改动任何字节之前校验,因此这三类砖不会再被本插件写出来。(若你仍在用 `0.1.10` 及更早版本并已中招,同样按上面方式手工修复,再升级到 `0.2.0`。)
|
|
110
|
+
|
|
111
|
+
|
|
74
112
|
### 远程设置解锁(`unlockRemoteSettings`,默认关闭)
|
|
75
113
|
|
|
76
114
|
**现象**:通过域名反代访问 dsh 时,设置 → 模型页报「加载提供方目录失败: settings are unavailable in this browser」,常规设置、插件配置等设置面同样不可用。这不是故障,是 dsh 的有意设计:设置文档(含 provider 凭据引用等敏感配置)只对"本机浏览器"开放——客户端在页面加载时按 `location.hostname` 判定是否回环(`localhost` / `127.x`),反代域名永远不算,设置镜像因此保持进程本地、根本不发起读取。注意这与服务端无关:本插件已把已认证请求改写为回环来源,`/api` 通道完全正常,只有客户端这道门在拦。
|
|
@@ -109,11 +147,11 @@ npm run test # 单元 + 集成测试(node:test,真实 HTTP/WS 全流
|
|
|
109
147
|
- `src/auth-core.ts` — 纯逻辑:scrypt 哈希、Cookie 签名/校验、限速器、`next` 校验
|
|
110
148
|
- `src/gate.ts` — 把认证闸门包到 node:http 服务器的 request/upgrade 监听器外层
|
|
111
149
|
- `src/handlers.ts` — 登录页/登录/登出/改密/状态/监听切换 路由处理
|
|
112
|
-
- `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js`
|
|
150
|
+
- `src/profile-patch.ts` — 监听地址切换:webserver 行的 patch 层文本改写(保留注释与 `!!js` 表达式),并在写入前拒绝通配符
|
|
151
|
+
- `src/listen-addresses.ts` — 监听地址取值域:字面量/通配符判定、本机网卡枚举(候选列表)、本机对端判定(mint 重入与 XFF 信任)
|
|
113
152
|
- `src/remote-unlock.ts` — 远程设置解锁:`unlockRemoteSettings` 开启时经 `webServer.tapIndex` 注入 `ownsHost` 启动页脚本
|
|
114
|
-
- `src/settings.ts` — `web-auth` settings 命名空间注册 + listenHost 变更应用(卡片的分发键与数据源)
|
|
115
153
|
- `src/index.ts` — cordis 插件装配(host 半区)
|
|
116
|
-
- `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.
|
|
154
|
+
- `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.section` 插槽,id `web-auth`;读写走本插件 REST)
|
|
117
155
|
- `cordis.patch.yml` — bundle 补丁层(insert 插件行)
|
|
118
156
|
|
|
119
157
|
## License
|
package/lib/client/index.d.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
-
/** Services provided by other browser plugins before this one mounts.
|
|
3
|
-
*
|
|
4
|
-
* `connection` and `remote` are required by `settingsScope.bind` (it resolves
|
|
5
|
-
* the settings transport and the forwarded-invalidation subscription through
|
|
6
|
-
* `ctx.get`), so the fiber must wait for them — exactly like
|
|
7
|
-
* the settings shell does for its own sections. dsh >= 0.1.7 dropped
|
|
8
|
-
* `settingsScope` itself, so it is resolved optionally below.
|
|
9
|
-
*/
|
|
2
|
+
/** Services provided by other browser plugins before this one mounts. */
|
|
10
3
|
export declare const inject: string[];
|
|
11
4
|
/**
|
|
12
|
-
* Client plugin body: register the settings page.
|
|
13
|
-
*
|
|
5
|
+
* Client plugin body: register the settings page.
|
|
6
|
+
*
|
|
7
|
+
* The page needs only the slot ledger — it reads and writes the password and
|
|
8
|
+
* the listen address through the plugin's own REST routes
|
|
9
|
+
* (/api/web-auth/{status,password,listen}). It deliberately does NOT bind the
|
|
10
|
+
* browser settings transport: dsh >= 0.2.1-alpha.2 removed the
|
|
11
|
+
* `settingsScope` client service (its settings plane is now the schema-driven
|
|
12
|
+
* SettingsForms on the host), so a page that waited for it would never
|
|
13
|
+
* register — the 访问认证 entry would silently vanish from Settings.
|
|
14
|
+
*
|
|
14
15
|
* @param ctx - the client cordis context.
|
|
15
16
|
*/
|
|
16
17
|
export declare function apply(ctx: Context): void;
|
package/lib/client.js
CHANGED
|
@@ -102,37 +102,49 @@ var dshWebAuthModule = (() => {
|
|
|
102
102
|
return { ok: false, message: "\u7F51\u7EDC\u9519\u8BEF\uFF0C\u8BF7\u91CD\u8BD5\u3002" };
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
var LISTEN_OPTIONS = [
|
|
106
|
-
{ value: "127.0.0.1", label: "127.0.0.1\uFF08\u4EC5\u672C\u673A\uFF09" },
|
|
107
|
-
{ value: "0.0.0.0", label: "0.0.0.0\uFF08\u6240\u6709\u7F51\u5361\uFF0C\u5C40\u57DF\u7F51\u53EF\u8BBF\u95EE\uFF09" }
|
|
108
|
-
];
|
|
109
105
|
function isListenHost(value) {
|
|
110
|
-
return value === "
|
|
106
|
+
return typeof value === "string" && value.length > 0;
|
|
111
107
|
}
|
|
112
108
|
async function fetchStatus() {
|
|
113
109
|
try {
|
|
114
110
|
const response = await fetch("/api/web-auth/status");
|
|
115
111
|
if (!response.ok) return null;
|
|
116
112
|
const data = await response.json();
|
|
117
|
-
return {
|
|
113
|
+
return {
|
|
114
|
+
configured: data.configured === true,
|
|
115
|
+
host: isListenHost(data.host) ? data.host : void 0,
|
|
116
|
+
allowed: Array.isArray(data.allowed) ? data.allowed : [],
|
|
117
|
+
needsMigration: data.needsMigration === true
|
|
118
|
+
};
|
|
118
119
|
} catch {
|
|
119
120
|
return null;
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
async function postListenHost(host) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch("/api/web-auth/listen", {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify({ host })
|
|
129
|
+
});
|
|
130
|
+
const data = await response.json();
|
|
131
|
+
if (response.ok && data.ok === true) return { ok: true };
|
|
132
|
+
return { ok: false, message: data.message ?? data.error ?? String(response.status) };
|
|
133
|
+
} catch {
|
|
134
|
+
return { ok: false, message: "\u7F51\u7EDC\u9519\u8BEF" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function WebAuthSettingsSection() {
|
|
128
138
|
const [configured, setConfigured] = (0, import_react.useState)(null);
|
|
129
139
|
const [host, setHost] = (0, import_react.useState)(null);
|
|
140
|
+
const [options, setOptions] = (0, import_react.useState)([]);
|
|
141
|
+
const [needsMigration, setNeedsMigration] = (0, import_react.useState)(false);
|
|
130
142
|
const [oldPassword, setOldPassword] = (0, import_react.useState)("");
|
|
131
143
|
const [newPassword, setNewPassword] = (0, import_react.useState)("");
|
|
132
144
|
const [confirm, setConfirm] = (0, import_react.useState)("");
|
|
133
145
|
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
134
146
|
const [message, setMessage] = (0, import_react.useState)(null);
|
|
135
|
-
const [listenPick, setListenPick] = (0, import_react.useState)("
|
|
147
|
+
const [listenPick, setListenPick] = (0, import_react.useState)("");
|
|
136
148
|
const [listenBusy, setListenBusy] = (0, import_react.useState)(false);
|
|
137
149
|
const [listenMsg, setListenMsg] = (0, import_react.useState)(null);
|
|
138
150
|
(0, import_react.useEffect)(() => {
|
|
@@ -140,16 +152,17 @@ var dshWebAuthModule = (() => {
|
|
|
140
152
|
void fetchStatus().then((status) => {
|
|
141
153
|
if (!alive || status === null) return;
|
|
142
154
|
setConfigured(status.configured);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
setOptions(status.allowed);
|
|
156
|
+
setNeedsMigration(status.needsMigration);
|
|
157
|
+
const selectable = status.host !== void 0 && status.allowed.some((o) => o.value === status.host);
|
|
158
|
+
const pick = selectable ? status.host : status.allowed[0]?.value ?? "";
|
|
159
|
+
setHost(status.host ?? null);
|
|
160
|
+
setListenPick(pick);
|
|
147
161
|
});
|
|
148
162
|
return () => {
|
|
149
163
|
alive = false;
|
|
150
164
|
};
|
|
151
165
|
}, []);
|
|
152
|
-
if (snapshot.status === "unavailable") return null;
|
|
153
166
|
const submit = async () => {
|
|
154
167
|
if (newPassword.length < 8) {
|
|
155
168
|
setMessage({ kind: "err", text: "\u65B0\u5BC6\u7801\u81F3\u5C11 8 \u4F4D\u3002" });
|
|
@@ -175,27 +188,30 @@ var dshWebAuthModule = (() => {
|
|
|
175
188
|
}
|
|
176
189
|
};
|
|
177
190
|
const applyListen = async () => {
|
|
178
|
-
if (host === listenPick) return;
|
|
191
|
+
if (listenPick.length === 0 || host === listenPick) return;
|
|
179
192
|
setListenBusy(true);
|
|
180
193
|
setListenMsg(null);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
|
|
184
|
-
for (let i = 0; i < 15; i++) {
|
|
185
|
-
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
186
|
-
const status = await fetchStatus();
|
|
187
|
-
if (status?.host === listenPick) {
|
|
188
|
-
setHost(listenPick);
|
|
189
|
-
setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
|
|
194
|
-
} catch {
|
|
195
|
-
setListenMsg({ kind: "err", text: "\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002" });
|
|
196
|
-
} finally {
|
|
194
|
+
const result = await postListenHost(listenPick);
|
|
195
|
+
if (!result.ok) {
|
|
197
196
|
setListenBusy(false);
|
|
197
|
+
setListenMsg({ kind: "err", text: "\u5199\u5165\u5931\u8D25\uFF1A" + String(result.message) });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5207\u6362\uFF0C\u6B63\u5728\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF0C\u9875\u9762\u5C06\u77ED\u6682\u65AD\u8FDE\u2026" });
|
|
201
|
+
for (let i = 0; i < 15; i++) {
|
|
202
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
203
|
+
const status = await fetchStatus();
|
|
204
|
+
if (status?.host === listenPick) {
|
|
205
|
+
setHost(listenPick);
|
|
206
|
+
setNeedsMigration(status.needsMigration);
|
|
207
|
+
setOptions(status.allowed);
|
|
208
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u751F\u6548\uFF1A\u76D1\u542C\u5730\u5740\u4E3A " + listenPick + "\u3002" });
|
|
209
|
+
setListenBusy(false);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
198
212
|
}
|
|
213
|
+
setListenMsg({ kind: "ok", text: "\u5DF2\u5E94\u7528\uFF0C\u82E5\u9875\u9762\u8FDE\u63A5\u4E2D\u65AD\u8BF7\u7A0D\u5019\u5237\u65B0\uFF08\u76D1\u542C\u5730\u5740\uFF1A" + listenPick + "\uFF09\u3002" });
|
|
214
|
+
setListenBusy(false);
|
|
199
215
|
};
|
|
200
216
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dwa-page", children: [
|
|
201
217
|
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "dwa-page-head", children: [
|
|
@@ -264,9 +280,9 @@ var dshWebAuthModule = (() => {
|
|
|
264
280
|
id: "dwa-listen",
|
|
265
281
|
className: "dwa-input",
|
|
266
282
|
value: listenPick,
|
|
267
|
-
disabled: listenBusy ||
|
|
283
|
+
disabled: listenBusy || options.length === 0,
|
|
268
284
|
onChange: (e) => setListenPick(e.target.value),
|
|
269
|
-
children:
|
|
285
|
+
children: options.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
|
|
270
286
|
}
|
|
271
287
|
)
|
|
272
288
|
] }),
|
|
@@ -274,31 +290,30 @@ var dshWebAuthModule = (() => {
|
|
|
274
290
|
"button",
|
|
275
291
|
{
|
|
276
292
|
className: "dwa-btn",
|
|
277
|
-
disabled: listenBusy ||
|
|
293
|
+
disabled: listenBusy || listenPick.length === 0 || host === listenPick,
|
|
278
294
|
onClick: () => void applyListen(),
|
|
279
295
|
children: "\u5E94\u7528\u76D1\u542C\u5730\u5740"
|
|
280
296
|
}
|
|
281
297
|
) }),
|
|
282
|
-
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\
|
|
298
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-hint", children: "\u5217\u8868\u662F\u672C\u673A\u7F51\u5361\u4E0A\u7684\u5177\u4F53\u5730\u5740\u3002dsh 0.2.1-alpha.2 \u8D77\u4E0D\u518D\u63A5\u53D7 0.0.0.0 / ::\uFF08\u901A\u914D\u7B26\u4F1A\u5728\u89E3\u6790\u914D\u7F6E\u9636\u6BB5\u76F4\u63A5\u8BA9 dsh \u542F\u52A8\u5931\u8D25\uFF09\uFF0C\u9700\u8981\u5C40\u57DF\u7F51\u6216\u53CD\u4EE3\u8BBF\u95EE\u8BF7\u9009\u62E9\u5BF9\u5E94\u7F51\u5361\u5730\u5740\uFF0C\u5E76\u4F18\u5148\u5728\u672C\u673A\u653E\u4E00\u5C42\u53CD\u4EE3\u6307\u5411 127.0.0.1\u3002 \u6CE8\u610F\uFF1A\u7ED1\u5B9A\u5177\u4F53\u7F51\u5361\u5730\u5740\u540E 127.0.0.1 \u4E0D\u518D\u76D1\u542C\uFF0C\u672C\u673A\u4E5F\u9700\u6539\u7528\u8BE5\u5730\u5740\u8BBF\u95EE\uFF1B\u82E5\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
|
|
299
|
+
needsMigration && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": "err", role: "alert", children: "\u5F53\u524D\u914D\u7F6E\u7684\u76D1\u542C\u5730\u5740\u4E0D\u662F\u672C\u63D2\u4EF6\u53EF\u5199\u5165\u7684\u5177\u4F53\u5730\u5740\uFF08\u4F8B\u5982\u5386\u53F2\u9057\u7559\u7684 0.0.0.0\uFF09\u3002\u8BF7\u6539\u9009\u4E00\u4E2A\u7F51\u5361\u5730\u5740\u540E\u5E94\u7528\u3002" }),
|
|
283
300
|
listenMsg !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dwa-msg", "data-kind": listenMsg.kind, role: "alert", children: listenMsg.text })
|
|
284
301
|
] })
|
|
285
302
|
] });
|
|
286
303
|
}
|
|
287
304
|
function apply(ctx) {
|
|
288
|
-
ctx.inject(["
|
|
305
|
+
ctx.inject(["slots"], (raw) => {
|
|
289
306
|
const c = raw;
|
|
290
|
-
if (c.settingsScope === void 0) return;
|
|
291
307
|
injectStyle();
|
|
292
|
-
const scope = c.settingsScope.bind({ namespace: WEB_AUTH_NS });
|
|
293
308
|
c.slots.inject(
|
|
294
309
|
"settings.section",
|
|
295
310
|
() => c.slots.register(
|
|
296
311
|
{
|
|
297
312
|
name: "settings.section",
|
|
298
|
-
id:
|
|
313
|
+
id: WEB_AUTH_NS,
|
|
299
314
|
order: 120,
|
|
300
315
|
label: "\u8BBF\u95EE\u8BA4\u8BC1",
|
|
301
|
-
inject: () => ({
|
|
316
|
+
inject: () => ({})
|
|
302
317
|
},
|
|
303
318
|
WebAuthSettingsSection
|
|
304
319
|
)
|
package/lib/handlers.d.ts
CHANGED
|
@@ -80,8 +80,12 @@ export declare function pathnameOf(req: IncomingMessage): string;
|
|
|
80
80
|
export declare function cookieValue(header: string | undefined, name: string): string | undefined;
|
|
81
81
|
/**
|
|
82
82
|
* The client address for rate limiting. X-Forwarded-For is honored ONLY when
|
|
83
|
-
* the direct peer is
|
|
84
|
-
*
|
|
83
|
+
* the direct peer is local (a reverse proxy on this host), so a remote
|
|
84
|
+
* attacker hitting dsh directly cannot forge a fresh identity.
|
|
85
|
+
*
|
|
86
|
+
* "Local" is the machine's own address set, not just loopback: a concrete LAN
|
|
87
|
+
* bind stops listening on `127.0.0.1` entirely, so a same-host proxy arrives
|
|
88
|
+
* FROM the bound address. See isLocalPeerAddress.
|
|
85
89
|
*/
|
|
86
90
|
export declare function clientIp(req: IncomingMessage): string;
|
|
87
91
|
/** Whether the browser's connection is HTTPS (reverse-proxied or direct). */
|
|
@@ -128,6 +132,19 @@ export declare function createGate(env: HandlerEnv): Gate;
|
|
|
128
132
|
export declare function handleLoginPage(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
129
133
|
export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
|
|
130
134
|
export declare function handleLogout(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
135
|
+
/**
|
|
136
|
+
* The settings page's state: whether a password is configured, the current
|
|
137
|
+
* bind host, the writable concrete addresses of this machine, and whether the
|
|
138
|
+
* configured host needs migrating.
|
|
139
|
+
*
|
|
140
|
+
* `allowed` holds only addresses this plugin may write (loopback plus the
|
|
141
|
+
* machine's interfaces), so the page can never offer a value the write path
|
|
142
|
+
* would refuse. `needsMigration` is true when the resolved host is outside
|
|
143
|
+
* that domain — the wildcard (only reachable on hosts older than
|
|
144
|
+
* 0.2.1-alpha.2, since a newer boot would have failed already), a hostname,
|
|
145
|
+
* or an address this machine does not have; the page warns and preselects a
|
|
146
|
+
* concrete address instead of echoing a value that would be refused.
|
|
147
|
+
*/
|
|
131
148
|
export declare function handleStatus(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): void;
|
|
132
149
|
export interface ChangePasswordBody {
|
|
133
150
|
oldPassword?: string;
|
|
@@ -138,7 +155,7 @@ export interface ListenChangeBody {
|
|
|
138
155
|
host?: unknown;
|
|
139
156
|
}
|
|
140
157
|
/**
|
|
141
|
-
* Switch the web server's bind host
|
|
158
|
+
* Switch the web server's bind host to a concrete local interface address.
|
|
142
159
|
*
|
|
143
160
|
* The change is applied to the owning patch layer (profile or home
|
|
144
161
|
* cordis.patch.yml); HMR then reloads the webserver row, which closes and
|
|
@@ -146,5 +163,10 @@ export interface ListenChangeBody {
|
|
|
146
163
|
* committed — the reload tears down every connection, including this one —
|
|
147
164
|
* so the client gets its confirmation first and the write lands a beat
|
|
148
165
|
* later (see {@link ListenController}).
|
|
166
|
+
*
|
|
167
|
+
* The wildcard is refused with 400: it is not a bind this plugin may store,
|
|
168
|
+
* because dsh >= 0.2.1-alpha.2 fails the whole startup on it while resolving
|
|
169
|
+
* config. The response lists the selectable addresses so a stale client can
|
|
170
|
+
* correct itself.
|
|
149
171
|
*/
|
|
150
172
|
export declare function handleListenChange(req: IncomingMessage, res: ServerResponse, env: HandlerEnv): Promise<void>;
|
package/lib/handlers.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { COOKIE_NAME, MIN_PASSWORD_LENGTH, SESSION_TTL_SECONDS, hashPassword, needsSessionRefresh, sanitizeNext, signSession, verifyPassword, verifySession } from "./auth-core.js";
|
|
3
3
|
import { renderLoginPage } from "./login-page.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isAllowedListenHost } from "./profile-patch.js";
|
|
5
|
+
import { isLocalPeerAddress, listListenHosts } from "./listen-addresses.js";
|
|
5
6
|
/** Maximum accepted request body for the login form / API. */
|
|
6
7
|
export const MAX_BODY_BYTES = 16 * 1024;
|
|
7
8
|
// ── small request helpers ───────────────────────────────────────────────────
|
|
@@ -23,13 +24,16 @@ export function cookieValue(header, name) {
|
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
26
|
* The client address for rate limiting. X-Forwarded-For is honored ONLY when
|
|
26
|
-
* the direct peer is
|
|
27
|
-
*
|
|
27
|
+
* the direct peer is local (a reverse proxy on this host), so a remote
|
|
28
|
+
* attacker hitting dsh directly cannot forge a fresh identity.
|
|
29
|
+
*
|
|
30
|
+
* "Local" is the machine's own address set, not just loopback: a concrete LAN
|
|
31
|
+
* bind stops listening on `127.0.0.1` entirely, so a same-host proxy arrives
|
|
32
|
+
* FROM the bound address. See isLocalPeerAddress.
|
|
28
33
|
*/
|
|
29
34
|
export function clientIp(req) {
|
|
30
35
|
const direct = req.socket.remoteAddress ?? 'unknown';
|
|
31
|
-
|
|
32
|
-
if (loopback) {
|
|
36
|
+
if (isLocalPeerAddress(direct)) {
|
|
33
37
|
const xff = req.headers['x-forwarded-for'];
|
|
34
38
|
if (typeof xff === 'string') {
|
|
35
39
|
const first = xff.split(',')[0]?.trim();
|
|
@@ -155,16 +159,17 @@ export function tokenQueryCount(req) {
|
|
|
155
159
|
*/
|
|
156
160
|
export const MINT_REENTRY_HEADER = 'x-dsh-web-auth-mint';
|
|
157
161
|
/**
|
|
158
|
-
* Whether this tokened request is the server-side mint's own
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
+
* Whether this tokened request is the server-side mint's own local re-entry:
|
|
163
|
+
* the marker header AND a direct local TCP peer (not clientIp's XFF-aware
|
|
164
|
+
* view — a proxied remote client must not pass even if it forges the header,
|
|
165
|
+
* and the mint never traverses the proxy). "Local" covers the bound address
|
|
166
|
+
* as well as loopback, because a concrete LAN bind has no loopback listener
|
|
167
|
+
* for the mint to come back through.
|
|
162
168
|
*/
|
|
163
169
|
function isMintReentry(req) {
|
|
164
170
|
if (req.headers[MINT_REENTRY_HEADER] !== '1')
|
|
165
171
|
return false;
|
|
166
|
-
|
|
167
|
-
return direct === '127.0.0.1' || direct === '::1' || direct === '::ffff:127.0.0.1';
|
|
172
|
+
return isLocalPeerAddress(req.socket.remoteAddress ?? undefined);
|
|
168
173
|
}
|
|
169
174
|
/**
|
|
170
175
|
* Build the gate used to wrap the HTTP server listeners. Whitelisted paths
|
|
@@ -470,9 +475,27 @@ export function handleLogout(req, res, env) {
|
|
|
470
475
|
redirect(res, '/login?loggedOut=1');
|
|
471
476
|
}
|
|
472
477
|
// ── GET /api/web-auth/status — settings page state ─────────────────────────
|
|
478
|
+
/**
|
|
479
|
+
* The settings page's state: whether a password is configured, the current
|
|
480
|
+
* bind host, the writable concrete addresses of this machine, and whether the
|
|
481
|
+
* configured host needs migrating.
|
|
482
|
+
*
|
|
483
|
+
* `allowed` holds only addresses this plugin may write (loopback plus the
|
|
484
|
+
* machine's interfaces), so the page can never offer a value the write path
|
|
485
|
+
* would refuse. `needsMigration` is true when the resolved host is outside
|
|
486
|
+
* that domain — the wildcard (only reachable on hosts older than
|
|
487
|
+
* 0.2.1-alpha.2, since a newer boot would have failed already), a hostname,
|
|
488
|
+
* or an address this machine does not have; the page warns and preselects a
|
|
489
|
+
* concrete address instead of echoing a value that would be refused.
|
|
490
|
+
*/
|
|
473
491
|
export function handleStatus(req, res, env) {
|
|
474
|
-
const body = { configured: env.state.readHash() !== undefined };
|
|
475
492
|
const current = env.listen?.current();
|
|
493
|
+
const options = listListenHosts();
|
|
494
|
+
const body = {
|
|
495
|
+
configured: env.state.readHash() !== undefined,
|
|
496
|
+
allowed: options,
|
|
497
|
+
needsMigration: current !== undefined && !isAllowedListenHost(current)
|
|
498
|
+
};
|
|
476
499
|
if (current !== undefined)
|
|
477
500
|
body.host = current;
|
|
478
501
|
writeJson(res, 200, body);
|
|
@@ -521,7 +544,7 @@ export async function handleChangePassword(req, res, env) {
|
|
|
521
544
|
writeJson(res, 200, { ok: true, sessionsInvalidated: true });
|
|
522
545
|
}
|
|
523
546
|
/**
|
|
524
|
-
* Switch the web server's bind host
|
|
547
|
+
* Switch the web server's bind host to a concrete local interface address.
|
|
525
548
|
*
|
|
526
549
|
* The change is applied to the owning patch layer (profile or home
|
|
527
550
|
* cordis.patch.yml); HMR then reloads the webserver row, which closes and
|
|
@@ -529,6 +552,11 @@ export async function handleChangePassword(req, res, env) {
|
|
|
529
552
|
* committed — the reload tears down every connection, including this one —
|
|
530
553
|
* so the client gets its confirmation first and the write lands a beat
|
|
531
554
|
* later (see {@link ListenController}).
|
|
555
|
+
*
|
|
556
|
+
* The wildcard is refused with 400: it is not a bind this plugin may store,
|
|
557
|
+
* because dsh >= 0.2.1-alpha.2 fails the whole startup on it while resolving
|
|
558
|
+
* config. The response lists the selectable addresses so a stale client can
|
|
559
|
+
* correct itself.
|
|
532
560
|
*/
|
|
533
561
|
export async function handleListenChange(req, res, env) {
|
|
534
562
|
const body = await readBody(req);
|
|
@@ -545,7 +573,11 @@ export async function handleListenChange(req, res, env) {
|
|
|
545
573
|
return;
|
|
546
574
|
}
|
|
547
575
|
if (!isAllowedListenHost(parsed.host)) {
|
|
548
|
-
writeJson(res, 400, {
|
|
576
|
+
writeJson(res, 400, {
|
|
577
|
+
error: 'invalid-host',
|
|
578
|
+
message: 'bind a concrete IPv4/IPv6 address this machine has (the wildcard and unowned addresses are refused)',
|
|
579
|
+
allowed: listListenHosts()
|
|
580
|
+
});
|
|
549
581
|
return;
|
|
550
582
|
}
|
|
551
583
|
const listen = env.listen;
|
package/lib/index.js
CHANGED
|
@@ -22,8 +22,8 @@ import { RateLimiter } from "./auth-core.js";
|
|
|
22
22
|
import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
|
|
23
23
|
import { MINT_REENTRY_HEADER, createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
|
|
24
24
|
import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
|
|
25
|
-
import { installWebAuthSettings } from "./settings.js";
|
|
26
25
|
import { injectOwnsHost } from "./remote-unlock.js";
|
|
26
|
+
import { resetLocalAddressCache } from "./listen-addresses.js";
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
29
|
import z from '@deepseek-ai/schemastery';
|
|
@@ -106,11 +106,20 @@ export function apply(ctx, config) {
|
|
|
106
106
|
// away; failures are logged, never thrown — the client was confirmed.
|
|
107
107
|
listen: buildListenController(ctx)
|
|
108
108
|
};
|
|
109
|
-
// The
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// only
|
|
113
|
-
|
|
109
|
+
// The listen host is changed through the plugin's own REST surface
|
|
110
|
+
// (/api/web-auth/listen) and applied to the patch layer by `env.listen`:
|
|
111
|
+
// the webserver's bind host is composition config, and the patch is the
|
|
112
|
+
// only place the loader reads it from. The value domain is a concrete
|
|
113
|
+
// local interface address — never the wildcard, which fails the whole
|
|
114
|
+
// startup on dsh >= 0.2.1-alpha.2 (see listen-addresses.ts).
|
|
115
|
+
//
|
|
116
|
+
// Earlier releases mirrored the value into a `web-auth` settings namespace
|
|
117
|
+
// (settings.register + scope.watch). That API no longer exists: dsh
|
|
118
|
+
// >= 0.2.1-alpha.2 replaced it with schema-driven SettingsForms
|
|
119
|
+
// (describe/update keyed by profile entry id), and the browser-side
|
|
120
|
+
// `settingsScope` service is gone with it. The namespace registration is
|
|
121
|
+
// therefore removed rather than left calling a method that is no longer
|
|
122
|
+
// there; the settings page talks to the REST endpoints below.
|
|
114
123
|
if (state.readHash() === undefined) {
|
|
115
124
|
// console.error, not ctx.logger.warn: the cordis default logger level
|
|
116
125
|
// suppresses warn (level 2) unless the deployment raises it, and this
|
|
@@ -212,7 +221,12 @@ function buildDshCookieMinter(ctx) {
|
|
|
212
221
|
return async () => {
|
|
213
222
|
if (connection === undefined)
|
|
214
223
|
return undefined;
|
|
215
|
-
|
|
224
|
+
// The bind host is a concrete literal (dsh rejects the wildcard outright),
|
|
225
|
+
// so the mint re-enters through the very address the server listens on.
|
|
226
|
+
// The legacy wildcard mapping stays as belt-and-braces: on a host old
|
|
227
|
+
// enough to accept it, loopback is the reachable spelling of "any".
|
|
228
|
+
const bound = ctx.webServer.host;
|
|
229
|
+
const host = bound === '0.0.0.0' || bound === '::' ? '127.0.0.1' : bound;
|
|
216
230
|
try {
|
|
217
231
|
const url = connection.authenticatedUrl(`http://${host}:${String(ctx.webServer.port)}`);
|
|
218
232
|
// The marker header is what the gate's token branch keys on to forward
|
|
@@ -263,6 +277,10 @@ function buildListenController(ctx) {
|
|
|
263
277
|
setTimeout(() => {
|
|
264
278
|
try {
|
|
265
279
|
writeWebserverHostPatch(patchPath, content);
|
|
280
|
+
// The listener is about to move to another interface: drop the
|
|
281
|
+
// cached local-address set so peer trust follows the new bind
|
|
282
|
+
// immediately instead of after the TTL.
|
|
283
|
+
resetLocalAddressCache();
|
|
266
284
|
}
|
|
267
285
|
catch (error) {
|
|
268
286
|
console.error('[dsh-web-auth] failed to apply listen address:', error);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The always-offered loopback bind (this plugin's default and the safe choice). */
|
|
2
|
+
export declare const LOOPBACK_LISTEN_HOST = "127.0.0.1";
|
|
3
|
+
/** Strip an IPv6 zone id (`fe80::1%en0` → `fe80::1`). */
|
|
4
|
+
export declare function stripZoneId(value: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Whether a value is an IPv4/IPv6 literal (hostnames are rejected by the
|
|
7
|
+
* webserver's own `parseIpLiteral`, so they are rejected here too).
|
|
8
|
+
*/
|
|
9
|
+
export declare function isIpLiteral(value: unknown): value is string;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a literal is the unspecified (wildcard) address: `0.0.0.0`, any
|
|
12
|
+
* spelling of IPv6 `::`, and their IPv4-mapped forms (`::ffff:0.0.0.0`).
|
|
13
|
+
* Non-literals are not wildcards — {@link isConcreteListenHost} rejects them.
|
|
14
|
+
*
|
|
15
|
+
* IPv6 is normalized through the WHATWG URL parser, which collapses every
|
|
16
|
+
* all-zero spelling to `[::]` and renders IPv4-mapped forms as hex groups, so
|
|
17
|
+
* `0:0:0:0:0:0:0:0`, `::0` and `::ffff:0.0.0.0` are all recognized.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isWildcardListenHost(value: unknown): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Whether a value is a bind host the webserver's config schema accepts: a
|
|
22
|
+
* concrete IPv4/IPv6 literal that is not the wildcard.
|
|
23
|
+
*
|
|
24
|
+
* This is the SCHEMA's domain, not this plugin's write domain — see
|
|
25
|
+
* {@link isLocalListenHost}, which every write path actually runs (the schema
|
|
26
|
+
* also accepts literals the machine does not own, and writing one of those
|
|
27
|
+
* breaks the next boot at bind time).
|
|
28
|
+
*/
|
|
29
|
+
export declare function isConcreteListenHost(value: unknown): value is string;
|
|
30
|
+
/** One selectable bind host for the settings page. */
|
|
31
|
+
export interface ListenHostOption {
|
|
32
|
+
/** The literal written into the patch layer. */
|
|
33
|
+
value: string;
|
|
34
|
+
/** Human-facing label (address + interface name). */
|
|
35
|
+
label: string;
|
|
36
|
+
/** Whether this option keeps the service unreachable from the network. */
|
|
37
|
+
loopback: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** Whether a literal is in the loopback range (127/8 or `::1`, mapped forms included). */
|
|
40
|
+
export declare function isLoopbackListenHost(value: unknown): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The bind hosts offered by the settings page: loopback first, then every
|
|
43
|
+
* concrete address of a local interface (labelled with the interface name).
|
|
44
|
+
* Every entry is writable — see {@link isLocalListenHost}, which is the write
|
|
45
|
+
* domain this list is drawn from.
|
|
46
|
+
*
|
|
47
|
+
* @returns The ordered options; always at least the loopback default.
|
|
48
|
+
*/
|
|
49
|
+
export declare function listListenHosts(): ListenHostOption[];
|
|
50
|
+
/**
|
|
51
|
+
* Whether a value is a bind host this plugin may WRITE: a concrete literal
|
|
52
|
+
* naming an address this machine actually has.
|
|
53
|
+
*
|
|
54
|
+
* Stricter than {@link isConcreteListenHost}, and deliberately so. A literal
|
|
55
|
+
* the machine does not own (`127.0.0.2` on macOS, `10.99.99.99`, a stale DHCP
|
|
56
|
+
* lease) passes the webserver's schema and is written happily — then fails at
|
|
57
|
+
* bind time with EADDRNOTAVAIL, so the NEXT boot dies the same way the
|
|
58
|
+
* wildcard does, with this plugin unable to help. Restricting writes to
|
|
59
|
+
* addresses the machine has turns that into an immediate 400.
|
|
60
|
+
*
|
|
61
|
+
* @param value - the requested bind host.
|
|
62
|
+
* @param now - clock injection for the interface-cache TTL (tests).
|
|
63
|
+
* @returns Whether the value may be written to the patch layer.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isLocalListenHost(value: unknown, now?: number): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Whether a TCP peer address belongs to this machine (loopback or any local
|
|
68
|
+
* interface address).
|
|
69
|
+
*
|
|
70
|
+
* This replaced a hard loopback test: with a concrete LAN address bound,
|
|
71
|
+
* `127.0.0.1` is not listening at all, so the server's own re-entry (the DSH
|
|
72
|
+
* cookie mint) and a same-host reverse proxy both arrive FROM the bound
|
|
73
|
+
* address. An off-host client cannot present a local address as its source
|
|
74
|
+
* (that needs L2 spoofing on the same segment), so treating the machine's own
|
|
75
|
+
* addresses as trusted keeps the X-Forwarded-For fence and the mint marker as
|
|
76
|
+
* tight as the loopback-only version on a loopback bind. Any 127/8 spelling
|
|
77
|
+
* counts — a peer claiming that range is by definition on this host.
|
|
78
|
+
*
|
|
79
|
+
* @param address - `socket.remoteAddress` (undefined before a socket exists).
|
|
80
|
+
* @param now - clock injection for the cache TTL (tests).
|
|
81
|
+
* @returns Whether the peer is this machine.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isLocalPeerAddress(address: string | undefined, now?: number): boolean;
|
|
84
|
+
/** Drop the cached interface sets (tests, and after a listen-address change). */
|
|
85
|
+
export declare function resetLocalAddressCache(): void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — the bind-address domain: which `host` values the webserver
|
|
3
|
+
* accepts, which local interfaces can be offered to the user, and whether a
|
|
4
|
+
* TCP peer is local.
|
|
5
|
+
*
|
|
6
|
+
* dsh >= 0.2.1-alpha.2 validates the webserver's `host` as a CONCRETE
|
|
7
|
+
* IPv4/IPv6 literal and rejects the unspecified (wildcard) address outright:
|
|
8
|
+
*
|
|
9
|
+
* webserver: host "0.0.0.0" is an unspecified (wildcard) address, which is
|
|
10
|
+
* not supported: binding every interface would expose remote code execution
|
|
11
|
+
* to the network; bind one concrete IPv4 or IPv6 address of a local
|
|
12
|
+
* interface instead
|
|
13
|
+
*
|
|
14
|
+
* The rejection happens while the loader RESOLVES CONFIG — before any plugin
|
|
15
|
+
* fiber activates. A wildcard written into the profile patch therefore bricks
|
|
16
|
+
* the whole boot (webserver never activates, every dependent plugin waits
|
|
17
|
+
* forever) and NOTHING can self-repair it: this plugin does not load either.
|
|
18
|
+
* That is why every write path in this plugin runs {@link isConcreteListenHost}
|
|
19
|
+
* and refuses a wildcard instead of writing it.
|
|
20
|
+
*
|
|
21
|
+
* `dsh web --host` is validated by the same rule (`isWildcardHost`), and
|
|
22
|
+
* `parseIpLiteral` rejects hostnames, so only IP literals are ever written.
|
|
23
|
+
*/
|
|
24
|
+
import { isIP } from 'node:net';
|
|
25
|
+
import { networkInterfaces } from 'node:os';
|
|
26
|
+
/** The always-offered loopback bind (this plugin's default and the safe choice). */
|
|
27
|
+
export const LOOPBACK_LISTEN_HOST = '127.0.0.1';
|
|
28
|
+
/** Normalized IPv6 forms whose bits are all zero (the IPv4-any-mapped form included). */
|
|
29
|
+
const WILDCARD_IPV6_FORMS = new Set(['[::]', '[::ffff:0:0]']);
|
|
30
|
+
/** Direct peers always considered local, whichever spelling the socket reports. */
|
|
31
|
+
const LOOPBACK_PEERS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
32
|
+
/** Strip an IPv6 zone id (`fe80::1%en0` → `fe80::1`). */
|
|
33
|
+
export function stripZoneId(value) {
|
|
34
|
+
const at = value.indexOf('%');
|
|
35
|
+
return at === -1 ? value : value.slice(0, at);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether a value is an IPv4/IPv6 literal (hostnames are rejected by the
|
|
39
|
+
* webserver's own `parseIpLiteral`, so they are rejected here too).
|
|
40
|
+
*/
|
|
41
|
+
export function isIpLiteral(value) {
|
|
42
|
+
return typeof value === 'string' && isIP(stripZoneId(value)) !== 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether a literal is the unspecified (wildcard) address: `0.0.0.0`, any
|
|
46
|
+
* spelling of IPv6 `::`, and their IPv4-mapped forms (`::ffff:0.0.0.0`).
|
|
47
|
+
* Non-literals are not wildcards — {@link isConcreteListenHost} rejects them.
|
|
48
|
+
*
|
|
49
|
+
* IPv6 is normalized through the WHATWG URL parser, which collapses every
|
|
50
|
+
* all-zero spelling to `[::]` and renders IPv4-mapped forms as hex groups, so
|
|
51
|
+
* `0:0:0:0:0:0:0:0`, `::0` and `::ffff:0.0.0.0` are all recognized.
|
|
52
|
+
*/
|
|
53
|
+
export function isWildcardListenHost(value) {
|
|
54
|
+
if (typeof value !== 'string')
|
|
55
|
+
return false;
|
|
56
|
+
const bare = stripZoneId(value);
|
|
57
|
+
const family = isIP(bare);
|
|
58
|
+
if (family === 4)
|
|
59
|
+
return bare.split('.').every((octet) => octet === '0');
|
|
60
|
+
if (family !== 6)
|
|
61
|
+
return false;
|
|
62
|
+
try {
|
|
63
|
+
return WILDCARD_IPV6_FORMS.has(new URL('http://[' + bare + ']').hostname);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether a value is a bind host the webserver's config schema accepts: a
|
|
71
|
+
* concrete IPv4/IPv6 literal that is not the wildcard.
|
|
72
|
+
*
|
|
73
|
+
* This is the SCHEMA's domain, not this plugin's write domain — see
|
|
74
|
+
* {@link isLocalListenHost}, which every write path actually runs (the schema
|
|
75
|
+
* also accepts literals the machine does not own, and writing one of those
|
|
76
|
+
* breaks the next boot at bind time).
|
|
77
|
+
*/
|
|
78
|
+
export function isConcreteListenHost(value) {
|
|
79
|
+
return isIpLiteral(value) && !isWildcardListenHost(value);
|
|
80
|
+
}
|
|
81
|
+
/** Whether a literal is in the loopback range (127/8 or `::1`, mapped forms included). */
|
|
82
|
+
export function isLoopbackListenHost(value) {
|
|
83
|
+
if (typeof value !== 'string')
|
|
84
|
+
return false;
|
|
85
|
+
const bare = stripZoneId(value);
|
|
86
|
+
if (isIP(bare) === 4 && bare.startsWith('127.'))
|
|
87
|
+
return true;
|
|
88
|
+
if (isIP(bare) !== 6)
|
|
89
|
+
return false;
|
|
90
|
+
try {
|
|
91
|
+
const hostname = new URL('http://[' + bare + ']').hostname;
|
|
92
|
+
// `[::1]` and the `::ffff:127.0.0.0/104` mapped range (`[::ffff:7f00:1]`).
|
|
93
|
+
return hostname === '[::1]' || hostname.startsWith('[::ffff:7f');
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Link-local addresses need a zone id to bind and are never the intended
|
|
101
|
+
* choice (`169.254.0.0/16`, `fe80::/10`), so they are not offered.
|
|
102
|
+
*/
|
|
103
|
+
function isLinkLocal(value) {
|
|
104
|
+
if (isIP(value) === 4)
|
|
105
|
+
return value.startsWith('169.254.');
|
|
106
|
+
return /^fe[89ab]/i.test(value);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The bind hosts offered by the settings page: loopback first, then every
|
|
110
|
+
* concrete address of a local interface (labelled with the interface name).
|
|
111
|
+
* Every entry is writable — see {@link isLocalListenHost}, which is the write
|
|
112
|
+
* domain this list is drawn from.
|
|
113
|
+
*
|
|
114
|
+
* @returns The ordered options; always at least the loopback default.
|
|
115
|
+
*/
|
|
116
|
+
export function listListenHosts() {
|
|
117
|
+
const options = [
|
|
118
|
+
{ value: LOOPBACK_LISTEN_HOST, label: '127.0.0.1(仅本机)', loopback: true }
|
|
119
|
+
];
|
|
120
|
+
const seen = new Set([LOOPBACK_LISTEN_HOST]);
|
|
121
|
+
for (const [name, addresses] of Object.entries(networkInterfaces())) {
|
|
122
|
+
for (const entry of addresses ?? []) {
|
|
123
|
+
const value = stripZoneId(entry.address);
|
|
124
|
+
if (entry.internal || isLinkLocal(value))
|
|
125
|
+
continue;
|
|
126
|
+
if (!isConcreteListenHost(value) || seen.has(value))
|
|
127
|
+
continue;
|
|
128
|
+
seen.add(value);
|
|
129
|
+
const family = isIP(value) === 6 ? 'IPv6' : 'IPv4';
|
|
130
|
+
options.push({
|
|
131
|
+
value,
|
|
132
|
+
label: `${value}(${name},${family},局域网可访问)`,
|
|
133
|
+
loopback: false
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return options;
|
|
138
|
+
}
|
|
139
|
+
/** Cached interface-address sets (interfaces change rarely; a TTL bounds staleness). */
|
|
140
|
+
let interfaceCache;
|
|
141
|
+
const INTERFACE_CACHE_TTL_MS = 10_000;
|
|
142
|
+
/**
|
|
143
|
+
* The addresses this machine actually has, from `os.networkInterfaces()`.
|
|
144
|
+
*
|
|
145
|
+
* `owned` is the set a bind may use: exactly the assigned addresses, plus
|
|
146
|
+
* `127.0.0.1`, which every platform assigns to its loopback interface (the
|
|
147
|
+
* explicit seed keeps the write domain usable even if enumeration reports an
|
|
148
|
+
* empty loopback entry). Note the set is NOT "any 127/8": on macOS only
|
|
149
|
+
* `127.0.0.1` is assigned, so `127.0.0.2` fails to bind with EADDRNOTAVAIL.
|
|
150
|
+
*
|
|
151
|
+
* `peers` additionally carries the IPv4-mapped IPv6 spelling of every IPv4
|
|
152
|
+
* address, because a dual-stack socket reports the mapped form as
|
|
153
|
+
* `remoteAddress`.
|
|
154
|
+
*/
|
|
155
|
+
function interfaceAddresses(now) {
|
|
156
|
+
if (interfaceCache !== undefined && interfaceCache.expires > now)
|
|
157
|
+
return interfaceCache;
|
|
158
|
+
const owned = new Set([LOOPBACK_LISTEN_HOST]);
|
|
159
|
+
const peers = new Set(LOOPBACK_PEERS);
|
|
160
|
+
for (const entry of Object.values(networkInterfaces())) {
|
|
161
|
+
for (const address of entry ?? []) {
|
|
162
|
+
const value = stripZoneId(address.address);
|
|
163
|
+
owned.add(value);
|
|
164
|
+
peers.add(value);
|
|
165
|
+
// The mapped spelling is the same socket peer on a dual-stack listener.
|
|
166
|
+
if (isIP(value) === 4)
|
|
167
|
+
peers.add('::ffff:' + value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
interfaceCache = { expires: now + INTERFACE_CACHE_TTL_MS, owned, peers };
|
|
171
|
+
return interfaceCache;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether a value is a bind host this plugin may WRITE: a concrete literal
|
|
175
|
+
* naming an address this machine actually has.
|
|
176
|
+
*
|
|
177
|
+
* Stricter than {@link isConcreteListenHost}, and deliberately so. A literal
|
|
178
|
+
* the machine does not own (`127.0.0.2` on macOS, `10.99.99.99`, a stale DHCP
|
|
179
|
+
* lease) passes the webserver's schema and is written happily — then fails at
|
|
180
|
+
* bind time with EADDRNOTAVAIL, so the NEXT boot dies the same way the
|
|
181
|
+
* wildcard does, with this plugin unable to help. Restricting writes to
|
|
182
|
+
* addresses the machine has turns that into an immediate 400.
|
|
183
|
+
*
|
|
184
|
+
* @param value - the requested bind host.
|
|
185
|
+
* @param now - clock injection for the interface-cache TTL (tests).
|
|
186
|
+
* @returns Whether the value may be written to the patch layer.
|
|
187
|
+
*/
|
|
188
|
+
export function isLocalListenHost(value, now = Date.now()) {
|
|
189
|
+
if (!isConcreteListenHost(value))
|
|
190
|
+
return false;
|
|
191
|
+
return interfaceAddresses(now).owned.has(stripZoneId(value));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Whether a TCP peer address belongs to this machine (loopback or any local
|
|
195
|
+
* interface address).
|
|
196
|
+
*
|
|
197
|
+
* This replaced a hard loopback test: with a concrete LAN address bound,
|
|
198
|
+
* `127.0.0.1` is not listening at all, so the server's own re-entry (the DSH
|
|
199
|
+
* cookie mint) and a same-host reverse proxy both arrive FROM the bound
|
|
200
|
+
* address. An off-host client cannot present a local address as its source
|
|
201
|
+
* (that needs L2 spoofing on the same segment), so treating the machine's own
|
|
202
|
+
* addresses as trusted keeps the X-Forwarded-For fence and the mint marker as
|
|
203
|
+
* tight as the loopback-only version on a loopback bind. Any 127/8 spelling
|
|
204
|
+
* counts — a peer claiming that range is by definition on this host.
|
|
205
|
+
*
|
|
206
|
+
* @param address - `socket.remoteAddress` (undefined before a socket exists).
|
|
207
|
+
* @param now - clock injection for the cache TTL (tests).
|
|
208
|
+
* @returns Whether the peer is this machine.
|
|
209
|
+
*/
|
|
210
|
+
export function isLocalPeerAddress(address, now = Date.now()) {
|
|
211
|
+
if (address === undefined || address.length === 0)
|
|
212
|
+
return false;
|
|
213
|
+
const bare = stripZoneId(address);
|
|
214
|
+
if (LOOPBACK_PEERS.has(bare))
|
|
215
|
+
return true;
|
|
216
|
+
if (isLoopbackListenHost(bare))
|
|
217
|
+
return true;
|
|
218
|
+
return interfaceAddresses(now).peers.has(bare);
|
|
219
|
+
}
|
|
220
|
+
/** Drop the cached interface sets (tests, and after a listen-address change). */
|
|
221
|
+
export function resetLocalAddressCache() {
|
|
222
|
+
interfaceCache = undefined;
|
|
223
|
+
}
|
package/lib/profile-patch.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A writable bind host: a concrete literal naming an address this machine
|
|
3
|
+
* has. A plain alias rather than a closed union — the domain is machine
|
|
4
|
+
* dependent, enforced at runtime by {@link isAllowedListenHost} and offered to
|
|
5
|
+
* the UI by listen-addresses.ts.
|
|
6
|
+
*/
|
|
7
|
+
export type ListenHost = string;
|
|
4
8
|
/**
|
|
5
9
|
* Delay between confirming the change to the browser and writing the patch
|
|
6
10
|
* file. The HMR watcher fires within milliseconds of the write and the
|
|
@@ -8,7 +12,11 @@ export type ListenHost = (typeof ALLOWED_LISTEN_HOSTS)[number];
|
|
|
8
12
|
* the confirmation — so the write lands a beat AFTER the response flushed.
|
|
9
13
|
*/
|
|
10
14
|
export declare const LISTEN_APPLY_DELAY_MS = 300;
|
|
11
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Whether a value is a bind host that is safe (and accepted) to write: a
|
|
17
|
+
* concrete literal naming an address of this machine. See isLocalListenHost
|
|
18
|
+
* for why the wildcard AND non-local literals are both refused.
|
|
19
|
+
*/
|
|
12
20
|
export declare function isAllowedListenHost(value: unknown): value is ListenHost;
|
|
13
21
|
/** Whether a patch document already spells the webserver row. */
|
|
14
22
|
export declare function patchHasWebserverEntry(content: string): boolean;
|
|
@@ -18,11 +26,15 @@ export declare function patchHasWebserverEntry(content: string): boolean;
|
|
|
18
26
|
* - entry present with a `host:` under `config:` → the value is replaced;
|
|
19
27
|
* - entry present without `host:` → the line is inserted under `config:`;
|
|
20
28
|
* - entry present without `config:` → a config block is appended;
|
|
21
|
-
* - no entry at all → a complete row is
|
|
29
|
+
* - no entry at all → a complete row is written (name/inject/port restated):
|
|
30
|
+
* replacing the generated template's `[]` marker when present, otherwise
|
|
31
|
+
* appended as a new block item.
|
|
22
32
|
*
|
|
23
33
|
* @param content - the patch file's current text.
|
|
24
|
-
* @param host - the target bind host
|
|
34
|
+
* @param host - the target bind host (an address this machine owns).
|
|
25
35
|
* @returns the next file text; every other byte is untouched.
|
|
36
|
+
* @throws When `host` is not an address this machine owns, or the document is
|
|
37
|
+
* flow-style and cannot be extended as valid YAML — see refuseListenHost.
|
|
26
38
|
*/
|
|
27
39
|
export declare function setWebserverHost(content: string, host: ListenHost): string;
|
|
28
40
|
/** Read a patch file as text; a missing file reads as the empty document. */
|
|
@@ -36,7 +48,8 @@ export declare function readPatchOrEmpty(patchPath: string): string;
|
|
|
36
48
|
export declare function resolveListenPatchPath(profilePatch: string, homePatch: string): string;
|
|
37
49
|
/**
|
|
38
50
|
* Read + transform the owning patch layer for `host`, returning the next
|
|
39
|
-
* file content. Throws when the file cannot be read
|
|
51
|
+
* file content. Throws when the file cannot be read, or when `host` is not a
|
|
52
|
+
* concrete address (the wildcard refusal runs before any read).
|
|
40
53
|
*/
|
|
41
54
|
export declare function prepareWebserverHostPatch(patchPath: string, host: ListenHost): string;
|
|
42
55
|
/** Atomic write of prepared patch content (tmp + rename, like the state files). */
|
package/lib/profile-patch.js
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
* override entry in place hot-applies the change: the webserver row reloads,
|
|
9
9
|
* closes its listener, and rebinds on the new host — no process restart.
|
|
10
10
|
*
|
|
11
|
+
* The value domain is defined by listen-addresses.ts: a concrete IPv4/IPv6
|
|
12
|
+
* literal that is an address of THIS MACHINE. Two classes of value are refused
|
|
13
|
+
* because both leave a profile that cannot boot, and in both cases this plugin
|
|
14
|
+
* is not running afterwards to repair it:
|
|
15
|
+
*
|
|
16
|
+
* - the wildcard (`0.0.0.0`/`::`), which dsh >= 0.2.1-alpha.2 rejects while
|
|
17
|
+
* resolving the loader config — before any plugin fiber activates;
|
|
18
|
+
* - a literal the machine does not own (a stale DHCP address, `127.0.0.2` on
|
|
19
|
+
* macOS), which fails at bind time with EADDRNOTAVAIL on the next start.
|
|
20
|
+
*
|
|
21
|
+
* {@link setWebserverHost} therefore validates and throws, so no caller can
|
|
22
|
+
* write either even if a stale client asks.
|
|
23
|
+
*
|
|
11
24
|
* This module only ever touches the `host` value inside the `webserver`
|
|
12
25
|
* entry. Everything else — comments, other entries, and especially the
|
|
13
26
|
* `!!js` port expression — is preserved byte-for-byte, which a YAML
|
|
@@ -17,8 +30,7 @@
|
|
|
17
30
|
*/
|
|
18
31
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
32
|
import { randomBytes } from 'node:crypto';
|
|
20
|
-
|
|
21
|
-
export const ALLOWED_LISTEN_HOSTS = ['127.0.0.1', '0.0.0.0'];
|
|
33
|
+
import { isLocalListenHost, isWildcardListenHost } from "./listen-addresses.js";
|
|
22
34
|
/**
|
|
23
35
|
* Delay between confirming the change to the browser and writing the patch
|
|
24
36
|
* file. The HMR watcher fires within milliseconds of the write and the
|
|
@@ -26,9 +38,30 @@ export const ALLOWED_LISTEN_HOSTS = ['127.0.0.1', '0.0.0.0'];
|
|
|
26
38
|
* the confirmation — so the write lands a beat AFTER the response flushed.
|
|
27
39
|
*/
|
|
28
40
|
export const LISTEN_APPLY_DELAY_MS = 300;
|
|
29
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Whether a value is a bind host that is safe (and accepted) to write: a
|
|
43
|
+
* concrete literal naming an address of this machine. See isLocalListenHost
|
|
44
|
+
* for why the wildcard AND non-local literals are both refused.
|
|
45
|
+
*/
|
|
30
46
|
export function isAllowedListenHost(value) {
|
|
31
|
-
return value
|
|
47
|
+
return isLocalListenHost(value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Throw the refusal for a host that must never reach the patch layer. Each
|
|
51
|
+
* case gets its own message: the wildcard and a non-local literal both leave
|
|
52
|
+
* a profile that cannot boot, but for different reasons and with different
|
|
53
|
+
* fixes.
|
|
54
|
+
*/
|
|
55
|
+
function refuseListenHost(value) {
|
|
56
|
+
const shown = JSON.stringify(value);
|
|
57
|
+
if (isWildcardListenHost(value)) {
|
|
58
|
+
throw new Error(`refusing to write ${shown}: the unspecified (wildcard) bind address is rejected by ` +
|
|
59
|
+
'dsh >= 0.2.1-alpha.2 while resolving config, which fails the entire startup ' +
|
|
60
|
+
'(the webserver never activates). Bind one concrete local interface address instead.');
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`refusing to write ${shown}: not an address of this machine. Bind one concrete IPv4/IPv6 ` +
|
|
63
|
+
'address that a local interface actually has (or 127.0.0.1) — an address the machine ' +
|
|
64
|
+
'does not own fails to bind with EADDRNOTAVAIL and leaves the next startup broken.');
|
|
32
65
|
}
|
|
33
66
|
/** The patch row id owning the web transport. */
|
|
34
67
|
const WEBSERVER_ROW_ID = 'webserver';
|
|
@@ -53,13 +86,19 @@ export function patchHasWebserverEntry(content) {
|
|
|
53
86
|
* - entry present with a `host:` under `config:` → the value is replaced;
|
|
54
87
|
* - entry present without `host:` → the line is inserted under `config:`;
|
|
55
88
|
* - entry present without `config:` → a config block is appended;
|
|
56
|
-
* - no entry at all → a complete row is
|
|
89
|
+
* - no entry at all → a complete row is written (name/inject/port restated):
|
|
90
|
+
* replacing the generated template's `[]` marker when present, otherwise
|
|
91
|
+
* appended as a new block item.
|
|
57
92
|
*
|
|
58
93
|
* @param content - the patch file's current text.
|
|
59
|
-
* @param host - the target bind host
|
|
94
|
+
* @param host - the target bind host (an address this machine owns).
|
|
60
95
|
* @returns the next file text; every other byte is untouched.
|
|
96
|
+
* @throws When `host` is not an address this machine owns, or the document is
|
|
97
|
+
* flow-style and cannot be extended as valid YAML — see refuseListenHost.
|
|
61
98
|
*/
|
|
62
99
|
export function setWebserverHost(content, host) {
|
|
100
|
+
if (!isAllowedListenHost(host))
|
|
101
|
+
refuseListenHost(host);
|
|
63
102
|
const lines = content.split('\n');
|
|
64
103
|
let entryStart = -1;
|
|
65
104
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -76,8 +115,32 @@ export function setWebserverHost(content, host) {
|
|
|
76
115
|
' config:',
|
|
77
116
|
' host: ' + host,
|
|
78
117
|
' port: !!js ' + WEBSERVER_PORT_EXPRESSION
|
|
79
|
-
]
|
|
80
|
-
|
|
118
|
+
];
|
|
119
|
+
// The dsh-generated template for a fresh profile is a comment header
|
|
120
|
+
// followed by `[]` (the valid empty-sequence document). Appending a block
|
|
121
|
+
// item after `[]` yields `[]\n- id: …`, which is NOT valid YAML — the boot
|
|
122
|
+
// then dies with "failed to parse overlay …: end of the stream or a
|
|
123
|
+
// document separator is expected", i.e. the same class of unbootable
|
|
124
|
+
// state as the wildcard. Replace the empty marker instead of appending.
|
|
125
|
+
const marker = lines.findIndex((line) => line.trim() === '[]');
|
|
126
|
+
if (marker !== -1) {
|
|
127
|
+
lines.splice(marker, 1, ...block);
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
// Any other flow-style document (e.g. `[{ id: webserver }]`) cannot be
|
|
131
|
+
// extended textually without producing invalid YAML; refuse loudly rather
|
|
132
|
+
// than write a file the next boot cannot parse.
|
|
133
|
+
const firstContent = lines.find((line) => {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
return trimmed.length > 0 && !trimmed.startsWith('#');
|
|
136
|
+
});
|
|
137
|
+
if (firstContent !== undefined && firstContent.trimStart().startsWith('[')) {
|
|
138
|
+
throw new Error('refusing to edit a flow-style patch document (' + firstContent.trim() + '): ' +
|
|
139
|
+
'convert it to block style (a `- id:` list) first — appending a block item ' +
|
|
140
|
+
'after a flow sequence produces YAML the next boot cannot parse.');
|
|
141
|
+
}
|
|
142
|
+
const text = content.length === 0 || content.endsWith('\n') ? content + block.join('\n') + '\n' : content + '\n' + block.join('\n') + '\n';
|
|
143
|
+
return text;
|
|
81
144
|
}
|
|
82
145
|
// The entry block ends at the next top-level list item or EOF.
|
|
83
146
|
let entryEnd = lines.length;
|
|
@@ -137,7 +200,8 @@ export function resolveListenPatchPath(profilePatch, homePatch) {
|
|
|
137
200
|
}
|
|
138
201
|
/**
|
|
139
202
|
* Read + transform the owning patch layer for `host`, returning the next
|
|
140
|
-
* file content. Throws when the file cannot be read
|
|
203
|
+
* file content. Throws when the file cannot be read, or when `host` is not a
|
|
204
|
+
* concrete address (the wildcard refusal runs before any read).
|
|
141
205
|
*/
|
|
142
206
|
export function prepareWebserverHostPatch(patchPath, host) {
|
|
143
207
|
return setWebserverHost(readPatchOrEmpty(patchPath), host);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-web-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,33 +50,32 @@
|
|
|
50
50
|
"client": {
|
|
51
51
|
"inject": [
|
|
52
52
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
53
|
-
"@deepseek-ai/dsh-client-ui-settings"
|
|
54
|
-
"@deepseek-ai/dsh-client-connection",
|
|
55
|
-
"@deepseek-ai/dsh-api-remotes"
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
56
54
|
],
|
|
57
55
|
"platform": "web"
|
|
58
56
|
}
|
|
59
57
|
},
|
|
60
58
|
"peerDependencies": {
|
|
61
59
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1 || ^0.2.1-alpha.1",
|
|
60
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2 || ^0.2.0-rc.1 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-rc.2 || ^0.2.1-rc.3",
|
|
63
61
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
64
62
|
"react": "^18.2.0",
|
|
65
63
|
"react-dom": "^18.2.0"
|
|
66
64
|
},
|
|
67
65
|
"dependencies": {
|
|
68
|
-
"@deepseek-ai/schemastery": "3.18.5
|
|
66
|
+
"@deepseek-ai/schemastery": "3.18.5"
|
|
69
67
|
},
|
|
70
68
|
"devDependencies": {
|
|
71
|
-
"@deepseek-ai/cordis": "4.0.5
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-
|
|
73
|
-
"@deepseek-ai/dsh-host-webserver": "0.2.1-
|
|
74
|
-
"@deepseek-ai/dsh-settings": "0.2.1-
|
|
75
|
-
"@deepseek-ai/schemastery": "3.18.5
|
|
69
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.3",
|
|
71
|
+
"@deepseek-ai/dsh-host-webserver": "0.2.1-rc.3",
|
|
72
|
+
"@deepseek-ai/dsh-settings": "0.2.1-rc.3",
|
|
73
|
+
"@deepseek-ai/schemastery": "3.18.5",
|
|
76
74
|
"@types/node": "^22.0.0",
|
|
77
75
|
"@types/react": "^18.3.0",
|
|
78
76
|
"esbuild": "^0.24.0",
|
|
79
77
|
"react": "^18.2.0",
|
|
80
|
-
"typescript": "^5.6.0"
|
|
78
|
+
"typescript": "^5.6.0",
|
|
79
|
+
"yaml": "2.9.1"
|
|
81
80
|
}
|
|
82
81
|
}
|
package/lib/settings.d.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
|
-
*
|
|
4
|
-
* The namespace exists so the plugin's browser half can read and write its
|
|
5
|
-
* own settings page: the page registers on the `settings.section` slot (the
|
|
6
|
-
* 「访问认证」 entry in the settings sidebar) and binds this namespace through
|
|
7
|
-
* the client settings scope, which is the only write path to `listenHost`.
|
|
8
|
-
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
|
-
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
|
-
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
|
11
|
-
* changed through a dedicated API, never through the settings document.
|
|
12
|
-
*
|
|
13
|
-
* Applying `listenHost` still goes through the profile patch layer (the
|
|
14
|
-
* webserver's bind host is composition config; the patch is how it is
|
|
15
|
-
* changed, and HMR hot-applies it). The settings document is the user-facing
|
|
16
|
-
* source of intent; this module translates a committed change into the same
|
|
17
|
-
* prepare → deferred-commit path the REST API used before.
|
|
18
|
-
*/
|
|
19
|
-
import z from '@deepseek-ai/schemastery';
|
|
20
|
-
import { type ListenHost } from './profile-patch.ts';
|
|
21
|
-
import type { ListenController } from './handlers.ts';
|
|
22
|
-
/** The settings namespace string (the card's dispatch key, spelled the same client-side). */
|
|
23
|
-
export declare const WEB_AUTH_NS = "web-auth";
|
|
24
|
-
/** Branded namespace for the settings provider. */
|
|
25
|
-
export declare const NS = "web-auth";
|
|
26
|
-
/** The namespace's resolved shape. */
|
|
27
|
-
export interface WebAuthSettings {
|
|
28
|
-
/** The web server's bind host. */
|
|
29
|
-
listenHost: ListenHost;
|
|
30
|
-
}
|
|
31
|
-
/** Schema resolving the namespace (schemastery, matching the webserver's own).
|
|
32
|
-
*
|
|
33
|
-
* Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
|
|
34
|
-
* written by an out-of-band client) must REJECT the write — keeping the last
|
|
35
|
-
* good resolved value — rather than silently resolving to the default. A
|
|
36
|
-
* default would look like a genuine user change to the watch below and could
|
|
37
|
-
* rebind the webserver off a value nobody actually chose.
|
|
38
|
-
*/
|
|
39
|
-
export declare const WebAuthSettingsSchema: z<WebAuthSettings>;
|
|
40
|
-
/**
|
|
41
|
-
* Register the `web-auth` settings namespace and translate committed
|
|
42
|
-
* `listenHost` changes into the live bind-host switch.
|
|
43
|
-
*
|
|
44
|
-
* The registration rides the plugin fiber and attaches only when a settings
|
|
45
|
-
* service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
|
|
46
|
-
* which have no web surface and no card — keep working without one. The
|
|
47
|
-
* composition `base` is the webserver's host at boot: the patch layer IS the
|
|
48
|
-
* deployment's composition, and seeding it this way makes the resolved value
|
|
49
|
-
* match reality with no user layer and no settings-document write.
|
|
50
|
-
*
|
|
51
|
-
* The watch callback applies a changed host through the same
|
|
52
|
-
* prepare → deferred-commit controller the REST API uses; a change that is a
|
|
53
|
-
* no-op against the current runtime host is skipped, and a commit that did
|
|
54
|
-
* not actually change the resolved value (invalid writes keep the last good
|
|
55
|
-
* value; document reloads re-resolve) never touches the webserver. This also
|
|
56
|
-
* makes the initial attach harmless — the resolved value equals the runtime
|
|
57
|
-
* host.
|
|
58
|
-
*
|
|
59
|
-
* @param ctx - the plugin context (must expose `settings` when available).
|
|
60
|
-
* @param listen - the live listen-host controller (prepare/commit).
|
|
61
|
-
*/
|
|
62
|
-
export declare function installWebAuthSettings(ctx: import('./context-types.ts').PluginContext, listen: ListenController): void;
|
package/lib/settings.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-web-auth — the `web-auth` settings namespace.
|
|
3
|
-
*
|
|
4
|
-
* The namespace exists so the plugin's browser half can read and write its
|
|
5
|
-
* own settings page: the page registers on the `settings.section` slot (the
|
|
6
|
-
* 「访问认证」 entry in the settings sidebar) and binds this namespace through
|
|
7
|
-
* the client settings scope, which is the only write path to `listenHost`.
|
|
8
|
-
* The namespace carries the one genuine configuration value the plugin owns:
|
|
9
|
-
* the web server's bind host (`listenHost`). The password is deliberately
|
|
10
|
-
* NOT here — it is a secret stored hashed under `$DSH_HOME/web-auth/` and
|
|
11
|
-
* changed through a dedicated API, never through the settings document.
|
|
12
|
-
*
|
|
13
|
-
* Applying `listenHost` still goes through the profile patch layer (the
|
|
14
|
-
* webserver's bind host is composition config; the patch is how it is
|
|
15
|
-
* changed, and HMR hot-applies it). The settings document is the user-facing
|
|
16
|
-
* source of intent; this module translates a committed change into the same
|
|
17
|
-
* prepare → deferred-commit path the REST API used before.
|
|
18
|
-
*/
|
|
19
|
-
import z from '@deepseek-ai/schemastery';
|
|
20
|
-
import { isAllowedListenHost } from "./profile-patch.js";
|
|
21
|
-
/** The settings namespace string (the card's dispatch key, spelled the same client-side). */
|
|
22
|
-
export const WEB_AUTH_NS = 'web-auth';
|
|
23
|
-
/** Branded namespace for the settings provider. */
|
|
24
|
-
export const NS = WEB_AUTH_NS;
|
|
25
|
-
/** Schema resolving the namespace (schemastery, matching the webserver's own).
|
|
26
|
-
*
|
|
27
|
-
* Deliberately NO `.default(...)`: an invalid user-layer value (e.g. `null`
|
|
28
|
-
* written by an out-of-band client) must REJECT the write — keeping the last
|
|
29
|
-
* good resolved value — rather than silently resolving to the default. A
|
|
30
|
-
* default would look like a genuine user change to the watch below and could
|
|
31
|
-
* rebind the webserver off a value nobody actually chose.
|
|
32
|
-
*/
|
|
33
|
-
export const WebAuthSettingsSchema = z.object({
|
|
34
|
-
listenHost: z.union([z.const('127.0.0.1'), z.const('0.0.0.0')])
|
|
35
|
-
});
|
|
36
|
-
/**
|
|
37
|
-
* Register the `web-auth` settings namespace and translate committed
|
|
38
|
-
* `listenHost` changes into the live bind-host switch.
|
|
39
|
-
*
|
|
40
|
-
* The registration rides the plugin fiber and attaches only when a settings
|
|
41
|
-
* service exists (`ctx.inject(['settings'])`), so CLI/headless profiles —
|
|
42
|
-
* which have no web surface and no card — keep working without one. The
|
|
43
|
-
* composition `base` is the webserver's host at boot: the patch layer IS the
|
|
44
|
-
* deployment's composition, and seeding it this way makes the resolved value
|
|
45
|
-
* match reality with no user layer and no settings-document write.
|
|
46
|
-
*
|
|
47
|
-
* The watch callback applies a changed host through the same
|
|
48
|
-
* prepare → deferred-commit controller the REST API uses; a change that is a
|
|
49
|
-
* no-op against the current runtime host is skipped, and a commit that did
|
|
50
|
-
* not actually change the resolved value (invalid writes keep the last good
|
|
51
|
-
* value; document reloads re-resolve) never touches the webserver. This also
|
|
52
|
-
* makes the initial attach harmless — the resolved value equals the runtime
|
|
53
|
-
* host.
|
|
54
|
-
*
|
|
55
|
-
* @param ctx - the plugin context (must expose `settings` when available).
|
|
56
|
-
* @param listen - the live listen-host controller (prepare/commit).
|
|
57
|
-
*/
|
|
58
|
-
export function installWebAuthSettings(ctx, listen) {
|
|
59
|
-
ctx.inject(['settings'], (sctx) => {
|
|
60
|
-
const current = listen.current();
|
|
61
|
-
const base = {
|
|
62
|
-
listenHost: isAllowedListenHost(current) ? current : '127.0.0.1'
|
|
63
|
-
};
|
|
64
|
-
const scope = sctx.settings.register(NS, WebAuthSettingsSchema, { base });
|
|
65
|
-
const dispose = scope.watch((next, prev) => {
|
|
66
|
-
const desired = next.listenHost;
|
|
67
|
-
// Only a real resolved-value change is a user gesture worth applying:
|
|
68
|
-
// a commit that re-resolves to the same value (document reloads, a
|
|
69
|
-
// rejected patch keeping the last good value) must not touch the
|
|
70
|
-
// webserver. Combined with the no-default schema, an invalid write
|
|
71
|
-
// cannot masquerade as a change.
|
|
72
|
-
if (desired === prev.listenHost)
|
|
73
|
-
return;
|
|
74
|
-
if (desired === listen.current())
|
|
75
|
-
return;
|
|
76
|
-
try {
|
|
77
|
-
listen.commit(listen.prepare(desired));
|
|
78
|
-
}
|
|
79
|
-
catch (error) {
|
|
80
|
-
// The settings document already committed; the apply is best-effort
|
|
81
|
-
// (failures logged, never thrown — the client was already confirmed).
|
|
82
|
-
console.error('[dsh-web-auth] failed to apply listenHost from settings:', error);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
sctx.effect(() => dispose, 'web-auth: listen host settings watcher');
|
|
86
|
-
});
|
|
87
|
-
}
|