@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 CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  - **登录页 + HttpOnly 签名 Cookie**(HMAC-SHA256,7 天滑动续期),浏览器自动携带,HTTP / WebSocket / SSE / 静态资源全覆盖
8
8
  - **首次设置**:未配置密码时,登录页显示「设置访问密码」表单(引导流程,见下文安全说明)
9
- - **GUI 改密**:设置 → 插件 →「访问认证」卡片(插件配置 tab),需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
9
+ - **GUI 改密**:设置 → 侧栏「访问认证」页面,需旧密码;改密后**轮换签名密钥,所有已登录会话立即下线**
10
10
  - **退出登录**:设置卡片按钮,或访问 `/logout`
11
11
  - **防爆破**:登录与改密接口按客户端 IP 限速(连续 5 次失败锁 1 分钟起,指数退避至 30 分钟;15 分钟内累计)
12
- - **反代友好**:Cookie `Secure` 自动跟随 `x-forwarded-proto`;限速信任回环对端的 `X-Forwarded-For`
13
- - **监听地址切换**:设置卡片可一键把 dsh 的监听地址在 `127.0.0.1`(仅本机)与 `0.0.0.0`(所有网卡 / 局域网可访问)之间切换,热生效、无需重启进程(写入 `web-auth` settings 命名空间 → host 半区应用 patch → HMR 重绑 webserver)
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
- | 切换监听地址(设置卡片) | 校验取值(仅 `127.0.0.1` / `0.0.0.0`)→ 写入 `web-auth` settings 命名空间(revision 设栅)→ host 半区应用 patch(profile 优先,home 兜底)→ HMR 热重载 webserver 重新绑定;**WebSocket 短暂断连后自动重连** |
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`。在 **设置 → 插件 → 插件配置 → 访问认证** 卡片底部可以把它切换为 `0.0.0.0`(所有网卡,局域网可访问),或切回仅本机——无需重启 dsh 进程:
67
+ dsh 默认只监听 `127.0.0.1`。在 **设置 → 侧栏「访问认证」** 页面底部可以把它切换为本机任意网卡上的**具体地址**(列表由 host 半区枚举 `os.networkInterfaces()` 生成),或切回 `127.0.0.1` 仅本机——无需重启 dsh 进程:
66
68
 
67
- - 切换写入 `web-auth` settings 命名空间(`$DSH_HOME/settings.yaml`,revision 设栅);host 半区监听到提交后应用到当前部署的 patch 层:profile 的 `cordis.patch.yml`;若 home 层(`$DSH_HOME/cordis.patch.yml`)已声明 webserver 行则写 home 层(home 优先级更高,改在低层会被覆盖)。文件里其余内容(注释、`!!js` 端口表达式)原样保留。
68
- - dsh 的 HMR 会监听到 patch 文件变化并热重载 webserver 行:**关闭旧监听、按新地址重新绑定**,全程无需重启进程。重启进程后该设置依然生效(settings 命名空间 + patch 文件都在)。
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
- - 注意:若你正通过**局域网 IP**(`0.0.0.0` 模式)访问并切回 `127.0.0.1`,切回后只能在本机访问,请改用 `127.0.0.1` 重新打开页面。
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.plugin.item` 插槽,keyed by `web-auth` 命名空间)
154
+ - `src/client/index.tsx` — 设置卡片(浏览器半区,`settings.section` 插槽,id `web-auth`;读写走本插件 REST)
117
155
  - `cordis.patch.yml` — bundle 补丁层(insert 插件行)
118
156
 
119
157
  ## License
@@ -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. The shell's declaration
13
- * may not be on the ledger yet, so the registration waits via slots.inject.
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 === "127.0.0.1" || value === "0.0.0.0";
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 { configured: data.configured === true, host: isListenHost(data.host) ? data.host : void 0 };
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 WebAuthSettingsSection(props) {
123
- const { scope } = props;
124
- const snapshot = (0, import_react.useSyncExternalStore)(
125
- (listener) => scope.subscribe(listener),
126
- () => scope.getSnapshot()
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)("127.0.0.1");
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
- if (status.host !== void 0) {
144
- setHost(status.host);
145
- setListenPick(status.host);
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
- try {
182
- await scope.set("listenHost", listenPick);
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 || host === null || !snapshot.writable,
283
+ disabled: listenBusy || options.length === 0,
268
284
  onChange: (e) => setListenPick(e.target.value),
269
- children: LISTEN_OPTIONS.map((option) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
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 || host === null || host === listenPick || !snapshot.writable,
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: "\u5207\u6362\u540E dsh \u4F1A\u7ACB\u5373\u91CD\u65B0\u7ED1\u5B9A\u76D1\u542C\u7AEF\u53E3\uFF08WebSocket \u77ED\u6682\u65AD\u8FDE\u540E\u81EA\u52A8\u91CD\u8FDE\uFF09\u3002\u6CE8\u610F\uFF1A\u82E5\u5F53\u524D\u901A\u8FC7\u5C40\u57DF\u7F51 IP \u8BBF\u95EE\uFF0C\u5207\u56DE 127.0.0.1 \u540E\u53EA\u80FD\u5728\u672C\u673A\u8BBF\u95EE\u3002" }),
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(["settingsScope"], (raw) => {
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: "web-auth",
313
+ id: WEB_AUTH_NS,
299
314
  order: 120,
300
315
  label: "\u8BBF\u95EE\u8BA4\u8BC1",
301
- inject: () => ({ scope })
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 loopback (the reverse proxy sits on the same host), so
84
- * a remote attacker hitting dsh directly cannot forge a fresh identity.
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 between '127.0.0.1' and '0.0.0.0'.
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 { ALLOWED_LISTEN_HOSTS, isAllowedListenHost } from "./profile-patch.js";
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 loopback (the reverse proxy sits on the same host), so
27
- * a remote attacker hitting dsh directly cannot forge a fresh identity.
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
- const loopback = direct === '127.0.0.1' || direct === '::1' || direct === '::ffff:127.0.0.1';
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 loopback
159
- * re-entry: the marker header AND a direct loopback TCP peer (not
160
- * clientIp's XFF-aware view — a proxied remote client must not pass even
161
- * if it forges the header, and the mint never traverses the proxy).
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
- const direct = req.socket.remoteAddress ?? '';
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 between '127.0.0.1' and '0.0.0.0'.
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, { error: 'invalid-host', allowed: [...ALLOWED_LISTEN_HOSTS] });
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 plugin's own settings page (设置 → 侧栏「访问认证」) rides the
110
- // `web-auth` settings namespace: binding the namespace is how the page reads
111
- // and writes it, and committed listenHost changes are applied here. Attaches
112
- // only when a settings service exists (CLI/headless profiles skip it).
113
- installWebAuthSettings(ctx, env.listen);
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
- const host = ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '::' ? '127.0.0.1' : ctx.webServer.host;
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
+ }
@@ -1,6 +1,10 @@
1
- /** The two bind hosts the webserver config schema accepts. */
2
- export declare const ALLOWED_LISTEN_HOSTS: readonly ["127.0.0.1", "0.0.0.0"];
3
- export type ListenHost = (typeof ALLOWED_LISTEN_HOSTS)[number];
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
- /** Whether a value is one of the two allowed bind-host literals. */
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 appended (name/inject/port restated).
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 literal.
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). */
@@ -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
- /** The two bind hosts the webserver config schema accepts. */
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
- /** Whether a value is one of the two allowed bind-host literals. */
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 === '127.0.0.1' || value === '0.0.0.0';
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 appended (name/inject/port restated).
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 literal.
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
- ].join('\n');
80
- return content.length === 0 || content.endsWith('\n') ? content + block + '\n' : content + '\n' + block + '\n';
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.1.10",
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-alpha.1"
66
+ "@deepseek-ai/schemastery": "3.18.5"
69
67
  },
70
68
  "devDependencies": {
71
- "@deepseek-ai/cordis": "4.0.5-alpha.1",
72
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-alpha.1",
73
- "@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.1",
74
- "@deepseek-ai/dsh-settings": "0.2.1-alpha.1",
75
- "@deepseek-ai/schemastery": "3.18.5-alpha.1",
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
- }