kczx-user-management 1.0.0
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 +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-plugin-user-management — Host half
|
|
5
|
+
*
|
|
6
|
+
* An HTTPS remote-access gateway + user administration for the dsh web
|
|
7
|
+
* surface. The plugin spins up its OWN node:https listener (TLS + self-signed
|
|
8
|
+
* certs + Host allow-list) that reverse-proxies to the loopback dsh
|
|
9
|
+
* webserver, with user-management's own user store as the auth:
|
|
10
|
+
* - Standalone /login page (login + register tabs; first registrant becomes
|
|
11
|
+
* admin). Unauthenticated page navigations redirect to /login, API/WS
|
|
12
|
+
* traffic is answered 401.
|
|
13
|
+
* - Optional TOTP two-step verification (RFC 6238): self-service enrollment
|
|
14
|
+
* with QR + manual entry, OTP demanded after a correct password at login,
|
|
15
|
+
* brute-force lockout, single-use replay guard, and an admin recovery
|
|
16
|
+
* reset. See src/totp.js and the /me/totp/* + /users/:id/reset-totp APIs.
|
|
17
|
+
* - JSON API under /user-management/api: sessions, self service, admin user
|
|
18
|
+
* management (list / delete / reset password / role / disable), IP bans,
|
|
19
|
+
* and the activity + audit ledgers.
|
|
20
|
+
* - Everything else (the SPA, dsh /api, /plugins, static) is proxied to the
|
|
21
|
+
* loopback dsh web — but only past the auth gate, so the loopback dsh web
|
|
22
|
+
* stays unreachable directly and the auth cannot be bypassed.
|
|
23
|
+
*
|
|
24
|
+
* Role model: the first account registered into an empty system becomes
|
|
25
|
+
* admin; later registrations are plain users. Admins manage everyone,
|
|
26
|
+
* plain users see/change only themselves.
|
|
27
|
+
*
|
|
28
|
+
* Data lives in `$DSH_HOME/user-management/` (0600, atomic writes) — see
|
|
29
|
+
* src/store.js. Self-signed certs live under `$DSH_HOME/user-management/certs/`.
|
|
30
|
+
*
|
|
31
|
+
* IDENTITY FOR SIBLING PLUGINS: this plugin provides the cordis service
|
|
32
|
+
* `user-management` ({ resolveRequest(req), resolveToken(token) }) so any
|
|
33
|
+
* host-plane plugin can map an incoming request's `um_session` cookie to
|
|
34
|
+
* `{ id, username, role, ... }` — e.g. to attribute scheduled items, shares
|
|
35
|
+
* or edits to a user. Consume it with RUNTIME `ctx.inject(['user-management'],
|
|
36
|
+
* cb)` only (statically injecting an optional service hangs activation);
|
|
37
|
+
* full examples in the README section 「给其他插件:解析请求的用户身份」 and
|
|
38
|
+
* on createIdentityService below.
|
|
39
|
+
*
|
|
40
|
+
* The network-access layer (gateway-core/proxy/certs + hot-reload + self-heal)
|
|
41
|
+
* is adapted from dsh-gateway (clarknu/dsh-gateway); the auth backend is
|
|
42
|
+
* user-management's store (um_session), NOT dsh-gateway's flat HMAC users.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFileSync } from 'node:fs'
|
|
46
|
+
import { join } from 'node:path'
|
|
47
|
+
import { networkInterfaces } from 'node:os'
|
|
48
|
+
import { request as httpRequest } from 'node:http'
|
|
49
|
+
import { request as httpsRequest } from 'node:https'
|
|
50
|
+
import z from '@deepseek-ai/schemastery'
|
|
51
|
+
import {
|
|
52
|
+
createStore,
|
|
53
|
+
dshHome,
|
|
54
|
+
normalizeIp,
|
|
55
|
+
tempPassword,
|
|
56
|
+
StoreError,
|
|
57
|
+
ACTIVITY_LIMIT_DEFAULT,
|
|
58
|
+
normalizePermissions,
|
|
59
|
+
} from './store.js'
|
|
60
|
+
import { createTrustedIdentity } from './trusted-identity.js'
|
|
61
|
+
import { otpauthUri } from './totp.js'
|
|
62
|
+
import qrCodeFactory from './vendor/qrcode-generator.js'
|
|
63
|
+
import {
|
|
64
|
+
SESSION_COOKIE,
|
|
65
|
+
API_PREFIX,
|
|
66
|
+
createDecider,
|
|
67
|
+
parseCookies,
|
|
68
|
+
isAuditableRequest,
|
|
69
|
+
} from './gate.js'
|
|
70
|
+
import { renderLoginPage } from './login-page.js'
|
|
71
|
+
import { createGateway } from './gateway-core.js'
|
|
72
|
+
|
|
73
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
74
|
+
|
|
75
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
76
|
+
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
77
|
+
/**
|
|
78
|
+
* Compact "what changed" text for a permission edit. The ledger is read by
|
|
79
|
+
* humans scanning for who loosened or tightened what — a full JSON blob would
|
|
80
|
+
* bury that, and folder lists can be long.
|
|
81
|
+
*/
|
|
82
|
+
function describePermissionDelta(before, after) {
|
|
83
|
+
const parts = []
|
|
84
|
+
for (const key of Object.keys(after)) {
|
|
85
|
+
const toText = (value) => (Array.isArray(value) ? value.join('|') : value === null ? '' : String(value))
|
|
86
|
+
const a = toText(after[key])
|
|
87
|
+
const b = toText(before ? before[key] : undefined)
|
|
88
|
+
if (a !== b) parts.push(`${key}=${a === '' ? '(none)' : a}`)
|
|
89
|
+
}
|
|
90
|
+
return parts.length > 0 ? parts.join(' ') : 'no change'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Ledger types a plain user may read about themselves. */
|
|
94
|
+
const SELF_ACTIVITY_TYPES = ['login', 'login_failed', 'logout', 'password_change', 'totp_enabled', 'totp_disabled']
|
|
95
|
+
const ADMIN_ACTIVITY_TYPES = ['login', 'login_failed', 'logout', 'password_change', 'register', 'reset_password', 'role_change', 'delete_user', 'access', 'totp_enabled', 'totp_disabled', 'totp_reset', 'user_created', 'user_disabled', 'user_enabled', 'permission_change', 'audit_clear', 'ban_ip', 'unban_ip']
|
|
96
|
+
const AUDIT_LIMIT_DEFAULT = 200
|
|
97
|
+
/** TOTP brute-force guard: lock an account's OTP check after this many
|
|
98
|
+
* consecutive bad codes (the code space is only 10^6, so without this an
|
|
99
|
+
* attacker holding the password could grind the 3-valid-codes-per-30s
|
|
100
|
+
* window indefinitely). In-memory only — a restart clears it. */
|
|
101
|
+
const OTP_FAIL_LIMIT = 5
|
|
102
|
+
const OTP_LOCKOUT_MS = 60 * 1000
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Per-username lockout after consecutive TOTP failures. Deliberately simple:
|
|
106
|
+
* `fail()` counts, and reaching the limit locks for `lockoutMs` (during which
|
|
107
|
+
* even a correct code is rejected — pacing attacks defeat count-only guards).
|
|
108
|
+
*/
|
|
109
|
+
function createOtpGuard({ limit = OTP_FAIL_LIMIT, lockoutMs = OTP_LOCKOUT_MS, now = () => Date.now() } = {}) {
|
|
110
|
+
const state = new Map() // username(lowercased) → { fails, lockedUntil }
|
|
111
|
+
const keyOf = (username) => String(username || '').toLowerCase()
|
|
112
|
+
return {
|
|
113
|
+
fail(username) {
|
|
114
|
+
const key = keyOf(username)
|
|
115
|
+
const rec = state.get(key) || { fails: 0, lockedUntil: 0 }
|
|
116
|
+
rec.fails += 1
|
|
117
|
+
if (rec.fails >= limit) {
|
|
118
|
+
rec.lockedUntil = now() + lockoutMs
|
|
119
|
+
rec.fails = 0
|
|
120
|
+
}
|
|
121
|
+
state.set(key, rec)
|
|
122
|
+
},
|
|
123
|
+
/** Remaining lockout in ms; 0 = not locked. */
|
|
124
|
+
locked(username) {
|
|
125
|
+
const rec = state.get(keyOf(username))
|
|
126
|
+
if (!rec) return 0
|
|
127
|
+
return Math.max(0, rec.lockedUntil - now())
|
|
128
|
+
},
|
|
129
|
+
reset(username) {
|
|
130
|
+
state.delete(keyOf(username))
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Self-contained QR SVG for the enrollment URI (white ground, scannable). */
|
|
136
|
+
function totpQrSvg(uri) {
|
|
137
|
+
const qr = qrCodeFactory(0, 'M')
|
|
138
|
+
qr.addData(uri)
|
|
139
|
+
qr.make()
|
|
140
|
+
return qr.createSvgTag({ cellSize: 5, margin: 2 })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sendJson(res, status, payload, extraHeaders) {
|
|
144
|
+
res.writeHead(status, Object.assign({ 'content-type': 'application/json; charset=utf-8' }, extraHeaders || {}))
|
|
145
|
+
res.end(JSON.stringify(payload))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readJsonBody(req) {
|
|
149
|
+
return new Promise((fulfil, reject) => {
|
|
150
|
+
let size = 0
|
|
151
|
+
const chunks = []
|
|
152
|
+
req.on('data', (chunk) => {
|
|
153
|
+
size += chunk.length
|
|
154
|
+
if (size > MAX_BODY_BYTES) { reject(new Error('request body too large')); req.destroy(); return }
|
|
155
|
+
chunks.push(chunk)
|
|
156
|
+
})
|
|
157
|
+
req.on('end', () => {
|
|
158
|
+
try { fulfil(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
|
|
159
|
+
catch (error) { reject(new StoreError('bad_json', `invalid JSON body: ${error && error.message}`)) }
|
|
160
|
+
})
|
|
161
|
+
req.on('error', reject)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Secure because the gateway is HTTPS-only now (the shared-server HTTP gate is gone).
|
|
166
|
+
function sessionCookie(token) {
|
|
167
|
+
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=${SESSION_TTL_SECONDS}`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearedCookie() {
|
|
171
|
+
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Normalize an API path: strip trailing slashes (keep the root). */
|
|
175
|
+
function normalizeApiPath(pathname) {
|
|
176
|
+
const stripped = pathname.replace(/\/+$/, '')
|
|
177
|
+
return stripped === '' ? pathname : stripped
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function statusForStoreError(error) {
|
|
181
|
+
switch (error && error.code) {
|
|
182
|
+
case 'last_admin': return 409
|
|
183
|
+
case 'not_found': return 404
|
|
184
|
+
default: return 400
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The full API request handler, factored out for tests. `deps`:
|
|
190
|
+
* { store, clientIp(req) }.
|
|
191
|
+
* Anonymous (no session) → 401 on every non-public endpoint; an authenticated
|
|
192
|
+
* non-admin hitting an admin endpoint → 403. The gateway also 401s anonymous
|
|
193
|
+
* non-public paths before reaching here (defense in depth), but handleApi is
|
|
194
|
+
* self-consistent without it so it can be tested on a bare http server.
|
|
195
|
+
*/
|
|
196
|
+
async function handleApi(req, res, deps) {
|
|
197
|
+
const url = new URL(req.url || '/', 'http://dsh.local')
|
|
198
|
+
const path = normalizeApiPath(url.pathname)
|
|
199
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
200
|
+
const { store } = deps
|
|
201
|
+
const apiPath = path.startsWith(`${API_PREFIX}/`) ? path.slice(API_PREFIX.length) : path
|
|
202
|
+
|
|
203
|
+
const authed = async () => {
|
|
204
|
+
// A signature-bearing request from the co-located login gateway has no
|
|
205
|
+
// um_session cookie (it never logged in here) — the trusted identity IS
|
|
206
|
+
// the session, so the ledgers, the user table and the admin APIs all keep
|
|
207
|
+
// working behind the single door.
|
|
208
|
+
if (typeof deps.resolveTrusted === 'function') {
|
|
209
|
+
const trusted = deps.resolveTrusted(req)
|
|
210
|
+
if (trusted) return trusted
|
|
211
|
+
}
|
|
212
|
+
const cookies = parseCookies(req.headers && req.headers.cookie)
|
|
213
|
+
return store.resolveSession(cookies[SESSION_COOKIE])
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// requireAdmin: 401 anonymous, 403 authenticated non-admin, else the admin session.
|
|
217
|
+
const requireAdmin = async () => {
|
|
218
|
+
const session = await authed()
|
|
219
|
+
if (!session) return { ok: false, status: 401, message: '未登录' }
|
|
220
|
+
if (session.user.role !== 'admin') return { ok: false, status: 403, message: '需要管理员权限' }
|
|
221
|
+
return { ok: true, session }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── anonymous endpoints ──────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
if (apiPath === '/login' && method === 'POST') {
|
|
227
|
+
const body = await readJsonBody(req)
|
|
228
|
+
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
|
229
|
+
const outcome = await store.checkLogin(username, body.password)
|
|
230
|
+
if (outcome.result === 'invalid') {
|
|
231
|
+
await store.appendActivity({ type: 'login_failed', username: username || null, ip: deps.clientIp(req), detail: 'wrong credentials' })
|
|
232
|
+
return sendJson(res, 401, { error: '用户名或密码错误' })
|
|
233
|
+
}
|
|
234
|
+
if (outcome.result === 'disabled') {
|
|
235
|
+
await store.appendActivity({ type: 'login_failed', username: username, userId: outcome.user.id, ip: deps.clientIp(req), detail: 'account disabled' })
|
|
236
|
+
return sendJson(res, 403, { error: '账号已被禁用,请联系管理员' })
|
|
237
|
+
}
|
|
238
|
+
const user = outcome.user
|
|
239
|
+
// TOTP step — only revealed AFTER the password is correct (otpRequired in
|
|
240
|
+
// the response tells the login page to show the code field; a missing or
|
|
241
|
+
// wrong code never discloses whether the account has TOTP to a caller who
|
|
242
|
+
// doesn't hold valid credentials).
|
|
243
|
+
if (user.totpSecret) {
|
|
244
|
+
const guard = deps.otpGuard || (deps.otpGuard = createOtpGuard())
|
|
245
|
+
const lockedFor = guard.locked(username)
|
|
246
|
+
if (lockedFor > 0) {
|
|
247
|
+
await store.appendActivity({ type: 'login_failed', username, userId: user.id, ip: deps.clientIp(req), detail: 'totp attempts locked' })
|
|
248
|
+
return sendJson(res, 429, { error: `两步验证失败次数过多,请 ${Math.ceil(lockedFor / 1000)} 秒后再试`, otpRequired: true })
|
|
249
|
+
}
|
|
250
|
+
const code = typeof body.otp === 'string' ? body.otp.trim() : ''
|
|
251
|
+
if (code === '') {
|
|
252
|
+
return sendJson(res, 401, { error: '请输入两步验证码', otpRequired: true })
|
|
253
|
+
}
|
|
254
|
+
const verdict = await store.verifyLoginOtp(user, code)
|
|
255
|
+
if (!verdict.ok) {
|
|
256
|
+
guard.fail(username)
|
|
257
|
+
await store.appendActivity({ type: 'login_failed', username, userId: user.id, ip: deps.clientIp(req), detail: 'invalid totp' })
|
|
258
|
+
return sendJson(res, 401, { error: '两步验证码错误', otpRequired: true })
|
|
259
|
+
}
|
|
260
|
+
guard.reset(username)
|
|
261
|
+
}
|
|
262
|
+
const { token } = await store.createSession(user)
|
|
263
|
+
await store.touchLogin(user)
|
|
264
|
+
await store.appendActivity({ type: 'login', username: user.username, userId: user.id, ip: deps.clientIp(req) })
|
|
265
|
+
return sendJson(res, 200, { user: store.publicUser(user) }, { 'set-cookie': sessionCookie(token) })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (apiPath === '/register' && method === 'POST') {
|
|
269
|
+
const body = await readJsonBody(req)
|
|
270
|
+
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
|
271
|
+
const role = store.roleForNextRegistration()
|
|
272
|
+
// Approval mode (autoActivate off, the default): every self-registration
|
|
273
|
+
// except the first admin starts DISABLED until an admin enables the
|
|
274
|
+
// account — the users-table enable button is the approval action. The
|
|
275
|
+
// bare-handler test harness (no deps.autoActivate) keeps classic
|
|
276
|
+
// auto-activation so old flows read unchanged.
|
|
277
|
+
const autoActivate = deps.autoActivate ? deps.autoActivate() === true : true
|
|
278
|
+
const pending = role !== 'admin' && !autoActivate
|
|
279
|
+
try {
|
|
280
|
+
const created = await store.createUser({ username, password: body.password, role, disabled: pending })
|
|
281
|
+
if (pending) {
|
|
282
|
+
await store.appendActivity({ type: 'register', username: created.username, userId: created.id, ip: deps.clientIp(req), detail: `role=${role} pending` })
|
|
283
|
+
return sendJson(res, 200, { user: created, pending: true })
|
|
284
|
+
}
|
|
285
|
+
const user = store.findUserByUsername(created.username)
|
|
286
|
+
const { token } = await store.createSession(user)
|
|
287
|
+
await store.touchLogin(user)
|
|
288
|
+
await store.appendActivity({ type: 'register', username: created.username, userId: created.id, ip: deps.clientIp(req), detail: `role=${role}` })
|
|
289
|
+
return sendJson(res, 200, { user: created }, { 'set-cookie': sessionCookie(token) })
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
292
|
+
throw error
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (apiPath === '/session' && method === 'GET') {
|
|
297
|
+
const session = await authed()
|
|
298
|
+
return sendJson(res, 200, {
|
|
299
|
+
user: session ? store.publicUser(session.user) : null,
|
|
300
|
+
// Observer mode (merged deployment): this layer sits behind the single
|
|
301
|
+
// login door, so the client hides everything that only makes sense for a
|
|
302
|
+
// door of its own — accounts, bans, certificates, 2FA, sign-out — and
|
|
303
|
+
// shows the ledgers it exists for.
|
|
304
|
+
observer: typeof deps.observerMode === 'function' ? deps.observerMode() === true : false,
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (apiPath === '/logout' && method === 'POST') {
|
|
309
|
+
const session = await authed()
|
|
310
|
+
if (session) {
|
|
311
|
+
await store.dropSession(session.token)
|
|
312
|
+
await store.appendActivity({ type: 'logout', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
|
|
313
|
+
}
|
|
314
|
+
return sendJson(res, 200, { ok: true }, { 'set-cookie': clearedCookie() })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── authenticated self service ───────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
if (apiPath === '/me/password' && method === 'POST') {
|
|
320
|
+
const session = await authed()
|
|
321
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
322
|
+
const body = await readJsonBody(req)
|
|
323
|
+
const ok = await store.verifyLogin(session.user.username, body.oldPassword)
|
|
324
|
+
if (!ok) return sendJson(res, 400, { error: '当前密码不正确' })
|
|
325
|
+
try {
|
|
326
|
+
await store.setPassword(session.user, body.newPassword)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
await store.dropUserSessions(session.user.id, session.token)
|
|
332
|
+
await store.appendActivity({ type: 'password_change', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
|
|
333
|
+
return sendJson(res, 200, { ok: true })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── TOTP self-service enrollment ─────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
// Step 1: mint the (pending) secret. The QR + otpauth URI are generated
|
|
339
|
+
// here so the browser half stays bundle-light.
|
|
340
|
+
if (apiPath === '/me/totp/setup' && method === 'POST') {
|
|
341
|
+
const session = await authed()
|
|
342
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
343
|
+
try {
|
|
344
|
+
const { secret } = await store.startTotpSetup(session.user)
|
|
345
|
+
const uri = otpauthUri({ username: session.user.username, secret })
|
|
346
|
+
return sendJson(res, 200, { secret, otpauth: uri, qrSvg: totpQrSvg(uri) })
|
|
347
|
+
} catch (error) {
|
|
348
|
+
if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
|
|
349
|
+
throw error
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Step 2: prove possession of the secret with a live code.
|
|
354
|
+
if (apiPath === '/me/totp/activate' && method === 'POST') {
|
|
355
|
+
const session = await authed()
|
|
356
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
357
|
+
const body = await readJsonBody(req)
|
|
358
|
+
let activated = false
|
|
359
|
+
try {
|
|
360
|
+
activated = await store.activateTotp(session.user, body.code)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
|
|
363
|
+
throw error
|
|
364
|
+
}
|
|
365
|
+
if (!activated) return sendJson(res, 400, { error: '动态码不正确,请确认验证器时间已同步后重试' })
|
|
366
|
+
await store.appendActivity({ type: 'totp_enabled', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
|
|
367
|
+
return sendJson(res, 200, { ok: true })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Disable: identity re-confirmed by password (a stolen session alone must
|
|
371
|
+
// not be able to weaken the account). Existing sessions are kept — they
|
|
372
|
+
// already passed the full login.
|
|
373
|
+
if (apiPath === '/me/totp/disable' && method === 'POST') {
|
|
374
|
+
const session = await authed()
|
|
375
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
376
|
+
const body = await readJsonBody(req)
|
|
377
|
+
const ok = await store.verifyLogin(session.user.username, body.password)
|
|
378
|
+
if (!ok) return sendJson(res, 400, { error: '登录密码不正确' })
|
|
379
|
+
try {
|
|
380
|
+
await store.disableTotp(session.user)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
|
|
383
|
+
throw error
|
|
384
|
+
}
|
|
385
|
+
await store.appendActivity({ type: 'totp_disabled', username: session.user.username, userId: session.user.id, ip: deps.clientIp(req) })
|
|
386
|
+
return sendJson(res, 200, { ok: true })
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (apiPath === '/users' && method === 'GET') {
|
|
390
|
+
const session = await authed()
|
|
391
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
392
|
+
// Each row carries today's consumption: an administrator setting a limit is
|
|
393
|
+
// looking at the account it applies to, and a plain user wants to see their
|
|
394
|
+
// own allowance. Null means "no activity recorded today", not zero.
|
|
395
|
+
const withUsage = (row) => ({ ...row, usage: store.getUsage(row.username) })
|
|
396
|
+
if (session.user.role === 'admin') return sendJson(res, 200, { users: store.listUsers().map(withUsage) })
|
|
397
|
+
return sendJson(res, 200, { users: [withUsage(store.publicUser(session.user))] })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── 内部:接收登录门(dsh-passwords)的会话事件 ──────────────────────────
|
|
401
|
+
// 合并部署下登录发生在唯一入口,这本「登录记录」只能靠它推过来。凭据就是
|
|
402
|
+
// 签名身份头本身:只有回环 + 有效 HMAC 才进得来(resolveTrusted 已校验),
|
|
403
|
+
// 路径因此列在 PUBLIC_PATHS 里(浏览器路径的会话检查对它不适用)。
|
|
404
|
+
if (apiPath === '/internal/session-event' && method === 'POST') {
|
|
405
|
+
const trusted = typeof deps.resolveTrusted === 'function' ? deps.resolveTrusted(req) : null
|
|
406
|
+
if (!trusted) return sendJson(res, 401, { error: 'untrusted' })
|
|
407
|
+
const body = await readJsonBody(req)
|
|
408
|
+
const type = typeof body.type === 'string' ? body.type : ''
|
|
409
|
+
if (!INTERNAL_SESSION_TYPES.has(type)) return sendJson(res, 400, { error: 'unknown event' })
|
|
410
|
+
await store.appendActivity({
|
|
411
|
+
type,
|
|
412
|
+
username: trusted.user.username,
|
|
413
|
+
userId: trusted.user.id,
|
|
414
|
+
ip: deps.clientIp(req),
|
|
415
|
+
detail: typeof body.detail === 'string' && body.detail !== '' ? body.detail : undefined,
|
|
416
|
+
})
|
|
417
|
+
return sendJson(res, 200, { ok: true })
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── token usage reporting (any signed-in account) ────────────────────────
|
|
421
|
+
// dsh's own client projection is the only reliable source of provider
|
|
422
|
+
// accounting (the gateway cannot see it, and reconstructing it from response
|
|
423
|
+
// bodies was abandoned as unreliable), so the browser reports DELTAS of that
|
|
424
|
+
// projection and this side accumulates them into the hourly window the quota
|
|
425
|
+
// is measured against. Deltas are idempotent from the client's point of view
|
|
426
|
+
// — it tracks the last cumulative value — so a duplicate report cannot inflate
|
|
427
|
+
// a quota.
|
|
428
|
+
if (apiPath === '/usage/report' && method === 'POST') {
|
|
429
|
+
const session = await authed()
|
|
430
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
431
|
+
const body = await readJsonBody(req)
|
|
432
|
+
const tokens = Number(body.tokens)
|
|
433
|
+
if (!Number.isFinite(tokens) || tokens < 0 || tokens > 100_000_000) {
|
|
434
|
+
return sendJson(res, 400, { error: 'tokens 无效' })
|
|
435
|
+
}
|
|
436
|
+
await store.addTokens(session.user.username, Math.round(tokens))
|
|
437
|
+
return sendJson(res, 200, { ok: true })
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (apiPath === '/activity' && method === 'GET') {
|
|
441
|
+
const session = await authed()
|
|
442
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
443
|
+
const isAdmin = session.user.role === 'admin'
|
|
444
|
+
const type = url.searchParams.get('type') || undefined
|
|
445
|
+
const limit = Number(url.searchParams.get('limit')) || ACTIVITY_LIMIT_DEFAULT
|
|
446
|
+
if (!isAdmin) {
|
|
447
|
+
const entries = await store.listActivity({ types: SELF_ACTIVITY_TYPES, userId: session.user.id, limit })
|
|
448
|
+
return sendJson(res, 200, { entries })
|
|
449
|
+
}
|
|
450
|
+
const username = url.searchParams.get('username')
|
|
451
|
+
const target = username ? store.findUserByUsername(username) : null
|
|
452
|
+
const entries = await store.listActivity({
|
|
453
|
+
type,
|
|
454
|
+
userId: username ? (target ? target.id : 'NoSuchUser') : undefined,
|
|
455
|
+
limit,
|
|
456
|
+
})
|
|
457
|
+
return sendJson(res, 200, { entries })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (apiPath === '/audit' && method === 'GET') {
|
|
461
|
+
const session = await authed()
|
|
462
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
463
|
+
if (session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
|
|
464
|
+
const entries = await store.listAudit({
|
|
465
|
+
username: url.searchParams.get('username') || undefined,
|
|
466
|
+
method: url.searchParams.get('method') || undefined,
|
|
467
|
+
path: url.searchParams.get('path') || undefined,
|
|
468
|
+
statusClass: url.searchParams.get('statusClass') || undefined,
|
|
469
|
+
limit: Number(url.searchParams.get('limit')) || AUDIT_LIMIT_DEFAULT,
|
|
470
|
+
})
|
|
471
|
+
return sendJson(res, 200, { entries })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (apiPath === '/audit' && method === 'DELETE') {
|
|
475
|
+
const admin = await requireAdmin()
|
|
476
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
477
|
+
await store.clearAudit()
|
|
478
|
+
await store.appendActivity({ type: 'audit_clear', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req) })
|
|
479
|
+
return sendJson(res, 200, { ok: true })
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const auditDeleteMatch = /^\/audit\/([A-Za-z0-9_-]+)$/.exec(apiPath)
|
|
483
|
+
if (auditDeleteMatch && method === 'DELETE') {
|
|
484
|
+
const admin = await requireAdmin()
|
|
485
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
486
|
+
const removed = await store.removeAuditEntry(auditDeleteMatch[1])
|
|
487
|
+
if (!removed) return sendJson(res, 404, { error: '记录不存在' })
|
|
488
|
+
return sendJson(res, 200, { ok: true })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── admin-only IP bans ───────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
if (apiPath === '/bans' && method === 'GET') {
|
|
494
|
+
const session = await authed()
|
|
495
|
+
if (!session) return sendJson(res, 401, { error: '未登录' })
|
|
496
|
+
if (session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
|
|
497
|
+
return sendJson(res, 200, { bans: store.listBans(), selfIp: deps.clientIp(req) })
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (apiPath === '/bans' && method === 'POST') {
|
|
501
|
+
const admin = await requireAdmin()
|
|
502
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
503
|
+
const body = await readJsonBody(req)
|
|
504
|
+
const ip = typeof body.ip === 'string' ? body.ip.trim() : ''
|
|
505
|
+
if (ip === deps.clientIp(req)) return sendJson(res, 400, { error: '不能封禁当前正在使用的 IP' })
|
|
506
|
+
try {
|
|
507
|
+
await store.banIp(ip, { note: body.note, by: admin.session.user.username })
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
510
|
+
throw error
|
|
511
|
+
}
|
|
512
|
+
await store.appendActivity({ type: 'ban_ip', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `ip=${ip}${body.note ? ` note=${body.note}` : ''}` })
|
|
513
|
+
return sendJson(res, 200, { ok: true })
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const unbanMatch = /^\/bans\/(.+)$/.exec(apiPath)
|
|
517
|
+
if (unbanMatch && method === 'DELETE') {
|
|
518
|
+
const admin = await requireAdmin()
|
|
519
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
520
|
+
const ip = decodeURIComponent(unbanMatch[1])
|
|
521
|
+
try {
|
|
522
|
+
await store.unbanIp(ip)
|
|
523
|
+
} catch (error) {
|
|
524
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
525
|
+
throw error
|
|
526
|
+
}
|
|
527
|
+
await store.appendActivity({ type: 'unban_ip', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `ip=${ip}` })
|
|
528
|
+
return sendJson(res, 200, { ok: true })
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── admin-only user administration ───────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
if (apiPath === '/users' && method === 'POST') {
|
|
534
|
+
const admin = await requireAdmin()
|
|
535
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
536
|
+
const body = await readJsonBody(req)
|
|
537
|
+
try {
|
|
538
|
+
const created = await store.createUser({ username: body.username, password: body.password, role: body.role || 'user' })
|
|
539
|
+
await store.appendActivity({ type: 'user_created', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `created=${created.username} role=${created.role}` })
|
|
540
|
+
return sendJson(res, 200, { user: created })
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
543
|
+
throw error
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const disableMatch = /^\/users\/([A-Za-z0-9_-]+)\/disabled$/.exec(apiPath)
|
|
548
|
+
if (disableMatch && method === 'POST') {
|
|
549
|
+
const admin = await requireAdmin()
|
|
550
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
551
|
+
const target = store.findUser(disableMatch[1])
|
|
552
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
553
|
+
if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能禁用自己的账号' })
|
|
554
|
+
const body = await readJsonBody(req)
|
|
555
|
+
const disabled = !!body.disabled
|
|
556
|
+
try {
|
|
557
|
+
await store.setDisabled(target, disabled)
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
560
|
+
throw error
|
|
561
|
+
}
|
|
562
|
+
if (disabled) await store.dropUserSessions(target.id)
|
|
563
|
+
await store.appendActivity({
|
|
564
|
+
type: disabled ? 'user_disabled' : 'user_enabled',
|
|
565
|
+
username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req),
|
|
566
|
+
detail: `target=${target.username}`,
|
|
567
|
+
})
|
|
568
|
+
return sendJson(res, 200, { user: store.publicUser(target) })
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── per-user permissions & quotas (admin) ────────────────────────────────
|
|
572
|
+
// The account model dsh-passwords used to own: workspace scope, hourly token
|
|
573
|
+
// cap, daily minutes, sandbox floor, upload/git switches and the policy ban.
|
|
574
|
+
// GET reads, PUT/PATCH merges a partial patch (absent keys keep their value).
|
|
575
|
+
const permissionsMatch = /^\/users\/([A-Za-z0-9_-]+)\/permissions$/.exec(apiPath)
|
|
576
|
+
if (permissionsMatch) {
|
|
577
|
+
const admin = await requireAdmin()
|
|
578
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
579
|
+
const target = store.findUser(permissionsMatch[1])
|
|
580
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
581
|
+
if (method === 'GET') return sendJson(res, 200, { user: store.publicUser(target) })
|
|
582
|
+
if (method === 'PUT' || method === 'PATCH') {
|
|
583
|
+
const body = await readJsonBody(req)
|
|
584
|
+
const before = store.publicUser(target).permissions
|
|
585
|
+
let updated
|
|
586
|
+
try {
|
|
587
|
+
updated = await store.setPermissions(target, body)
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
590
|
+
throw error
|
|
591
|
+
}
|
|
592
|
+
await store.appendActivity({
|
|
593
|
+
type: 'permission_change',
|
|
594
|
+
username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req),
|
|
595
|
+
detail: `target=${target.username} ${describePermissionDelta(before, updated.permissions)}`,
|
|
596
|
+
})
|
|
597
|
+
// Banning is a policy action: it takes effect on live sessions at once.
|
|
598
|
+
if (updated.permissions.banned && !before.banned) await store.dropUserSessions(target.id)
|
|
599
|
+
return sendJson(res, 200, { user: updated })
|
|
600
|
+
}
|
|
601
|
+
return sendJson(res, 405, { error: 'method not allowed' })
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const adminMatch = /^\/users\/([A-Za-z0-9_-]+)(?:\/(reset-password|reset-totp|role))?$/.exec(apiPath)
|
|
605
|
+
|
|
606
|
+
if (adminMatch && method === 'DELETE' && !adminMatch[2]) {
|
|
607
|
+
const admin = await requireAdmin()
|
|
608
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
609
|
+
const target = store.findUser(adminMatch[1])
|
|
610
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
611
|
+
if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能删除自己的账号' })
|
|
612
|
+
try {
|
|
613
|
+
await store.removeUser(target)
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
616
|
+
throw error
|
|
617
|
+
}
|
|
618
|
+
await store.appendActivity({ type: 'delete_user', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `deleted=${target.username}` })
|
|
619
|
+
return sendJson(res, 200, { ok: true })
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (adminMatch && adminMatch[2] === 'reset-password' && method === 'POST') {
|
|
623
|
+
const admin = await requireAdmin()
|
|
624
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
625
|
+
const target = store.findUser(adminMatch[1])
|
|
626
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
627
|
+
const generated = tempPassword()
|
|
628
|
+
await store.setPassword(target, generated)
|
|
629
|
+
await store.dropUserSessions(target.id)
|
|
630
|
+
await store.appendActivity({ type: 'reset_password', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username}` })
|
|
631
|
+
return sendJson(res, 200, { tempPassword: generated })
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Admin recovery path for a lost authenticator device: strip the target's
|
|
635
|
+
// TOTP so they can sign in with the password alone (and re-enroll). Does
|
|
636
|
+
// not touch their live sessions — this weakens FUTURE logins only, and the
|
|
637
|
+
// action itself lands in the activity ledger.
|
|
638
|
+
if (adminMatch && adminMatch[2] === 'reset-totp' && method === 'POST') {
|
|
639
|
+
const admin = await requireAdmin()
|
|
640
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
641
|
+
const target = store.findUser(adminMatch[1])
|
|
642
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
643
|
+
try {
|
|
644
|
+
await store.disableTotp(target)
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if (error instanceof StoreError) return sendJson(res, 409, { error: error.message })
|
|
647
|
+
throw error
|
|
648
|
+
}
|
|
649
|
+
await store.appendActivity({ type: 'totp_reset', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username}` })
|
|
650
|
+
return sendJson(res, 200, { user: store.publicUser(target) })
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (adminMatch && adminMatch[2] === 'role' && method === 'POST') {
|
|
654
|
+
const admin = await requireAdmin()
|
|
655
|
+
if (!admin.ok) return sendJson(res, admin.status, { error: admin.message })
|
|
656
|
+
const target = store.findUser(adminMatch[1])
|
|
657
|
+
if (!target) return sendJson(res, 404, { error: '用户不存在' })
|
|
658
|
+
if (target.id === admin.session.user.id) return sendJson(res, 403, { error: '不能修改自己的角色' })
|
|
659
|
+
const body = await readJsonBody(req)
|
|
660
|
+
try {
|
|
661
|
+
await store.setRole(target, body.role)
|
|
662
|
+
} catch (error) {
|
|
663
|
+
if (error instanceof StoreError) return sendJson(res, statusForStoreError(error), { error: error.message })
|
|
664
|
+
throw error
|
|
665
|
+
}
|
|
666
|
+
await store.dropUserSessions(target.id)
|
|
667
|
+
await store.appendActivity({ type: 'role_change', username: admin.session.user.username, userId: admin.session.user.id, ip: deps.clientIp(req), detail: `target=${target.username} role=${target.role}` })
|
|
668
|
+
return sendJson(res, 200, { user: store.publicUser(target) })
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return sendJson(res, 404, { error: 'not found' })
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** How often an account's activity counter is allowed to be written. */
|
|
675
|
+
const USAGE_TOUCH_INTERVAL_MS = 60_000
|
|
676
|
+
|
|
677
|
+
/** Static assets never count as activity (a page load is not "using" the agent). */
|
|
678
|
+
const STATIC_PATH_RE = /\.(?:js|mjs|css|map|woff2?|ttf|otf|png|jpe?g|gif|svg|ico|webp|avif|mp4|webmanifest|txt)$/i
|
|
679
|
+
|
|
680
|
+
/** In-memory per-account throttle for the usage counter (not persisted). */
|
|
681
|
+
const lastUsageTouch = new Map()
|
|
682
|
+
|
|
683
|
+
/** Session events the signing gateway may push into the login ledger. */
|
|
684
|
+
const INTERNAL_SESSION_TYPES = new Set(['login', 'login_failed', 'logout'])
|
|
685
|
+
|
|
686
|
+
// ── schemastery config (the `user-management:` settings namespace) ──────────
|
|
687
|
+
const Config = z.object({
|
|
688
|
+
enabled: z.boolean().default(true),
|
|
689
|
+
// Registration approval switch: false (default) puts every self-registered
|
|
690
|
+
// account (first admin excepted) into the disabled state until an admin
|
|
691
|
+
// enables it from the users table; true activates registrations immediately.
|
|
692
|
+
autoActivate: z.boolean().default(false),
|
|
693
|
+
listenHost: z.string().default('0.0.0.0'),
|
|
694
|
+
port: z.natural().min(1).max(65535).default(19843),
|
|
695
|
+
sites: z
|
|
696
|
+
.array(
|
|
697
|
+
z.object({
|
|
698
|
+
hosts: z.array(z.string()).default([]),
|
|
699
|
+
cert: z.string().default(''),
|
|
700
|
+
key: z.string().default(''),
|
|
701
|
+
}),
|
|
702
|
+
)
|
|
703
|
+
.default([]), // empty = auto-enumerate all local IPs (see allLocalIPs)
|
|
704
|
+
// ── merged deployment (dsh-passwords in front) ──────────────────────────
|
|
705
|
+
// Shared HMAC key (dsh-passwords MCP_INTERNAL_SECRET). When set, a request
|
|
706
|
+
// arriving from a loopback peer carrying a valid signature is trusted as an
|
|
707
|
+
// authenticated user: this plugin stops being a login door and becomes the
|
|
708
|
+
// ledger observer behind the single door. Empty (default) = inert, i.e. the
|
|
709
|
+
// standalone deployment where this plugin owns the login. See
|
|
710
|
+
// src/trusted-identity.js for the exact trust conditions.
|
|
711
|
+
trustedSecret: z.string().default(''),
|
|
712
|
+
// Accepted signing peers; empty = loopback only (127.0.0.1 / ::1).
|
|
713
|
+
trustedPeers: z.array(z.string()).default([]),
|
|
714
|
+
// Serve the gateway listener over plain HTTP. ONLY legal together with a
|
|
715
|
+
// loopback listenHost: the listener is then reachable by the co-located
|
|
716
|
+
// dsh-passwords gateway alone, which tunnels WebSocket upgrades as raw TCP
|
|
717
|
+
// and therefore cannot speak TLS to this hop. Never enable with 0.0.0.0.
|
|
718
|
+
plaintext: z.boolean().default(false),
|
|
719
|
+
title: z.string().default('DSH 控制台'),
|
|
720
|
+
// Reserved (accepted, not wired): the store owns session lifetime, login
|
|
721
|
+
// failure is not auto-locked (admins set IP bans instead), and handleApi
|
|
722
|
+
// owns its own body-size cap. Kept for the settings card + forward use.
|
|
723
|
+
sessionDays: z.natural().min(1).default(7),
|
|
724
|
+
loginFailLimit: z.natural().min(1).default(5),
|
|
725
|
+
lockoutSeconds: z.natural().min(1).default(60),
|
|
726
|
+
maxBodyBytes: z.natural().min(1024).default(16384),
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Enumerate every non-loopback local IP (IPv4 + IPv6, including Tailscale):
|
|
731
|
+
* skips lo/Loopback + vEthernet/virtual/hyper-v interface names, internal
|
|
732
|
+
* addresses, IPv4 APIPA (169.254.), IPv6 loopback (::1) and link-local
|
|
733
|
+
* (fe80::). Accepts an optional `interfaces` arg (defaults to
|
|
734
|
+
* node:os.networkInterfaces()) so the filter is unit-testable with a stub.
|
|
735
|
+
*/
|
|
736
|
+
/** Loopback listener hosts — the only ones a plaintext gateway may bind. */
|
|
737
|
+
function isLoopbackHost(host) {
|
|
738
|
+
const value = String(host || '').trim().toLowerCase()
|
|
739
|
+
return value === '127.0.0.1' || value === 'localhost' || value === '::1'
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function allLocalIPs(interfaces) {
|
|
743
|
+
const ifaces = interfaces || (() => { try { return networkInterfaces() } catch { return {} } })()
|
|
744
|
+
const out = []
|
|
745
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
746
|
+
if (/^(lo|Loopback)/i.test(name)) continue
|
|
747
|
+
if (/vEthernet|virtual|hyper-v/i.test(name)) continue
|
|
748
|
+
for (const a of addrs || []) {
|
|
749
|
+
if (a.internal) continue
|
|
750
|
+
const addr = a.address || ''
|
|
751
|
+
if (!addr) continue
|
|
752
|
+
if (a.family === 'IPv4') {
|
|
753
|
+
if (addr.startsWith('169.254.')) continue
|
|
754
|
+
out.push(addr)
|
|
755
|
+
} else if (a.family === 'IPv6') {
|
|
756
|
+
if (addr === '::1' || addr.startsWith('fe80:')) continue
|
|
757
|
+
out.push(addr)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return out
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Auto-site host list: every local IP plus its sslip.io / nip.io wildcard-DNS
|
|
766
|
+
* aliases. Purely additive SAN coverage — trusting the self-signed cert once
|
|
767
|
+
* then makes IP, pretty-name and wildcard-DNS access all warning-free. (It
|
|
768
|
+
* does NOT remove the untrusted-cert warning by itself; for public IPs a real
|
|
769
|
+
* Let's Encrypt cert via these names is the zero-warning path, and on
|
|
770
|
+
* Tailscale `tailscale cert` + ts.net names remain the better choice.)
|
|
771
|
+
*/
|
|
772
|
+
function autoSiteHosts(ips) {
|
|
773
|
+
const list = (ips || []).map(String)
|
|
774
|
+
return ['localhost', ...list, ...list.flatMap((ip) => [`${ip}.sslip.io`, `${ip}.nip.io`])]
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve the effective site list from config + auto-enumerated local IPs.
|
|
779
|
+
*
|
|
780
|
+
* MERGE semantics (v0.5.4): the auto-enumerated host list — localhost, every
|
|
781
|
+
* non-loopback local IP and each IP's sslip.io / nip.io wildcard-DNS alias —
|
|
782
|
+
* ALWAYS forms the self-signed certificate site, so the gateway stays
|
|
783
|
+
* reachable on the LAN / Tailscale / loopback out of the box. Configured
|
|
784
|
+
* sites WITHOUT cert/key are folded into that self-signed site: their hosts
|
|
785
|
+
* are merged in (de-duplicated), covering names the local NICs can't see —
|
|
786
|
+
* e.g. a NAT'd public IP routed to this host that no local interface owns.
|
|
787
|
+
* Configured sites WITH cert/key are kept as independent SNI sites (real
|
|
788
|
+
* domain certs). Empty cfg.sites is the zero-config default (pure auto).
|
|
789
|
+
*
|
|
790
|
+
* Before v0.5.4 a configured sites array REPLACED the auto list entirely,
|
|
791
|
+
* dropping every local IP + alias and breaking local / LAN / Tailscale
|
|
792
|
+
* access with 421 (unknown host) the moment a user added any site.
|
|
793
|
+
*/
|
|
794
|
+
function resolveSites(cfg, autoIps) {
|
|
795
|
+
const configured = (cfg && cfg.sites && cfg.sites.length) ? cfg.sites : []
|
|
796
|
+
const extraHosts = configured
|
|
797
|
+
.filter((s) => !s.cert && !s.key)
|
|
798
|
+
.flatMap((s) => s.hosts || [])
|
|
799
|
+
const mergedHosts = [...new Set([...autoSiteHosts(autoIps), ...extraHosts])]
|
|
800
|
+
const domainSites = configured.filter((s) => s.cert || s.key)
|
|
801
|
+
return [{ hosts: mergedHosts, cert: '', key: '' }, ...domainSites]
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* The empty-user-store guard. Previously REFUSED to start a non-loopback
|
|
806
|
+
* listener with no registered users (forcing the first admin registration
|
|
807
|
+
* onto loopback). Relaxed to a no-op under the zero-config default (Option B):
|
|
808
|
+
* a non-loopback listener with no users STARTS and the first visitor
|
|
809
|
+
* registers as admin (the user-management model). The safety message moved
|
|
810
|
+
* to bootWarnings, which warns (does not refuse) in that case. Always returns
|
|
811
|
+
* null (safe to apply); kept + exported for tests + forward use.
|
|
812
|
+
*/
|
|
813
|
+
function emptyUsersGuard() {
|
|
814
|
+
return null
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Cordis service `user-management` — lets sibling host-plane plugins resolve
|
|
819
|
+
* who is behind an incoming request. Extracted as a factory so tests can
|
|
820
|
+
* exercise it without running the full apply() (which owns the gateway
|
|
821
|
+
* lifecycle).
|
|
822
|
+
*
|
|
823
|
+
* @typedef {Object} UmUser
|
|
824
|
+
* @property {string} id stable user id (e.g. `u_3_a1b2c3d4`)
|
|
825
|
+
* @property {string} username
|
|
826
|
+
* @property {'admin'|'user'} role
|
|
827
|
+
* @property {boolean} disabled always false — disabled users never resolve
|
|
828
|
+
* @property {number} createdAt epoch ms
|
|
829
|
+
* @property {number|null} lastLoginAt
|
|
830
|
+
*
|
|
831
|
+
* @example // consumer (any host-plane plugin):
|
|
832
|
+
* module.exports = {
|
|
833
|
+
* name: 'my-plugin',
|
|
834
|
+
* inject: ['webServer'], // ← do NOT list 'user-management' here;
|
|
835
|
+
* // statically injecting an optional
|
|
836
|
+
* // service hangs your activation
|
|
837
|
+
* apply(ctx) {
|
|
838
|
+
* ctx.inject(['user-management'], (scope) => {
|
|
839
|
+
* const um = scope['user-management']
|
|
840
|
+
* ctx.effect(() => ctx.webServer.register({
|
|
841
|
+
* kind: 'prefix', path: '/my-plugin/api',
|
|
842
|
+
* handler: async (req, res) => {
|
|
843
|
+
* const user = await um.resolveRequest(req)
|
|
844
|
+
* if (!user) return sendJson(res, 401, { error: '未登录' })
|
|
845
|
+
* if (user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员' })
|
|
846
|
+
* // user.username / user.id attribute the action
|
|
847
|
+
* },
|
|
848
|
+
* }), 'my-plugin: api')
|
|
849
|
+
* })
|
|
850
|
+
* },
|
|
851
|
+
* }
|
|
852
|
+
*
|
|
853
|
+
* Both resolvers await the store's async load internally, so the service is
|
|
854
|
+
* safe to call as soon as it is provided. Unauthenticated / expired sessions
|
|
855
|
+
* resolve to null; disabled users never resolve (the store drops their
|
|
856
|
+
* sessions on disable). Browser halves of sibling plugins don't need this
|
|
857
|
+
* service — they can simply `fetch('/user-management/api/session')`, which
|
|
858
|
+
* the gateway answers locally with `{ user: {...} | null }`.
|
|
859
|
+
*
|
|
860
|
+
* @param {{ store: object, ready: Promise<object> }} opts
|
|
861
|
+
* @returns {{ resolveRequest(req: object): Promise<UmUser|null>, resolveToken(token: string): Promise<UmUser|null> }}
|
|
862
|
+
*/
|
|
863
|
+
function createIdentityService({ store, ready }) {
|
|
864
|
+
const withSession = async (token) => {
|
|
865
|
+
try { await ready } catch { return null } // broken store → nobody resolves
|
|
866
|
+
const session = await store.resolveSession(token)
|
|
867
|
+
return session ? store.publicUser(session.user) : null
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
/** Resolve the browser request's `um_session` cookie to its user. */
|
|
871
|
+
async resolveRequest(req) {
|
|
872
|
+
const cookies = parseCookies(req.headers && req.headers.cookie)
|
|
873
|
+
return withSession(cookies[SESSION_COOKIE])
|
|
874
|
+
},
|
|
875
|
+
/** Lower-level variant for callers that already extracted the token. */
|
|
876
|
+
async resolveToken(token) {
|
|
877
|
+
return withSession(token)
|
|
878
|
+
},
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const plugin = {
|
|
883
|
+
name: 'user-management',
|
|
884
|
+
inject: ['webServer'],
|
|
885
|
+
__internals: {
|
|
886
|
+
handleApi,
|
|
887
|
+
sendJson,
|
|
888
|
+
readJsonBody,
|
|
889
|
+
normalizeApiPath,
|
|
890
|
+
statusForStoreError,
|
|
891
|
+
sessionCookie,
|
|
892
|
+
clearedCookie,
|
|
893
|
+
SELF_ACTIVITY_TYPES,
|
|
894
|
+
ADMIN_ACTIVITY_TYPES,
|
|
895
|
+
AUDIT_LIMIT_DEFAULT,
|
|
896
|
+
Config,
|
|
897
|
+
emptyUsersGuard,
|
|
898
|
+
allLocalIPs,
|
|
899
|
+
autoSiteHosts,
|
|
900
|
+
createIdentityService,
|
|
901
|
+
resolveSites,
|
|
902
|
+
createOtpGuard,
|
|
903
|
+
totpQrSvg,
|
|
904
|
+
},
|
|
905
|
+
apply(ctx, config = {}) {
|
|
906
|
+
const pluginName = 'user-management'
|
|
907
|
+
const store = createStore({ home: dshHome() })
|
|
908
|
+
const ready = store.load().catch((error) => {
|
|
909
|
+
console.error(`[${pluginName}] store load failed:`, error && error.message)
|
|
910
|
+
throw error
|
|
911
|
+
})
|
|
912
|
+
const socketIp = (req) => normalizeIp(req.socket && req.socket.remoteAddress)
|
|
913
|
+
// Merged deployment: the co-located dsh-passwords gateway signs the user —
|
|
914
|
+
// and the real client IP it saw — onto every request it proxies here.
|
|
915
|
+
// Memoized per request: the decider, both ledger hooks and the API all ask,
|
|
916
|
+
// and each ask would otherwise re-run the HMAC.
|
|
917
|
+
// Created inert and (re)built inside every rebuild: `config` here is only
|
|
918
|
+
// the composition base — the real values live in the settings namespace,
|
|
919
|
+
// which registers *after* apply() (see refreshTrustedIdentity below). The
|
|
920
|
+
// closure stays stable so deps/decider can hold it while the secret moves.
|
|
921
|
+
let trustedBase = createTrustedIdentity({ secret: '' })
|
|
922
|
+
let trustedCache = new WeakMap()
|
|
923
|
+
const resolveTrusted = (req) => {
|
|
924
|
+
if (!req || typeof req !== 'object') return null
|
|
925
|
+
if (trustedCache.has(req)) return trustedCache.get(req)
|
|
926
|
+
const session = trustedBase(req)
|
|
927
|
+
trustedCache.set(req, session)
|
|
928
|
+
return session
|
|
929
|
+
}
|
|
930
|
+
/** Rebuild the verifier from the live settings (secret/peers are hot). */
|
|
931
|
+
const refreshTrustedIdentity = (cfg) => {
|
|
932
|
+
trustedBase = createTrustedIdentity({ secret: cfg.trustedSecret, peers: cfg.trustedPeers })
|
|
933
|
+
trustedCache = new WeakMap()
|
|
934
|
+
}
|
|
935
|
+
/** True client IP: the signed upstream value when present, else the socket
|
|
936
|
+
* peer. Behind the single door every socket peer is 127.0.0.1, so without
|
|
937
|
+
* this the ledgers' IP column — and IP banning — would be meaningless. */
|
|
938
|
+
const clientIp = (req) => {
|
|
939
|
+
const trusted = resolveTrusted(req)
|
|
940
|
+
return (trusted && trusted.clientIp) || socketIp(req)
|
|
941
|
+
}
|
|
942
|
+
const deps = { store, clientIp, resolveTrusted }
|
|
943
|
+
|
|
944
|
+
// Identity service for sibling plugins (see createIdentityService).
|
|
945
|
+
ctx.provide('user-management', createIdentityService({ store, ready }))
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
const dataDir = join(dshHome(), 'user-management')
|
|
949
|
+
const certsDir = join(dataDir, 'certs')
|
|
950
|
+
|
|
951
|
+
const log = (msg) => console.log(`[${pluginName}] ${msg}`)
|
|
952
|
+
const warn = (msg) => console.warn(`[${pluginName}] ${msg}`)
|
|
953
|
+
log(`gateway plugin v${pkg.version} starting (pid ${process.pid})`)
|
|
954
|
+
|
|
955
|
+
/** The injected webServer service carries the real bound dsh port. */
|
|
956
|
+
const resolveUpstream = (cfg) => {
|
|
957
|
+
try {
|
|
958
|
+
const ws = ctx.webServer
|
|
959
|
+
if (ws && typeof ws.port === 'number') return `http://127.0.0.1:${ws.port}`
|
|
960
|
+
} catch { /* fall through */ }
|
|
961
|
+
warn('user-management: webServer service unavailable — assuming upstream http://127.0.0.1:3080')
|
|
962
|
+
return 'http://127.0.0.1:3080'
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// dsh 0.1.2-rc.1+ gates `/` index.html behind a launchToken + signed
|
|
966
|
+
// browser cookie (dsh-client-connection BrowserAuth). The injected
|
|
967
|
+
// `connection` service exposes the PUBLIC authenticatedUrl(baseUrl)
|
|
968
|
+
// builder — the sanctioned way to obtain a token-carrying URL (reading
|
|
969
|
+
// BrowserAuth.launchToken directly would couple us to a TS-private
|
|
970
|
+
// field). We hand the gateway a builder that binds the token URL to the
|
|
971
|
+
// upstream origin at call time, so hot-reloaded upstreams stay in sync.
|
|
972
|
+
// On 0.1.1-rc.2 (no BrowserAuth) the builder stays null and the proxy
|
|
973
|
+
// falls through to a plain passthrough — backward compatible.
|
|
974
|
+
let authenticatedUrlBuilder = null
|
|
975
|
+
if (typeof ctx.inject === 'function') {
|
|
976
|
+
ctx.inject(['connection'], (fiber) => {
|
|
977
|
+
try {
|
|
978
|
+
// ctx.inject delivers a fiber whose `.connection` is the registered
|
|
979
|
+
// HostConnectionService (cf. the existing settings inject: scope.settings).
|
|
980
|
+
const conn = (fiber && fiber.connection) || fiber
|
|
981
|
+
authenticatedUrlBuilder = conn && typeof conn.authenticatedUrl === 'function'
|
|
982
|
+
? (origin) => {
|
|
983
|
+
try { return conn.authenticatedUrl(origin) } catch { return null }
|
|
984
|
+
}
|
|
985
|
+
: null
|
|
986
|
+
} catch { authenticatedUrlBuilder = null }
|
|
987
|
+
})
|
|
988
|
+
}
|
|
989
|
+
// Binds at call time: proxy.js invokes getAuthenticatedUrl(origin) and
|
|
990
|
+
// needs the token URL string, not the builder function itself. Returning
|
|
991
|
+
// the bare builder (v0.8.0) made new URL(fn) throw inside the mint and
|
|
992
|
+
// silently skip cookie minting — masked on 0.1.1-rc.2 where the builder
|
|
993
|
+
// is null and the plain-proxy fallback was used regardless.
|
|
994
|
+
const getAuthenticatedUrl = (origin) => (authenticatedUrlBuilder ? authenticatedUrlBuilder(origin) : null)
|
|
995
|
+
|
|
996
|
+
// The decider runs on every gateway request: IP-ban check (403) → session
|
|
997
|
+
// resolve → gate decision (allow/redirect/401). onAccess feeds the activity
|
|
998
|
+
// ledger; the gateway wires onApiRequest/onWsOpen to the audit ledger.
|
|
999
|
+
const decider = createDecider({
|
|
1000
|
+
resolveSession: async (token) => { await ready; return store.resolveSession(token) },
|
|
1001
|
+
resolveTrusted,
|
|
1002
|
+
getClientIp: clientIp,
|
|
1003
|
+
isBanned: (ip) => store.isBanned(ip),
|
|
1004
|
+
onAccess: (req, resolved, path) => {
|
|
1005
|
+
store.appendActivity({
|
|
1006
|
+
type: 'access',
|
|
1007
|
+
username: resolved ? resolved.user.username : null,
|
|
1008
|
+
userId: resolved ? resolved.user.id : null,
|
|
1009
|
+
ip: clientIp(req),
|
|
1010
|
+
detail: path,
|
|
1011
|
+
}).catch(() => {})
|
|
1012
|
+
},
|
|
1013
|
+
})
|
|
1014
|
+
const auditEntry = (session, method, path, ip, status, type) => ({
|
|
1015
|
+
type,
|
|
1016
|
+
username: session ? session.user.username : null,
|
|
1017
|
+
userId: session ? session.user.id : null,
|
|
1018
|
+
ip,
|
|
1019
|
+
method,
|
|
1020
|
+
path,
|
|
1021
|
+
status: status === undefined ? null : status,
|
|
1022
|
+
})
|
|
1023
|
+
const auditHooks = {
|
|
1024
|
+
onApiRequest: (req, session, path, status) => {
|
|
1025
|
+
store.appendAudit(auditEntry(session, (req.method || 'GET').toUpperCase(), path, clientIp(req), status, 'api')).catch(() => {})
|
|
1026
|
+
},
|
|
1027
|
+
onWsOpen: (req, session, path) => {
|
|
1028
|
+
store.appendAudit(auditEntry(session, 'WS', path, clientIp(req), null, 'ws')).catch(() => {})
|
|
1029
|
+
},
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── gateway lifecycle: hot-reload (bind-then-swap) + self-heal ────────
|
|
1033
|
+
let settingsScope = null
|
|
1034
|
+
let current = null
|
|
1035
|
+
let currentOptions = null
|
|
1036
|
+
let startedAt = null
|
|
1037
|
+
let lastError = ''
|
|
1038
|
+
let lastOnErrorAt = 0
|
|
1039
|
+
let restarting = false
|
|
1040
|
+
let rebuildChain = Promise.resolve()
|
|
1041
|
+
const resolvedConfig = () => (settingsScope ? settingsScope.get() : config)
|
|
1042
|
+
// Registration approval switch, read per request so settings hot-reload applies.
|
|
1043
|
+
deps.autoActivate = () => resolvedConfig().autoActivate === true
|
|
1044
|
+
// Observer mode = a signing secret is configured, i.e. dsh-passwords owns
|
|
1045
|
+
// the login. Read per request so a settings change flips the UI live.
|
|
1046
|
+
deps.observerMode = () => String(resolvedConfig().trustedSecret || '') !== ''
|
|
1047
|
+
/**
|
|
1048
|
+
* Permissions for a session — the enforcement hooks' one question for the
|
|
1049
|
+
* host half.
|
|
1050
|
+
*
|
|
1051
|
+
* A trusted (signed) session carries no permissions of its own, so the
|
|
1052
|
+
* account record here is the source of truth, matched by username. An
|
|
1053
|
+
* unknown username yields null, which the gateway reads as "unrestricted":
|
|
1054
|
+
* enforcement is a property of the accounts this plugin owns, and inventing
|
|
1055
|
+
* restrictions for a name it has never seen would lock people out mid-migration.
|
|
1056
|
+
*/
|
|
1057
|
+
/**
|
|
1058
|
+
* Quota gate for the request path: accrue activity, then refuse once a
|
|
1059
|
+
* daily-minute or hourly-token allowance is spent.
|
|
1060
|
+
*
|
|
1061
|
+
* Only accounts that carry a limit are tracked at all — an account with no
|
|
1062
|
+
* limits must not pay a usage write for every request it makes. Accrual is
|
|
1063
|
+
* throttled by wall clock, and the elapsed time since the previous touch is
|
|
1064
|
+
* what gets added, so the counter tracks real activity rather than request
|
|
1065
|
+
* count.
|
|
1066
|
+
*
|
|
1067
|
+
* @returns {string|null} a refusal reason, or null to proceed.
|
|
1068
|
+
*/
|
|
1069
|
+
deps.quotaRefusal = (session, path) => {
|
|
1070
|
+
const username = session && session.user && session.user.username
|
|
1071
|
+
if (typeof username !== 'string' || username === '') return null
|
|
1072
|
+
const user = store.findUserByUsername(username)
|
|
1073
|
+
if (!user) return null
|
|
1074
|
+
const perms = normalizePermissions(user.permissions)
|
|
1075
|
+
const daily = perms.dailyMinuteLimit
|
|
1076
|
+
const hourly = perms.hourlyTokenLimit
|
|
1077
|
+
if (daily === null && hourly === null) return null
|
|
1078
|
+
if (typeof path === 'string' && STATIC_PATH_RE.test(path)) return null
|
|
1079
|
+
|
|
1080
|
+
const ts = Date.now()
|
|
1081
|
+
const previous = lastUsageTouch.get(username) || 0
|
|
1082
|
+
if (ts - previous >= USAGE_TOUCH_INTERVAL_MS) {
|
|
1083
|
+
lastUsageTouch.set(username, ts)
|
|
1084
|
+
// Fire and forget: a quota counter must never add latency to a request.
|
|
1085
|
+
void store.touchUsage(username, previous === 0 ? 0 : (ts - previous) / 1000).catch(() => {})
|
|
1086
|
+
}
|
|
1087
|
+
const usage = store.getUsage(username)
|
|
1088
|
+
if (!usage) return null
|
|
1089
|
+
if (daily !== null && usage.activeSeconds >= daily * 60) return '每日时长配额已用完'
|
|
1090
|
+
if (hourly !== null && usage.hourlyTokens >= hourly) return '每小时 token 配额已用完'
|
|
1091
|
+
return null
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Workspace ownership, exposed to the gateway's enforcement hooks.
|
|
1096
|
+
*
|
|
1097
|
+
* The gateway owns no storage: it asks these four questions and applies the
|
|
1098
|
+
* answers, which keeps the ownership policy testable at the store level and
|
|
1099
|
+
* the HTTP mechanics testable at the proxy level.
|
|
1100
|
+
*/
|
|
1101
|
+
// Error → status mapping lives with the store's own error types.
|
|
1102
|
+
deps.statusForError = (error) => statusForStoreError(error)
|
|
1103
|
+
deps.workspaceOwners = () => store.workspaceOwners()
|
|
1104
|
+
deps.claimWorkspace = (path, username) => store.claimWorkspace(path, username)
|
|
1105
|
+
deps.releaseWorkspace = (path) => store.releaseWorkspace(path)
|
|
1106
|
+
deps.renameWorkspace = (oldPath, newPath) => store.renameWorkspace(oldPath, newPath)
|
|
1107
|
+
|
|
1108
|
+
deps.permissionsFor = (session) => {
|
|
1109
|
+
const username = session && session.user && session.user.username
|
|
1110
|
+
if (typeof username !== 'string' || username === '') return null
|
|
1111
|
+
const user = store.findUserByUsername(username)
|
|
1112
|
+
if (!user) return null
|
|
1113
|
+
return normalizePermissions(user.permissions)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const queueRebuild = (force = false) => {
|
|
1117
|
+
rebuildChain = rebuildChain
|
|
1118
|
+
.then(async () => {
|
|
1119
|
+
await ready
|
|
1120
|
+
const cfg = resolvedConfig()
|
|
1121
|
+
refreshTrustedIdentity(cfg)
|
|
1122
|
+
if (cfg.enabled === false) {
|
|
1123
|
+
if (current) log('user-management: disabled — listener stopped')
|
|
1124
|
+
restarting = false
|
|
1125
|
+
stopHealthCheck()
|
|
1126
|
+
try { current && current.stop() } catch (e) { warn(`user-management: error stopping listener — ${e.message || e}`) }
|
|
1127
|
+
current = null
|
|
1128
|
+
currentOptions = null
|
|
1129
|
+
startedAt = null
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
// Site resolution (MERGE, v0.5.4): auto-enumerated hosts (localhost
|
|
1133
|
+
// + every non-loopback local IP + their sslip.io / nip.io aliases)
|
|
1134
|
+
// always form the self-signed cert site, so the gateway stays
|
|
1135
|
+
// reachable on the LAN/Tailscale/loopback. Configured sites WITHOUT
|
|
1136
|
+
// cert/key fold their hosts into that self-signed site (covers names
|
|
1137
|
+
// the local NICs can't see, e.g. a NAT'd public IP); configured
|
|
1138
|
+
// sites WITH cert/key stay independent SNI sites (real domain
|
|
1139
|
+
// certs). Empty cfg.sites = pure auto (zero-config default).
|
|
1140
|
+
const sites = resolveSites(cfg, allLocalIPs())
|
|
1141
|
+
// Plaintext is legal on loopback only: it exists so the co-located
|
|
1142
|
+
// dsh-passwords gateway can reach this observer through the raw TCP
|
|
1143
|
+
// tunnel it uses for WebSocket upgrades. Any other bind would expose
|
|
1144
|
+
// the observer unencrypted, so it is refused rather than honoured.
|
|
1145
|
+
if (cfg.plaintext && !isLoopbackHost(cfg.listenHost)) {
|
|
1146
|
+
warn(`user-management: plaintext listener pinned to 127.0.0.1 (configured listenHost "${cfg.listenHost}" would serve it unencrypted)`)
|
|
1147
|
+
}
|
|
1148
|
+
const options = {
|
|
1149
|
+
listenHost: cfg.plaintext ? '127.0.0.1' : cfg.listenHost,
|
|
1150
|
+
plaintext: cfg.plaintext === true,
|
|
1151
|
+
port: cfg.port,
|
|
1152
|
+
upstream: resolveUpstream(cfg),
|
|
1153
|
+
getAuthenticatedUrl,
|
|
1154
|
+
sites,
|
|
1155
|
+
certsDir,
|
|
1156
|
+
title: cfg.title,
|
|
1157
|
+
decider,
|
|
1158
|
+
handleApi,
|
|
1159
|
+
renderLoginPage,
|
|
1160
|
+
deps,
|
|
1161
|
+
clearedCookie,
|
|
1162
|
+
auditHooks,
|
|
1163
|
+
log,
|
|
1164
|
+
warn,
|
|
1165
|
+
onError: (error) => {
|
|
1166
|
+
const now = Date.now()
|
|
1167
|
+
if (now - lastOnErrorAt < 30000) {
|
|
1168
|
+
warn('user-management: listener error recurring — suppressing auto-restart')
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
lastOnErrorAt = now
|
|
1172
|
+
void queueRebuild(true)
|
|
1173
|
+
},
|
|
1174
|
+
}
|
|
1175
|
+
if (current && !force) {
|
|
1176
|
+
// Two-tier hot reload: request-time fields mutate in place (no gap);
|
|
1177
|
+
// listener-affecting fields restart the server.
|
|
1178
|
+
const restartNeeded =
|
|
1179
|
+
currentOptions.listenHost !== options.listenHost ||
|
|
1180
|
+
currentOptions.plaintext !== options.plaintext ||
|
|
1181
|
+
currentOptions.port !== options.port ||
|
|
1182
|
+
currentOptions.upstream !== options.upstream ||
|
|
1183
|
+
JSON.stringify(currentOptions.sites) !== JSON.stringify(options.sites)
|
|
1184
|
+
if (!restartNeeded) {
|
|
1185
|
+
restarting = false
|
|
1186
|
+
Object.assign(currentOptions, options)
|
|
1187
|
+
currentOptions.sites = options.sites
|
|
1188
|
+
return
|
|
1189
|
+
}
|
|
1190
|
+
log('user-management: listener settings changed — restarting')
|
|
1191
|
+
}
|
|
1192
|
+
// A listener swap tears down the very connection that requested it.
|
|
1193
|
+
// Give the in-flight response a beat to flush before closing the old server.
|
|
1194
|
+
if (current) await new Promise((resolve) => setTimeout(resolve, 120))
|
|
1195
|
+
restarting = true
|
|
1196
|
+
stopHealthCheck()
|
|
1197
|
+
try { current && current.stop() } catch (e) { warn(`user-management: error stopping previous listener — ${e.message || e}`) }
|
|
1198
|
+
current = null
|
|
1199
|
+
currentOptions = null
|
|
1200
|
+
startedAt = null
|
|
1201
|
+
lastError = ''
|
|
1202
|
+
try {
|
|
1203
|
+
const next = createGateway(options)
|
|
1204
|
+
const port = await next.start() // bind-then-swap: only publish `current` after a successful bind
|
|
1205
|
+
current = next
|
|
1206
|
+
currentOptions = options
|
|
1207
|
+
startedAt = new Date().toISOString()
|
|
1208
|
+
restarting = false
|
|
1209
|
+
bootWarnings(cfg, sites, port)
|
|
1210
|
+
startHealthCheck()
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
restarting = false
|
|
1213
|
+
stopHealthCheck()
|
|
1214
|
+
lastError = error.message || String(error)
|
|
1215
|
+
warn(`user-management: failed to apply configuration, gateway is down — ${lastError}`)
|
|
1216
|
+
}
|
|
1217
|
+
})
|
|
1218
|
+
.catch(() => {}) // a contained rebuild never poisons the chain
|
|
1219
|
+
return rebuildChain
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function bootWarnings(cfg, sites, port) {
|
|
1223
|
+
const listenHost = String((cfg && cfg.listenHost) || '')
|
|
1224
|
+
const loopbackOnly = /^(127\.0\.0\.1|::1|localhost)$/i.test(listenHost)
|
|
1225
|
+
if (!loopbackOnly && store.listUsers().length === 0) {
|
|
1226
|
+
warn(`user-management: non-loopback listener with no registered users — the first visitor will register as admin; ensure the network is trusted (or register the first admin on loopback 127.0.0.1:${port} first)`)
|
|
1227
|
+
}
|
|
1228
|
+
const hosts = (sites || []).flatMap((s) => s.hosts || [])
|
|
1229
|
+
// After MERGE the self-signed site always carries localhost + every
|
|
1230
|
+
// local IP, so this warning only fires when there genuinely is no
|
|
1231
|
+
// non-loopback host anywhere (no local NICs + nothing configured) —
|
|
1232
|
+
// i.e. the gateway is about to be exposed with no reachable name.
|
|
1233
|
+
const hasNonLoopbackHost = hosts.some((h) => h && h !== 'localhost' && !/^(127\.|::1$)/.test(h))
|
|
1234
|
+
if (!hasNonLoopbackHost) {
|
|
1235
|
+
warn(`user-management: listening on port ${port} but no public hostname is configured — add sites[].hosts before exposing it`)
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ── self-heal: 60s HTTPS probe, rebuild only after 3 consecutive failures ─
|
|
1240
|
+
let healthTimer = null
|
|
1241
|
+
let checking = false
|
|
1242
|
+
let healthFails = 0
|
|
1243
|
+
const HEALTH_FAIL_LIMIT = 3
|
|
1244
|
+
|
|
1245
|
+
// Self-heal probe picks the first non-loopback IPv4 (falls back to loopback).
|
|
1246
|
+
const primaryIPv4 = () => allLocalIPs().find((ip) => ip.includes('.')) || null
|
|
1247
|
+
|
|
1248
|
+
// Plaintext twin of probeHttps — see the call site: probing a plaintext
|
|
1249
|
+
// listener with TLS would fail 3× and rebuild it in a loop forever.
|
|
1250
|
+
const probeHttp = (host, port, timeoutMs = 4000) =>
|
|
1251
|
+
new Promise((resolve) => {
|
|
1252
|
+
let settled = false
|
|
1253
|
+
const done = (ok) => { if (settled) return; settled = true; req.destroy(); resolve(ok) }
|
|
1254
|
+
const req = httpRequest(
|
|
1255
|
+
{ host, port, path: '/', method: 'GET', timeout: timeoutMs, headers: { Host: host } },
|
|
1256
|
+
(res) => { res.resume(); done(true) },
|
|
1257
|
+
)
|
|
1258
|
+
req.on('timeout', () => done(false))
|
|
1259
|
+
req.on('error', () => done(false))
|
|
1260
|
+
req.end()
|
|
1261
|
+
})
|
|
1262
|
+
|
|
1263
|
+
const probeHttps = (host, port, timeoutMs = 4000) =>
|
|
1264
|
+
new Promise((resolve) => {
|
|
1265
|
+
let settled = false
|
|
1266
|
+
const done = (ok) => { if (settled) return; settled = true; req.destroy(); resolve(ok) }
|
|
1267
|
+
const req = httpsRequest(
|
|
1268
|
+
{ host, port, path: '/', method: 'GET', rejectUnauthorized: false, timeout: timeoutMs, headers: { Host: host } },
|
|
1269
|
+
(res) => { res.resume(); done(true) },
|
|
1270
|
+
)
|
|
1271
|
+
req.on('timeout', () => done(false))
|
|
1272
|
+
req.on('error', () => done(false))
|
|
1273
|
+
req.end()
|
|
1274
|
+
})
|
|
1275
|
+
|
|
1276
|
+
const startHealthCheck = () => { stopHealthCheck(); healthTimer = setInterval(() => { void checkHealth() }, 60000); healthTimer.unref && healthTimer.unref() }
|
|
1277
|
+
const stopHealthCheck = () => { if (healthTimer) { clearInterval(healthTimer); healthTimer = null } }
|
|
1278
|
+
const checkHealth = async () => {
|
|
1279
|
+
const gw = current
|
|
1280
|
+
if (!gw || typeof gw.port !== 'number' || checking) return
|
|
1281
|
+
checking = true
|
|
1282
|
+
try {
|
|
1283
|
+
const host =
|
|
1284
|
+
currentOptions && currentOptions.listenHost === '0.0.0.0'
|
|
1285
|
+
? (primaryIPv4() || '127.0.0.1')
|
|
1286
|
+
: ((currentOptions && currentOptions.listenHost) || '127.0.0.1')
|
|
1287
|
+
const probe = (currentOptions && currentOptions.plaintext === true) ? probeHttp : probeHttps
|
|
1288
|
+
const ok = await probe(host, gw.port)
|
|
1289
|
+
if (current !== gw) return
|
|
1290
|
+
if (ok) { if (healthFails > 0) healthFails = 0; return }
|
|
1291
|
+
healthFails += 1
|
|
1292
|
+
if (healthFails >= HEALTH_FAIL_LIMIT) {
|
|
1293
|
+
healthFails = 0
|
|
1294
|
+
warn(`user-management: listener health check failed ${HEALTH_FAIL_LIMIT}x consecutively — not serving, restarting`)
|
|
1295
|
+
await queueRebuild(true)
|
|
1296
|
+
} else {
|
|
1297
|
+
warn(`user-management: HTTPS health check failed (${healthFails}/${HEALTH_FAIL_LIMIT}) — will retry`)
|
|
1298
|
+
}
|
|
1299
|
+
} finally {
|
|
1300
|
+
checking = false
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// ── /user-management/panel — admin-only status (reached via the gateway proxy) ─
|
|
1305
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1306
|
+
kind: 'prefix',
|
|
1307
|
+
path: '/user-management/panel',
|
|
1308
|
+
handler: async (req, res) => {
|
|
1309
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
1310
|
+
if (method !== 'GET' && method !== 'HEAD') return sendJson(res, 405, { error: 'method not allowed' })
|
|
1311
|
+
await ready
|
|
1312
|
+
const cookies = parseCookies(req.headers && req.headers.cookie)
|
|
1313
|
+
const session = await store.resolveSession(cookies[SESSION_COOKIE])
|
|
1314
|
+
if (!session || session.user.role !== 'admin') return sendJson(res, 403, { error: '需要管理员权限' })
|
|
1315
|
+
const cfg = resolvedConfig()
|
|
1316
|
+
const phase = cfg.enabled === false ? 'disabled' : restarting ? 'restarting' : current ? 'running' : lastError ? 'error' : 'stopped'
|
|
1317
|
+
return sendJson(res, 200, {
|
|
1318
|
+
version: pkg.version,
|
|
1319
|
+
enabled: cfg.enabled !== false,
|
|
1320
|
+
phase,
|
|
1321
|
+
startedAt,
|
|
1322
|
+
lastError,
|
|
1323
|
+
listenHost: cfg.listenHost,
|
|
1324
|
+
port: (current && current.port) || cfg.port,
|
|
1325
|
+
sites: (cfg.sites || []).map((s) => ({ hosts: s.hosts || [], cert: s.cert ? 'file' : 'auto' })),
|
|
1326
|
+
users: store.listUsers().length,
|
|
1327
|
+
})
|
|
1328
|
+
},
|
|
1329
|
+
}), `${pluginName}: panel route`)
|
|
1330
|
+
|
|
1331
|
+
// ── settings namespace: hot-reload on every committed change ────────────
|
|
1332
|
+
if (typeof ctx.inject === 'function') {
|
|
1333
|
+
ctx.inject(['settings'], (scope) => {
|
|
1334
|
+
try {
|
|
1335
|
+
const registration = scope.settings.register('user-management', Config, { base: config })
|
|
1336
|
+
settingsScope = registration
|
|
1337
|
+
registration.watch(() => { void queueRebuild() })
|
|
1338
|
+
void queueRebuild()
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
warn(`user-management: settings namespace unavailable, using the composition config only — ${error.message || error}`)
|
|
1341
|
+
void queueRebuild()
|
|
1342
|
+
}
|
|
1343
|
+
})
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Profiles without a settings service still get the gateway from composition config.
|
|
1347
|
+
void queueRebuild()
|
|
1348
|
+
|
|
1349
|
+
ctx.on('dispose', () => {
|
|
1350
|
+
stopHealthCheck()
|
|
1351
|
+
if (current) log('user-management: plugin disposed — stopping listener')
|
|
1352
|
+
try { current && current.stop() } catch (e) { warn(`user-management: error stopping listener on dispose — ${e.message || e}`) }
|
|
1353
|
+
current = null
|
|
1354
|
+
})
|
|
1355
|
+
},
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
export default plugin
|