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.
- package/CUTOVER-RUNBOOK.md +2 -2
- package/MERGED-DEPLOYMENT.md +2 -2
- package/README.md +23 -4
- package/client/bundle.js +38 -43
- package/client/index.js +38 -43
- package/docs/FAQ.md +4 -4
- package/package.json +1 -1
- package/src/certs.js +43 -10
- package/src/gateway-core.js +16 -3
- package/src/index.js +59 -25
- package/src/login-page.js +392 -145
- package/src/setup-key.js +117 -0
package/CUTOVER-RUNBOOK.md
CHANGED
|
@@ -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:
|
|
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>:
|
|
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.
|
package/MERGED-DEPLOYMENT.md
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
116
|
+
port: 8088
|
|
98
117
|
plaintext: false # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
|
|
99
118
|
sites: [] # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
|
|
100
119
|
title: 'DSH 控制台'
|
|
101
|
-
|
|
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
|
-
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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,
|
|
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
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
}))
|
|
1830
|
-
}, 'user-management:
|
|
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
|
-
/**
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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,
|
|
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
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
}))
|
|
1820
|
-
}, 'user-management:
|
|
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>:
|
|
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
|
-
# 谁占着
|
|
84
|
-
lsof -iTCP:
|
|
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`:
|
|
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.
|
|
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).
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 }
|
package/src/gateway-core.js
CHANGED
|
@@ -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
|
|
604
|
-
//
|
|
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 :
|
|
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) => {
|