kczx-user-management 1.0.2 → 1.0.3

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.
@@ -39,7 +39,7 @@ takes a few minutes and the rollback takes one command.
39
39
  # user-management:
40
40
  # listenHost: '0.0.0.0' # was 127.0.0.1 (observer mode)
41
41
  # plaintext: false # was true: the door terminates TLS again
42
- # port: 19843
42
+ # port: 8088
43
43
  # trustedSecret: '' # no signing gateway in front of it any more
44
44
 
45
45
  # 2. remove dsh-passwords from the profile
@@ -50,7 +50,7 @@ dsh plugin --profile web remove dsh-passwords
50
50
 
51
51
  ## Verification after the switch
52
52
 
53
- 1. `https://<host>:19843/login` renders, a wrong password is refused, a correct
53
+ 1. `https://<host>:8088/login` renders, a wrong password is refused, a correct
54
54
  one lands on the SPA.
55
55
  2. The settings page shows 用户管理 with the accounts that were migrated
56
56
  (`mxz`, `mxz1`) and their permissions intact.
@@ -43,7 +43,7 @@ dsh 宿主 127.0.0.1:3080
43
43
  user-management:
44
44
  enabled: true
45
45
  listenHost: '127.0.0.1' # 明文模式会强制为回环,配别的会被拒绝并告警
46
- port: 19843
46
+ port: 8088
47
47
  plaintext: true # 明文监听:登录门的 WS 升级走裸 TCP 隧道,无法对这一跳做 TLS
48
48
  trustedSecret: '<64 hex>' # 与 dsh-passwords 的 MCP_IDENTITY_SECRET 必须逐字一致
49
49
  # trustedPeers: [] # 默认即回环;仅在确有需要时覆盖
@@ -53,7 +53,7 @@ user-management:
53
53
 
54
54
  ```
55
55
  MCP_IDENTITY_SECRET=<同一个 64 hex> # 专用密钥,不复用 MCP_INTERNAL_SECRET
56
- MCP_GATEWAY_UPSTREAM=http://127.0.0.1:19843
56
+ MCP_GATEWAY_UPSTREAM=http://127.0.0.1:8088
57
57
  ```
58
58
 
59
59
  两端密钥不一致是**静默失败**——观察层会拒绝全部请求,台账停止增长而没有任何报错。
package/README.md CHANGED
@@ -54,11 +54,30 @@ pnpm add ./kczx-user-management-1.0.0.tgz # 正常路径:依赖从 regi
54
54
  node scripts/ensure-deps.mjs # 仅当上述解析不到 schemastery 时才需要
55
55
  ```
56
56
 
57
+ ## 首次使用(初始化)
58
+
59
+ **自助注册是关闭的**:账号只能由管理员在设置页创建,第一个管理员走"初始化"流程。
60
+
61
+ 1. 启动 dsh。库里还没有任何账号时,启动日志会打印一行初始化密钥:
62
+
63
+ ```
64
+ [user-management] first-run setup key 3f0c… (also in ~/.dsh/user-management/setup-key.txt)
65
+ ```
66
+
67
+ 密钥同时写在 `$DSH_HOME/user-management/setup-key.txt`;也可以用 `settings.yaml` 的
68
+ `user-management.setupKey` 自行指定(那样不会往磁盘写密钥文件)。
69
+
70
+ 2. 浏览器打开 `https://<主机>:8088/login` —— 此时显示**初始化页**:填初始化密钥 + 用户名 + 密码。
71
+ 3. 提交后该账号**直接成为管理员并登录**,初始化密钥当场销毁(删文件、清内存);此后 `/login` 只显示登录表单,
72
+ `/user-management/api/setup` 拒绝一切后续调用。
73
+
74
+ > 登录页样式与 dsh-passwords 一致(光晕背景 + 网格 + 玻璃卡片 + 渐变按钮),跟随宿主主题深浅色,右上角可切换中文 / English。
75
+
57
76
  ## 它做什么
58
77
 
59
78
  ### 一扇门
60
79
 
61
- 独立的 `node:https` 监听器(默认 `0.0.0.0:19843`),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名),
80
+ 独立的 `node:https` 监听器(默认 `0.0.0.0:8088`),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名),
62
81
  未登录的页面访问跳转 `/login`、API 请求 401;登录页自带深浅色。设置页提供证书下载(PEM/DER)、指纹核对与
63
82
  各系统导入向导。
64
83
 
@@ -80,7 +99,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
80
99
 
81
100
  | 台账 | 内容 |
82
101
  |---|---|
83
- | 登录记录 | 登录 / 失败 / 登出 / 改密 / 注册 / 角色变更 / 删号 / 权限变更(含紧凑差异串) |
102
+ | 登录记录 | 登录 / 失败 / 登出 / 改密 / 初始化(首次建号)/ 建号 / 删号 / 角色变更 / 资料变更 / 权限变更(含紧凑差异串) |
84
103
  | 访问记录 | 页面级访问(保留真实客户端 IP) |
85
104
  | 操作日志 | 经过网关的每次 API 调用与 WebSocket 连接(方法 / 路径 / 状态 / 来源 IP) |
86
105
 
@@ -94,11 +113,11 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
94
113
  user-management:
95
114
  enabled: true
96
115
  listenHost: '0.0.0.0' # 只本机可达就填 127.0.0.1
97
- port: 19843
116
+ port: 8088
98
117
  plaintext: false # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
99
118
  sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
100
119
  title: 'DSH 控制台'
101
- autoActivate: false # false = 自助注册需管理员启用
120
+ setupKey: '' # 首次初始化密钥;留空 = 启动时随机生成并写到 setup-key.txt
102
121
  historyMaxBytes: 67108864 # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)
103
122
  ```
104
123
 
package/client/bundle.js CHANGED
@@ -518,6 +518,10 @@ window.__ModuleLoader__.load({
518
518
  .um-wrap { font-size: 13px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 14px; }
519
519
  .um-avatar { border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.95); font-weight: 600; flex: none; user-select: none; letter-spacing: 0; }
520
520
  .um-brand-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 150px; }
521
+ .um-header-user { display: inline-flex; align-items: center; }
522
+ .um-header-user-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px 3px 3px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; line-height: 1; cursor: pointer; }
523
+ .um-header-user-btn:hover { background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-border-l2); }
524
+ .um-header-user-name { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
521
525
  .um-logout { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; flex: none; }
522
526
  .um-logout:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-error-primary); }
523
527
  .um-user-menu { min-width: 216px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); padding: 6px; }
@@ -650,45 +654,36 @@ window.__ModuleLoader__.load({
650
654
  }, label.charAt(0).toUpperCase())
651
655
  }
652
656
 
653
- /** Sidebar brand mark host renders it in the expanded brand row AND the
654
- * collapsed rail (owner supplies the square `size`), so fold states adapt
655
- * without us tracking the shell. Clicking it (or the name) opens the
656
- * user menu; sign-out sits at the bottom in red. */
657
- function BrandMark({ size, className }) {
657
+ /** Account menu for the right end of the conversation header.
658
+ *
659
+ * It lives in the session header's utilities group rather than the sidebar's
660
+ * brand seat: the brand belongs to the host (its fish logo returns there
661
+ * untouched), while the signed-in account belongs to the surface that acts on
662
+ * its behalf. Clicking opens the same menu used everywhere else — change
663
+ * password, sign out — with sign-out last, in red.
664
+ *
665
+ * Session-scoped by construction: the header only exists once a conversation
666
+ * is open, so on the welcome screen there is no account chip to click. */
667
+ function HeaderUserMenu() {
658
668
  useEffect(ensureStyles, [])
659
669
  const [me, setMe] = useState(sessionCache.me)
660
670
  useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
661
671
  const { toggle, menu, dialog } = useUserMenu(me)
662
- if (!me) {
663
- return h('div', { className, style: { width: size, height: size, borderRadius: '50%', background: 'var(--dsw-alias-bg-layer-3)' } })
664
- }
665
- return h('div', {
666
- className,
667
- style: { cursor: 'pointer', display: 'inline-flex', borderRadius: '50%' },
668
- title: me.username,
669
- 'aria-haspopup': 'menu',
670
- onClick: toggle,
671
- },
672
- h(UserAvatar, { username: me.username, size: size || 24 }),
672
+ if (!me) return null
673
+ return h('div', { className: 'um-header-user' },
674
+ h('button', {
675
+ type: 'button',
676
+ className: 'um-header-user-btn',
677
+ title: me.username,
678
+ 'aria-haspopup': 'menu',
679
+ onClick: toggle,
680
+ },
681
+ h(UserAvatar, { username: me.username, size: 22 }),
682
+ h('span', { className: 'um-header-user-name' }, me.username)),
673
683
  menu,
674
684
  dialog)
675
685
  }
676
686
 
677
- /** Sidebar brand name — host renders it beside the expanded mark only. */
678
- function BrandName() {
679
- useEffect(ensureStyles, [])
680
- const [me, setMe] = useState(sessionCache.me)
681
- useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
682
- const { toggle, menu, dialog } = useUserMenu(me)
683
- return h('span', {
684
- className: 'um-brand-name',
685
- style: { cursor: 'pointer' },
686
- title: me ? me.username : '',
687
- 'aria-haspopup': 'menu',
688
- onClick: toggle,
689
- }, me ? me.username : '', menu, dialog)
690
- }
691
-
692
687
  // ── logout ────────────────────────────────────────────────────────────────
693
688
 
694
689
  /** Locale accessor for slot components living outside apply()'s closure —
@@ -1810,24 +1805,24 @@ window.__ModuleLoader__.load({
1810
1805
  name: CLIENT_NAME,
1811
1806
  inject: ['slots', 'locale'],
1812
1807
  __boot,
1813
- __internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, BrandMark, BrandName, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
1808
+ __internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
1814
1809
  apply(ctx) {
1815
1810
  ctx.locale.register(NS, 'zh', ZH)
1816
1811
  ctx.locale.register(NS, 'en', EN)
1817
1812
  const t = ctx.locale.bind(NS)
1818
1813
  localeT = t
1819
1814
  ctx.effect(() => {
1820
- // Official-brand pattern: nested injects claim both brand seats, one
1821
- // generator registers them. The mark shows in expanded row + collapsed
1822
- // rail (fold-adaptive), the name only beside the expanded mark.
1823
- // priority: -1 shadows the host's default brand registration at 0
1824
- // ("lowest renders"); uninstalling this plugin restores the official
1825
- // brand with no residue.
1826
- ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.inject('sidebar.brand.name', function* () {
1827
- yield ctx.slots.register({ name: 'sidebar.brand.mark', priority: -1 }, BrandMark)
1828
- yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -1 }, BrandName)
1829
- }))
1830
- }, 'user-management: sidebar brand')
1815
+ // The account chip sits at the right end of the conversation header (the
1816
+ // utilities group). The sidebar's brand seats are deliberately left to the
1817
+ // host: its fish logo stays where it was, and uninstalling this plugin
1818
+ // leaves nothing behind.
1819
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
1820
+ name: 'conversation.session.header.utilities',
1821
+ id: `${CLIENT_NAME}:user-menu`,
1822
+ order: 50,
1823
+ locale: NS,
1824
+ }, HeaderUserMenu))
1825
+ }, 'user-management: session header account menu')
1831
1826
  // Token accounting: the session-scoped dock slot is the one seat whose
1832
1827
  // standard props carry `useProjection` — the only place the browser can
1833
1828
  // read the host's own token totals.
package/client/index.js CHANGED
@@ -508,6 +508,10 @@ const STYLE = `
508
508
  .um-wrap { font-size: 13px; color: var(--dsw-alias-label-primary); display: flex; flex-direction: column; gap: 14px; }
509
509
  .um-avatar { border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; color: rgba(255,255,255,.95); font-weight: 600; flex: none; user-select: none; letter-spacing: 0; }
510
510
  .um-brand-name { font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; max-width: 150px; }
511
+ .um-header-user { display: inline-flex; align-items: center; }
512
+ .um-header-user-btn { display: inline-flex; align-items: center; gap: 7px; padding: 3px 10px 3px 3px; border: 1px solid transparent; border-radius: 999px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; line-height: 1; cursor: pointer; }
513
+ .um-header-user-btn:hover { background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-border-l2); }
514
+ .um-header-user-name { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
511
515
  .um-logout { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 12px; padding: 6px 10px; border-radius: 7px; cursor: pointer; flex: none; }
512
516
  .um-logout:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-error-primary); }
513
517
  .um-user-menu { min-width: 216px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.18); padding: 6px; }
@@ -640,45 +644,36 @@ function UserAvatar({ username, size = 22 }) {
640
644
  }, label.charAt(0).toUpperCase())
641
645
  }
642
646
 
643
- /** Sidebar brand mark host renders it in the expanded brand row AND the
644
- * collapsed rail (owner supplies the square `size`), so fold states adapt
645
- * without us tracking the shell. Clicking it (or the name) opens the
646
- * user menu; sign-out sits at the bottom in red. */
647
- function BrandMark({ size, className }) {
647
+ /** Account menu for the right end of the conversation header.
648
+ *
649
+ * It lives in the session header's utilities group rather than the sidebar's
650
+ * brand seat: the brand belongs to the host (its fish logo returns there
651
+ * untouched), while the signed-in account belongs to the surface that acts on
652
+ * its behalf. Clicking opens the same menu used everywhere else — change
653
+ * password, sign out — with sign-out last, in red.
654
+ *
655
+ * Session-scoped by construction: the header only exists once a conversation
656
+ * is open, so on the welcome screen there is no account chip to click. */
657
+ function HeaderUserMenu() {
648
658
  useEffect(ensureStyles, [])
649
659
  const [me, setMe] = useState(sessionCache.me)
650
660
  useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
651
661
  const { toggle, menu, dialog } = useUserMenu(me)
652
- if (!me) {
653
- return h('div', { className, style: { width: size, height: size, borderRadius: '50%', background: 'var(--dsw-alias-bg-layer-3)' } })
654
- }
655
- return h('div', {
656
- className,
657
- style: { cursor: 'pointer', display: 'inline-flex', borderRadius: '50%' },
658
- title: me.username,
659
- 'aria-haspopup': 'menu',
660
- onClick: toggle,
661
- },
662
- h(UserAvatar, { username: me.username, size: size || 24 }),
662
+ if (!me) return null
663
+ return h('div', { className: 'um-header-user' },
664
+ h('button', {
665
+ type: 'button',
666
+ className: 'um-header-user-btn',
667
+ title: me.username,
668
+ 'aria-haspopup': 'menu',
669
+ onClick: toggle,
670
+ },
671
+ h(UserAvatar, { username: me.username, size: 22 }),
672
+ h('span', { className: 'um-header-user-name' }, me.username)),
663
673
  menu,
664
674
  dialog)
665
675
  }
666
676
 
667
- /** Sidebar brand name — host renders it beside the expanded mark only. */
668
- function BrandName() {
669
- useEffect(ensureStyles, [])
670
- const [me, setMe] = useState(sessionCache.me)
671
- useEffect(() => { loadMeCached().then((user) => setMe(user)) }, [])
672
- const { toggle, menu, dialog } = useUserMenu(me)
673
- return h('span', {
674
- className: 'um-brand-name',
675
- style: { cursor: 'pointer' },
676
- title: me ? me.username : '',
677
- 'aria-haspopup': 'menu',
678
- onClick: toggle,
679
- }, me ? me.username : '', menu, dialog)
680
- }
681
-
682
677
  // ── logout ────────────────────────────────────────────────────────────────
683
678
 
684
679
  /** Locale accessor for slot components living outside apply()'s closure —
@@ -1800,24 +1795,24 @@ module.exports = {
1800
1795
  name: CLIENT_NAME,
1801
1796
  inject: ['slots', 'locale'],
1802
1797
  __boot,
1803
- __internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, BrandMark, BrandName, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
1798
+ __internals: { NS, ZH, EN, TYPE_LABELS, api, formatTime, interpolate, filterActivity, filterAudit, avatarHue, UserAvatar, HeaderUserMenu, canBanIp, BanIpButton, ChangePasswordDialog, UserMenu, CertCard, CERT_INSTALL_COMMANDS, UserManagementSection, UserManagementEntry, AdminPanel, TotpCard, TotpSetupDialog, TotpDisableDialog },
1804
1799
  apply(ctx) {
1805
1800
  ctx.locale.register(NS, 'zh', ZH)
1806
1801
  ctx.locale.register(NS, 'en', EN)
1807
1802
  const t = ctx.locale.bind(NS)
1808
1803
  localeT = t
1809
1804
  ctx.effect(() => {
1810
- // Official-brand pattern: nested injects claim both brand seats, one
1811
- // generator registers them. The mark shows in expanded row + collapsed
1812
- // rail (fold-adaptive), the name only beside the expanded mark.
1813
- // priority: -1 shadows the host's default brand registration at 0
1814
- // ("lowest renders"); uninstalling this plugin restores the official
1815
- // brand with no residue.
1816
- ctx.slots.inject('sidebar.brand.mark', () => ctx.slots.inject('sidebar.brand.name', function* () {
1817
- yield ctx.slots.register({ name: 'sidebar.brand.mark', priority: -1 }, BrandMark)
1818
- yield ctx.slots.register({ name: 'sidebar.brand.name', priority: -1 }, BrandName)
1819
- }))
1820
- }, 'user-management: sidebar brand')
1805
+ // The account chip sits at the right end of the conversation header (the
1806
+ // utilities group). The sidebar's brand seats are deliberately left to the
1807
+ // host: its fish logo stays where it was, and uninstalling this plugin
1808
+ // leaves nothing behind.
1809
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
1810
+ name: 'conversation.session.header.utilities',
1811
+ id: `${CLIENT_NAME}:user-menu`,
1812
+ order: 50,
1813
+ locale: NS,
1814
+ }, HeaderUserMenu))
1815
+ }, 'user-management: session header account menu')
1821
1816
  // Token accounting: the session-scoped dock slot is the one seat whose
1822
1817
  // standard props carry `useProjection` — the only place the browser can
1823
1818
  // read the host's own token totals.
package/docs/FAQ.md CHANGED
@@ -57,7 +57,7 @@ rm ~/.dsh/user-management/users.json ~/.dsh/user-management/sessions.json
57
57
 
58
58
  ## 为什么直连 dsh web 端口(19080/3080)没有登录墙
59
59
 
60
- v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:19843`)承担,不是宿主的 19080。直连 19080 等于绕过网关——**请保证 dsh web 只监听 loopback**(127.0.0.1),对外只暴露 19843。本机 `dsh web` 若被改绑到 0.0.0.0:19080,等于没有认证裸奔。
60
+ v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:8088`)承担,不是宿主的 19080。直连 19080 等于绕过网关——**请保证 dsh web 只监听 loopback**(127.0.0.1),对外只暴露 8088。本机 `dsh web` 若被改绑到 0.0.0.0:19080,等于没有认证裸奔。
61
61
 
62
62
  ## 登录后很快又被要求登录
63
63
 
@@ -80,13 +80,13 @@ v0.4 起门禁由本插件**自带的 HTTPS 网关**(默认 `https://<IP>:1984
80
80
  ## 网关没起来 / 端口被占用
81
81
 
82
82
  ```bash
83
- # 谁占着 19843
84
- lsof -iTCP:19843
83
+ # 谁占着 8088
84
+ lsof -iTCP:8088
85
85
  # 启动日志(用户管理所有 [user-management] 前缀的输出都在这里):
86
86
  grep user-management ~/.dsh/logs/web.out.log ~/.dsh/logs/web.err.log
87
87
  ```
88
88
 
89
- - `EADDRINUSE`:19843 被别的进程(或另一个 dsh 实例)占了——杀掉占用者,或在 `~/.dsh/settings.yaml` 的 `user-management:` 段改 `port`
89
+ - `EADDRINUSE`:8088 被别的进程(或另一个 dsh 实例)占了——杀掉占用者,或在 `~/.dsh/settings.yaml` 的 `user-management:` 段改 `port`
90
90
  - 配置了 `enabled: false` 网关不会启动
91
91
  - 改了 hosts/证书配置不生效:删除 `~/.dsh/user-management/certs/` 下的旧证书文件再重启(旧证书按指纹稳定复用,不会自动重签)
92
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kczx-user-management",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",
package/src/certs.js CHANGED
@@ -5,9 +5,11 @@
5
5
  * generates a self-signed one (persisted under the certs dir so the
6
6
  * fingerprint stays stable across restarts — regenerating every boot would
7
7
  * break any client that pinned or trusted the previous cert). Self-signed
8
- * certs are issued for 100 years (days 36500). Persisted certs are NOT
9
- * regenerated automatically: to pick up a new expiry or a changed SAN set,
10
- * delete the cert/key files under the certs dir and restart.
8
+ * certs are issued for 100 years (days 36500). A persisted cert is reused while
9
+ * it still covers every host the site declares; when the host list gains a name
10
+ * it cannot validate (a new local IP, or the loopback literals), it is
11
+ * regenerated instead of silently serving a certificate that fails validation
12
+ * for that name. Expiry is not re-checked: rotate by deleting the files.
11
13
  *
12
14
  * Ported from dsh-gateway (clarknu/dsh-gateway) lib/certs.js, adapted to
13
15
  * CommonJS. SANs cover every host the site declares, as DNS or IP entries
@@ -15,6 +17,7 @@
15
17
  * not the CN) accept the self-signed certificate for each configured name.
16
18
  */
17
19
 
20
+ import { X509Certificate } from 'node:crypto'
18
21
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
19
22
  import { join } from 'node:path'
20
23
  import selfsigned from 'selfsigned'
@@ -40,12 +43,21 @@ function loadOrCreateSiteCert({ cert, key, hosts }, certsDir, log) {
40
43
  const crtPath = join(certsDir, `${safe}.crt`)
41
44
  const keyPath = join(certsDir, `${safe}.key`)
42
45
  if (existsSync(crtPath) && existsSync(keyPath)) {
43
- return {
44
- cert: readFileSync(crtPath, 'utf8'),
45
- key: readFileSync(keyPath, 'utf8'),
46
- auto: true,
47
- certPath: crtPath,
48
- keyPath: keyPath,
46
+ const existing = readFileSync(crtPath, 'utf8')
47
+ if (certCovers(existing, hosts, primary)) {
48
+ return {
49
+ cert: existing,
50
+ key: readFileSync(keyPath, 'utf8'),
51
+ auto: true,
52
+ certPath: crtPath,
53
+ keyPath: keyPath,
54
+ }
55
+ }
56
+ if (typeof log === 'function') {
57
+ log(
58
+ `user-management: regenerating the self-signed certificate for ${JSON.stringify(hosts || [primary])} — ` +
59
+ `the cached one at ${crtPath} does not cover every host`,
60
+ )
49
61
  }
50
62
  }
51
63
  const pems = selfsigned.generate(
@@ -76,6 +88,27 @@ function loadOrCreateSiteCert({ cert, key, hosts }, certsDir, log) {
76
88
  return { cert: pems.cert, key: pems.private, auto: true, certPath: crtPath, keyPath: keyPath }
77
89
  }
78
90
 
91
+ /**
92
+ * Does an already-persisted certificate still cover every name we need?
93
+ *
94
+ * Browsers validate the SAN list, so a cached certificate that predates a host
95
+ * is not merely stale — it fails for that name. checkIP/checkHost also
96
+ * normalize IPv6 spellings (`::1` vs the expanded form) for us, which a string
97
+ * comparison would get wrong and would then regenerate on every boot.
98
+ */
99
+ function certCovers(certPem, hosts, primary) {
100
+ let certificate
101
+ try {
102
+ certificate = new X509Certificate(certPem)
103
+ } catch {
104
+ return false
105
+ }
106
+ return sanEntries(hosts, primary).every((entry) => {
107
+ if (entry.type === 7) return certificate.checkIP(String(entry.ip)) !== undefined
108
+ return certificate.checkHost(String(entry.value)) !== undefined
109
+ })
110
+ }
111
+
79
112
  /** node-forge SAN entries: type 2 = DNS, type 7 = iPAddress (IPv4 or IPv6 literal). */
80
113
  function sanEntries(hosts, primary) {
81
114
  const names = [...new Set([...(hosts || []), primary].filter((h) => h && h !== '*'))]
@@ -86,4 +119,4 @@ function sanEntries(hosts, primary) {
86
119
  })
87
120
  }
88
121
 
89
- export { loadOrCreateSiteCert, sanEntries }
122
+ export { loadOrCreateSiteCert, sanEntries, certCovers }
@@ -116,6 +116,16 @@ function createGateway(options) {
116
116
  /** workspaceId → path, harvested from workspace-list responses (see below). */
117
117
  const workspacePathById = new Map()
118
118
 
119
+ /** ?lang=en switches the standalone door page; anything else stays Chinese. */
120
+ function loginPageLang(req) {
121
+ try {
122
+ const lang = new URL(req.url || '/', 'http://dsh.local').searchParams.get('lang')
123
+ return lang === 'en' ? 'en' : 'zh'
124
+ } catch {
125
+ return 'zh'
126
+ }
127
+ }
128
+
119
129
  function requestPath(req) {
120
130
  try { return new URL(req.url || '/', 'http://dsh.local').pathname } catch { return '' }
121
131
  }
@@ -600,8 +610,10 @@ function createGateway(options) {
600
610
  }))
601
611
  }
602
612
 
603
- // /login — standalone login/register page (register tab is default on a
604
- // fresh system; first registrant becomes admin). Authed users bounce to /.
613
+ // /login — the standalone door page. On a fresh system it renders the
614
+ // setup form instead of the login form (the owner account is created there
615
+ // with the server's setup key; self-registration is closed). Authed users
616
+ // bounce to /.
605
617
  if (path === LOGIN_PAGE_PATH) {
606
618
  if (method !== 'GET' && method !== 'HEAD') {
607
619
  return send(res, 405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD' }, 'method not allowed')
@@ -610,6 +622,7 @@ function createGateway(options) {
610
622
  const html = options.renderLoginPage({
611
623
  hasUsers: options.deps.store.listUsers().length > 0,
612
624
  title: options.title || 'DSH 控制台',
625
+ lang: loginPageLang(req),
613
626
  })
614
627
  return send(res, 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, html)
615
628
  }
@@ -759,7 +772,7 @@ function createGateway(options) {
759
772
  const onError = (err) => { server.removeListener('listening', onListening); reject(err) }
760
773
  server.once('listening', onListening)
761
774
  server.once('error', onError)
762
- server.listen({ host: options.listenHost || '0.0.0.0', port: options.port != null ? options.port : 19843 })
775
+ server.listen({ host: options.listenHost || '0.0.0.0', port: options.port != null ? options.port : 8088 })
763
776
  })
764
777
  boundPort = server.address().port
765
778
  server.on('error', (err) => {