@riceawa/dsh-lan-gateway 0.6.0 → 0.6.1

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,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <img src="https://img.shields.io/badge/DeepSeek%20Harness-4d6bfe?logo=deepseek&logoColor=fff&style=flat-square" alt="DeepSeek Harness" />
9
- <img src="https://img.shields.io/badge/version-0.6.0-2b7fff?style=flat-square" alt="version 0.6.0" />
9
+ <img src="https://img.shields.io/badge/version-0.6.1-2b7fff?style=flat-square" alt="version 0.6.1" />
10
10
  <img src="https://img.shields.io/badge/TLS-8b5cf6?logo=lock&logoColor=fff&style=flat-square" alt="TLS" />
11
11
  <img src="https://img.shields.io/github/license/rice-awa/dsh-lan-gateway?style=flat-square" alt="MIT license" />
12
12
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin" /></a>
@@ -44,10 +44,10 @@ cd dsh-lan-gateway
44
44
  pnpm install
45
45
  pnpm build # host → lib/index.js
46
46
  pnpm build:client # client → lib/client.js
47
- pnpm test # 117 项
47
+ pnpm test # 204 项
48
48
  ```
49
49
 
50
- 仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
50
+ 仓库里还有一个 [lan-gateway](skills/lan-gateway.md) 技能,安装后可直接在 dsh 对话里说「设置网关密码为 …」「开启远程访问」,agent 会调用 `lan_gateway` 工具完成,密码以参数传入,不写入配置,也不回显。改密码也可以不走模型:在本机打开 **Plugins** 页的「LAN 网关」卡片,直接填两次新密码覆盖(见下「登录密码」)。安装方式见 [INSTALL.md](INSTALL.md#for-agents完整安装流程)。
51
51
 
52
52
  如需在手机 / 平板上访问,可另外安装 [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile) 做移动端 UI 适配:
53
53
 
@@ -66,11 +66,11 @@ lan_gateway tls-regenerate # 换发自签名证书(tlsMode=self-signed 时
66
66
  lan_gateway disable # 关闭
67
67
  ```
68
68
 
69
- `lan_gateway` 是模型可调用的工具,上述命令无需手动执行。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会写入任何配置文件。
69
+ `lan_gateway` 是模型可调用的工具,上述命令无需手动执行。直接在对话里说「查看网关状态」「设置网关密码为 ……」即可,密码以参数传给模型,不会写入任何配置文件。改密码也可以完全不经模型:在本机 Plugins 页的插件卡片里直接改(见下「登录密码」)。
70
70
 
71
71
  ## 配置
72
72
 
73
- 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页,在官方插件列表里点开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。卡片挂在 Plugins 页的 `plugins.item` 槽上,且只在底座确实 served 本插件条目时注册——没有 Loader 条目(写入必然 409)时卡片不会出现。下表既是卡片字段,也是配置键:
73
+ 所有可调项都写在本插件自己的 profile 条目里(dsh ≥ 0.1.7 起,设置写入按**条目 id** 寻址;此前的 `lan-gateway` 用户设置命名空间已随 `settingsScope` 一起移除)。打开侧边栏的 **Plugins** 页,在官方插件列表里点开「LAN 网关」卡片即可修改,保存即生效,监听器会按新配置自动重启。卡片挂在 Plugins 页的 `plugins.item` 槽上,且只在底座确实 served 本插件条目时注册——没有 Loader 条目(写入必然 409)时卡片不会出现。下表既是卡片字段,也是配置键;卡片顶部另有一栏「登录密码」,改密码不走配置表(见下「登录密码」):
74
74
 
75
75
  | 键 | 默认值 | 说明 |
76
76
  | --- | --- | --- |
@@ -95,6 +95,16 @@ lan_gateway disable # 关闭
95
95
 
96
96
  配置里残留 `authRequired: false`(v0.4 及更早的写法)会被启停守卫拒绝并提示迁移,不会静默降级成免密。
97
97
 
98
+ ### 登录密码
99
+
100
+ 卡片顶部就是「登录密码」栏:一个「已设置 / 未设置」状态,两个密码框(新密码、再次输入),点「修改密码」直接覆盖原密码。
101
+
102
+ - **不回显旧密码。** 界面只报告密码是否存在;卡片读不到旧密码。`state.json` 里只有 scrypt 哈希与盐,`/lan-gateway/password` 也从不把哈希、盐或长度返回给浏览器。
103
+ - **不需要旧密码。** 该路由与配置路由共用同一道围栏:只接受本机 loopback 的同源请求(Host 必须是回环、跨站请求拒绝、写操作必须带匹配的 `Origin`),经网关远程访问的浏览器一律 403。能通过这道门的本地用户本来就能读 `~/.dsh`。
104
+ - **改完即生效。** 写盘后递增会话代次:旧密码立即失效,所有已登录会话与已建立的 WebSocket 全部作废,各来源需要重新登录。
105
+ - **只设置,不清空。** 清空密码会按设计停掉监听器(没有密码不允许监听),卡片不做这件事;要清空请用 `lan_gateway set-password` 并留空密码。
106
+ - **密码不是配置键。** 它不写进 profile 条目,也不会出现在 `--dump-config` 里;保存配置字段动不到它,改密码也不会碰你尚未保存的字段草稿。
107
+
98
108
  ### 入口加密
99
109
 
100
110
  网关默认拒绝纯明文监听,以下三种方式任选其一方可启动:
@@ -194,7 +204,7 @@ client bundle 在模块级给 `Crypto` 原型补一个基于 `crypto.getRandomVa
194
204
  ## 开发
195
205
 
196
206
  ```bash
197
- pnpm test # 117 项
207
+ pnpm test # 204 项
198
208
  pnpm typecheck # tsc 双端(host + client)
199
209
  ```
200
210
 
@@ -209,12 +219,13 @@ pnpm typecheck # tsc 双端(host + client)
209
219
  ✓ tests/x509.test.ts ( 6) 自签名证书 DER/SAN/签名/TLS 握手
210
220
  ✓ tests/tls.test.ts ( 9) 证书持久化 / 到期换发 / 重生成 / 自定义证书加载
211
221
  ✓ tests/uuid-shim.test.ts ( 3) 不安全源补丁 / 安全源 no-op / v4 正确性
212
- ✓ tests/settings-card.test.ts (10) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
213
- 卡片注册槽与条目 id 契约
222
+ ✓ tests/settings-card.test.ts (15) 设置页字段编解码(三态 auto ↔ false 不可混淆)/
223
+ 卡片注册槽与条目 id 契约 / 密码草稿闸门(长度与确认)
214
224
  ✓ tests/integration/gateway.test.ts (26) 真实网关端到端:全来源登录 / LAN 豁免 / 跨站 403 / 升级拒绝 /
215
225
  cookie 属性 / epoch 撤销 / 逐会话登出 / 尾斜杠 / IPv6 / 会话中继
216
- ✓ tests/integration/management-plane.test.ts (15) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
217
- 清空后继承 / 拒绝不可启动配置
226
+ ✓ tests/integration/management-plane.test.ts (24) 真实 apply():工具与卡片交替启停 / 未编辑字段与未知键保留 /
227
+ 清空后继承 / 拒绝不可启动配置 / 卡片改密路由(覆盖原密码 /
228
+ 递增代次 / 不返回哈希 / 不给清空 / 无 settings 也生效)
218
229
  ✓ tests/integration/session-races.test.ts ( 8) 改密落在登录与握手途中的竞态 / 上游非 101 应答
219
230
  ```
220
231
 
package/lib/client.js CHANGED
@@ -161,6 +161,21 @@ window.__ModuleLoader__.load({
161
161
  *
162
162
  * @module @riceawa/dsh-lan-gateway/client/card
163
163
  */
164
+ /**
165
+ * Judge a password draft the way the host's password route will, so a draft the
166
+ * card enables is never answered with a 400. The length bound is the shared
167
+ * {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
168
+ * here rather than server-side (the host is told one password, and storing only
169
+ * what was typed twice is the browser's job).
170
+ * @param password - the new password draft.
171
+ * @param confirm - the confirmation draft.
172
+ * @returns the reason it cannot be submitted, or `null` when it can.
173
+ */
174
+ function passwordProblem(password, confirm) {
175
+ if (password.length < 8) return "tooShort";
176
+ if (password !== confirm) return "mismatch";
177
+ return null;
178
+ }
164
179
  const LABELS = {
165
180
  zh: {
166
181
  title: "LAN 网关",
@@ -178,6 +193,19 @@ window.__ModuleLoader__.load({
178
193
  stopped: "已停止",
179
194
  tls: "TLS",
180
195
  lastError: "上次错误",
196
+ passwordSection: "登录密码",
197
+ passwordHint: "当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。",
198
+ passwordNew: "新密码(至少 8 位)",
199
+ passwordConfirm: "再次输入新密码",
200
+ passwordSet: "已设置",
201
+ passwordUnset: "未设置",
202
+ passwordRequired: "未设置密码时网关拒绝启动。",
203
+ passwordChange: "修改密码",
204
+ passwordChanged: "密码已更新:旧密码立即失效,所有已登录会话已作废。",
205
+ passwordFailed: "修改密码失败,请重试。",
206
+ passwordStaleHost: "宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。",
207
+ passwordTooShort: "密码至少 8 位。",
208
+ passwordMismatch: "两次输入不一致。",
181
209
  "field.enabled": "启用网关",
182
210
  "hint.enabled": "启动时监听 0.0.0.0 网关端口",
183
211
  "field.gatewayPort": "网关端口",
@@ -228,6 +256,19 @@ window.__ModuleLoader__.load({
228
256
  stopped: "Stopped",
229
257
  tls: "TLS",
230
258
  lastError: "Last error",
259
+ passwordSection: "Login password",
260
+ passwordHint: "The current password is never shown (it cannot be read back). Enter a new one twice to overwrite it; changing it advances the session epoch, so every signed-in session and live WebSocket is invalidated at once. Only changeable where dsh web runs on loopback.",
261
+ passwordNew: "New password (min 8 chars)",
262
+ passwordConfirm: "Repeat new password",
263
+ passwordSet: "Set",
264
+ passwordUnset: "Not set",
265
+ passwordRequired: "The gateway refuses to start without a password.",
266
+ passwordChange: "Change password",
267
+ passwordChanged: "Password updated: the old one no longer works and every signed-in session was revoked.",
268
+ passwordFailed: "The password change failed — retry.",
269
+ passwordStaleHost: "The host did not answer this endpoint — it may still be running the previous build. Restart dsh web and retry; the lan_gateway tool can change the password meanwhile.",
270
+ passwordTooShort: "At least 8 characters.",
271
+ passwordMismatch: "The two entries do not match.",
231
272
  "field.enabled": "Enable gateway",
232
273
  "hint.enabled": "Listen on the gateway port at boot",
233
274
  "field.gatewayPort": "Gateway port",
@@ -292,6 +333,11 @@ window.__ModuleLoader__.load({
292
333
  const [drafts, setDrafts] = (0, react.useState)({});
293
334
  const [saving, setSaving] = (0, react.useState)(false);
294
335
  const [failed, setFailed] = (0, react.useState)(null);
336
+ const [password, setPasswordDraft] = (0, react.useState)("");
337
+ const [passwordConfirm, setPasswordConfirmDraft] = (0, react.useState)("");
338
+ const [passwordBusy, setPasswordBusy] = (0, react.useState)(false);
339
+ const [passwordError, setPasswordError] = (0, react.useState)(null);
340
+ const [passwordNotice, setPasswordNotice] = (0, react.useState)(null);
295
341
  (0, react.useEffect)(() => {
296
342
  let cancelled = false;
297
343
  fetch("/lan-gateway/config").then(async (response) => {
@@ -354,6 +400,56 @@ window.__ModuleLoader__.load({
354
400
  return def === void 0 || parseValue(def, text ?? "") === void 0;
355
401
  });
356
402
  const dirty = Object.keys(drafts).length > 0;
403
+ const passwordDraftProblem = passwordProblem(password, passwordConfirm);
404
+ const passwordTyping = password !== "" || passwordConfirm !== "";
405
+ /** Adopt the host's post-write snapshot, keeping the last known values. */
406
+ const adopt = (body) => {
407
+ if (body.config === void 0) return;
408
+ setRoute({
409
+ config: body.config,
410
+ running: body.running ?? false,
411
+ port: body.port ?? 0,
412
+ tls: body.tls ?? "",
413
+ lastError: body.lastError ?? null,
414
+ passwordSet: body.passwordSet === true
415
+ });
416
+ };
417
+ /**
418
+ * Overwrite the login password. Deliberately writes only to
419
+ * `/lan-gateway/password`: the credential is not part of the config patch, so
420
+ * a change here can never disturb an unsaved settings draft, and the old
421
+ * password is neither sent nor requested.
422
+ */
423
+ const changePassword = async () => {
424
+ if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return;
425
+ setPasswordBusy(true);
426
+ setPasswordError(null);
427
+ setPasswordNotice(null);
428
+ try {
429
+ const response = await fetch("/lan-gateway/password", {
430
+ method: "POST",
431
+ headers: { "content-type": "application/json" },
432
+ body: JSON.stringify({ password })
433
+ });
434
+ const body = await response.json().catch(() => null);
435
+ if (!response.ok) {
436
+ setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`));
437
+ return;
438
+ }
439
+ if (body === null) {
440
+ setPasswordError(t.passwordStaleHost);
441
+ return;
442
+ }
443
+ setPasswordDraft("");
444
+ setPasswordConfirmDraft("");
445
+ setPasswordNotice(t.passwordChanged);
446
+ adopt(body);
447
+ } catch {
448
+ setPasswordError(t.passwordFailed);
449
+ } finally {
450
+ setPasswordBusy(false);
451
+ }
452
+ };
357
453
  const save = async () => {
358
454
  if (!dirty || saving || invalid()) return;
359
455
  setSaving(true);
@@ -377,13 +473,7 @@ window.__ModuleLoader__.load({
377
473
  setFailed(body.error ?? `HTTP ${response.status}`);
378
474
  return;
379
475
  }
380
- if (body.config !== void 0) setRoute({
381
- config: body.config,
382
- running: body.running ?? false,
383
- port: body.port ?? 0,
384
- tls: body.tls ?? "",
385
- lastError: body.lastError ?? null
386
- });
476
+ adopt(body);
387
477
  setDrafts({});
388
478
  } catch {
389
479
  setFailed(t.saveFailed);
@@ -539,6 +629,85 @@ window.__ModuleLoader__.load({
539
629
  route.lastError
540
630
  ]
541
631
  }) : null,
632
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
633
+ style: styles.section,
634
+ children: [
635
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
636
+ style: styles.sectionHead,
637
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
638
+ style: styles.label,
639
+ children: t.passwordSection
640
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
641
+ style: route.passwordSet === true ? styles.badge : styles.badgeAlert,
642
+ children: route.passwordSet === true ? t.passwordSet : t.passwordUnset
643
+ })]
644
+ }),
645
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
646
+ style: styles.hint,
647
+ children: t.passwordHint
648
+ }),
649
+ route.passwordSet === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
650
+ style: styles.error,
651
+ children: t.passwordRequired
652
+ }),
653
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
654
+ style: styles.passwordRow,
655
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
656
+ id: "lan-gw-password",
657
+ type: "password",
658
+ autoComplete: "new-password",
659
+ "aria-label": t.passwordNew,
660
+ style: passwordInput,
661
+ placeholder: t.passwordNew,
662
+ value: password,
663
+ disabled: passwordBusy,
664
+ onChange: (e) => {
665
+ setPasswordDraft(e.target.value);
666
+ setPasswordError(null);
667
+ setPasswordNotice(null);
668
+ }
669
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
670
+ id: "lan-gw-password-confirm",
671
+ type: "password",
672
+ autoComplete: "new-password",
673
+ "aria-label": t.passwordConfirm,
674
+ style: passwordInput,
675
+ placeholder: t.passwordConfirm,
676
+ value: passwordConfirm,
677
+ disabled: passwordBusy,
678
+ onChange: (e) => {
679
+ setPasswordConfirmDraft(e.target.value);
680
+ setPasswordError(null);
681
+ setPasswordNotice(null);
682
+ }
683
+ })]
684
+ }),
685
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
686
+ style: styles.passwordFoot,
687
+ children: [passwordError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
688
+ style: styles.error,
689
+ role: "alert",
690
+ children: passwordError
691
+ }) : passwordNotice !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
692
+ style: styles.notice,
693
+ role: "status",
694
+ children: passwordNotice
695
+ }) : passwordTyping && passwordDraftProblem !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
696
+ style: styles.error,
697
+ role: "status",
698
+ children: passwordDraftProblem === "tooShort" ? t.passwordTooShort : t.passwordMismatch
699
+ }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
700
+ type: "button",
701
+ style: styles.save,
702
+ disabled: passwordBusy || passwordDraftProblem !== null,
703
+ onClick: () => {
704
+ changePassword();
705
+ },
706
+ children: passwordBusy ? t.saving : t.passwordChange
707
+ })]
708
+ })
709
+ ]
710
+ }),
542
711
  FIELDS.map((def) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: renderControl(def) }, def.field)),
543
712
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
544
713
  style: styles.footer,
@@ -672,6 +841,58 @@ window.__ModuleLoader__.load({
672
841
  display: "flex",
673
842
  flexDirection: "column"
674
843
  },
844
+ section: {
845
+ display: "flex",
846
+ flexDirection: "column",
847
+ gap: "8px",
848
+ padding: "14px 0",
849
+ borderBottom: `1px solid ${L.border}`
850
+ },
851
+ sectionHead: {
852
+ display: "flex",
853
+ alignItems: "center",
854
+ gap: "8px"
855
+ },
856
+ badge: {
857
+ borderRadius: "999px",
858
+ padding: "1px 8px",
859
+ fontSize: "11px",
860
+ lineHeight: "17px",
861
+ fontWeight: 500,
862
+ whiteSpace: "nowrap",
863
+ background: L.badgeBg,
864
+ color: L.labelSecondary
865
+ },
866
+ badgeAlert: {
867
+ borderRadius: "999px",
868
+ padding: "1px 8px",
869
+ fontSize: "11px",
870
+ lineHeight: "17px",
871
+ fontWeight: 500,
872
+ whiteSpace: "nowrap",
873
+ background: L.badgeBg,
874
+ color: L.error
875
+ },
876
+ notice: {
877
+ flex: 1,
878
+ minWidth: 0,
879
+ margin: 0,
880
+ fontSize: "12px",
881
+ lineHeight: 1.5,
882
+ color: L.labelSecondary
883
+ },
884
+ passwordRow: {
885
+ display: "flex",
886
+ flexWrap: "wrap",
887
+ gap: "8px"
888
+ },
889
+ passwordFoot: {
890
+ display: "flex",
891
+ alignItems: "center",
892
+ justifyContent: "flex-end",
893
+ gap: "8px",
894
+ marginTop: "2px"
895
+ },
675
896
  field: {
676
897
  display: "flex",
677
898
  flexDirection: "column",
@@ -759,6 +980,11 @@ window.__ModuleLoader__.load({
759
980
  cursor: "pointer"
760
981
  }
761
982
  };
983
+ /** The shared input style plus the growth two side-by-side password boxes need. */
984
+ const passwordInput = {
985
+ ...styles.input,
986
+ flex: "1 1 160px"
987
+ };
762
988
  //#endregion
763
989
  //#region src/client/index.ts
764
990
  /**