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.
@@ -0,0 +1,769 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The user-management gateway server: one listener that terminates TLS
5
+ * (SNI-selected per-site certificates, self-signed where none are supplied),
6
+ * enforces a Host allow-list, runs the user-management store-backed auth gate,
7
+ * serves /login + /user-management/api/* locally, and reverse-proxies
8
+ * everything else — WebSocket upgrades included — to the loopback dsh
9
+ * webserver.
10
+ *
11
+ * Two listener modes share that whole pipeline:
12
+ * - default (node:https) — the standalone deployment, where this gateway IS
13
+ * the login door;
14
+ * - `plaintext: true` (node:http, loopback only) — the merged deployment,
15
+ * where dsh-passwords is the only door and this listener is the ledger
16
+ * observer it proxies through.
17
+ *
18
+ * Adapted from dsh-gateway (clarknu/dsh-gateway) lib/gateway-core.js. The
19
+ * dsh-gateway flat-users HMAC auth is replaced by user-management's store:
20
+ * the routing defers to a `decider` (see src/gate.js createDecider) for the
21
+ * allow/redirect/401/403 decision, and to `handleApi` for the local API.
22
+ *
23
+ * Cordis-free by design: the plugin wrapper (src/index.js) owns config
24
+ * resolution + lifecycle + hot-reload + self-heal; this module is testable
25
+ * standalone.
26
+ */
27
+
28
+ import { createServer as createHttpServer } from 'node:http'
29
+ import { createServer as createHttpsServer } from 'node:https'
30
+ import { createSecureContext } from 'node:tls'
31
+ import { X509Certificate } from 'node:crypto'
32
+ import { createProxy } from './proxy.js'
33
+ import {
34
+ clampSessionHistorySandbox,
35
+ collectIdPathPairs,
36
+ extractWorkspaceRenamePaths,
37
+ isWorkspaceCreate,
38
+ isWorkspaceDeleteOrRename,
39
+ normalizePath,
40
+ sanitizeHiddenUnicodeJson,
41
+ extractPathFromBody,
42
+ extractWorkspaceId,
43
+ filterByPathField,
44
+ findStringField,
45
+ folderAllowed,
46
+ forceRejectApproval,
47
+ isAdminOnlyPluginEndpoint,
48
+ isGitRequest,
49
+ isUploadRequest,
50
+ isWorkspaceRestricted,
51
+ filterByPathFieldWithPredicate,
52
+ permissionPresetFromCommand,
53
+ presetFromSettingsMutate,
54
+ sandboxPresetRank,
55
+ } from './permissions.js'
56
+ import { loadOrCreateSiteCert } from './certs.js'
57
+ import {
58
+ SESSION_COOKIE,
59
+ LOGIN_PAGE_PATH,
60
+ API_PREFIX,
61
+ parseCookies,
62
+ isAuditableRequest,
63
+ sendUnauthorized,
64
+ sendForbidden,
65
+ } from './gate.js'
66
+
67
+ /**
68
+ * Create a gateway from resolved options. Returns { start, stop, port }.
69
+ *
70
+ * options: {
71
+ * listenHost, port, upstream, sites, certsDir, title,
72
+ * decider, // async (req) => { action, session?, path? }
73
+ * handleApi, // async (req, res, deps)
74
+ * renderLoginPage, // ({ hasUsers, title }) => html
75
+ * deps, // { store, clientIp } for handleApi
76
+ * clearedCookie, // () => string (for /logout)
77
+ * auditHooks, // { onApiRequest(req, session, path, status), onWsOpen(req, session, path) }
78
+ * log(msg), warn(msg), onError(err)
79
+ * }
80
+ */
81
+ function createGateway(options) {
82
+ // Plaintext mode: the merged deployment's observer listener. See the option
83
+ // doc in index.js — loopback only, no certificate, reached solely by the
84
+ // co-located dsh-passwords gateway (which tunnels WS as raw TCP and so
85
+ // cannot speak TLS on this hop).
86
+ const plaintext = options.plaintext === true
87
+ const log = typeof options.log === 'function' ? options.log : () => {}
88
+ const warn = typeof options.warn === 'function' ? options.warn : log
89
+ const proxy = createProxy(options.upstream, options.getAuthenticatedUrl, { rewriteResponse: rewritePlanFor })
90
+
91
+ /** dsh's workspace-list RPC — the one response this layer rewrites. */
92
+ const WORKSPACE_LIST_RE = /^\/api\/workspace[.\/]list$/
93
+ /** dsh's session-creating RPC — the one request this layer inspects. */
94
+ const SESSION_CREATE_RE = /^\/api\/session[.\/]create([.\/]|$)/
95
+ /** The paths through which a sandbox preset can be widened. */
96
+ const SANDBOX_SETTINGS_RE = /^\/api\/settings[.\/]/
97
+ const SANDBOX_COMMAND_RE = /^\/api\/commands[.\/]execute$/
98
+ const SANDBOX_RESPOND_RE = /^\/api\/respond$/
99
+ /** Session history replay — clamped and sanitized for every account. */
100
+ const SESSION_HISTORY_RE = /^\/api\/session[.\/]history$/
101
+ /** Hard cap on an inspected request body; over it the request is refused. */
102
+ const MAX_GATE_BODY = 64 * 1024
103
+ /** workspaceId → path, harvested from workspace-list responses (see below). */
104
+ const workspacePathById = new Map()
105
+
106
+ function requestPath(req) {
107
+ try { return new URL(req.url || '/', 'http://dsh.local').pathname } catch { return '' }
108
+ }
109
+
110
+ /**
111
+ * Ask the host half for an account's permissions. Kept behind deps so this
112
+ * module stays cordis-free and testable: no store, no policy, just a lookup.
113
+ */
114
+ /** Owner map (path → username) as the host half sees it. */
115
+ function ownerMap() {
116
+ const read = options.deps && options.deps.workspaceOwners
117
+ if (typeof read !== 'function') return {}
118
+ try { return read() || {} } catch { return {} }
119
+ }
120
+
121
+ function sessionUsername(session) {
122
+ return session && session.user && typeof session.user.username === 'string' ? session.user.username : ''
123
+ }
124
+
125
+ function sessionIsAdmin(session) {
126
+ return !!(session && session.user && session.user.role === 'admin')
127
+ }
128
+
129
+ function applyWorkspaceChange(method, path) {
130
+ const change = options.deps && options.deps[method]
131
+ if (typeof change !== 'function') return
132
+ try {
133
+ const result = change.apply(null, path)
134
+ if (result && typeof result.catch === 'function') result.catch(() => {})
135
+ } catch { /* ownership bookkeeping must never break a request */ }
136
+ }
137
+
138
+ function permissionsFor(session) {
139
+ const lookup = options.deps && options.deps.permissionsFor
140
+ if (typeof lookup !== 'function' || !session) return null
141
+ try { return lookup(session) } catch { return null }
142
+ }
143
+
144
+ /**
145
+ * Decide whether a response must be rewritten before the client sees it.
146
+ *
147
+ * Today only the workspace list qualifies: an account with a folder allow-list
148
+ * must not receive entries outside it. The unrestricted case (empty list, or
149
+ * no account match) returns null and streams through untouched, so the common
150
+ * path pays nothing.
151
+ */
152
+ function rewritePlanFor(req) {
153
+ if ((req.method || '').toUpperCase() !== 'POST') return null
154
+ const path = requestPath(req)
155
+ const perms = permissionsFor(req.umSession)
156
+
157
+ if (WORKSPACE_LIST_RE.test(path)) {
158
+ const session = req.umSession
159
+ const folderScoped = perms !== null && perms !== undefined && isWorkspaceRestricted(perms.allowedFolders)
160
+ const ownerScoped = !sessionIsAdmin(session)
161
+ // An administrator with an unrestricted list has nothing to hide; anyone
162
+ // else either has a folder allow-list, or must not see other people's
163
+ // workspaces, or both.
164
+ if (!folderScoped && !ownerScoped) return null
165
+ const mine = sessionUsername(session)
166
+ const isAdmin = sessionIsAdmin(session)
167
+ return {
168
+ transform: (parsed) => {
169
+ // Harvest BEFORE filtering: the cache must also hold workspaces this
170
+ // account cannot see, so a create aimed at one resolves to a path and
171
+ // is then correctly refused (rather than looking unidentifiable).
172
+ collectIdPathPairs(parsed, workspacePathById)
173
+ const owners = ownerMap()
174
+ const visible = (candidate) => {
175
+ if (folderScoped && !folderAllowed(candidate, perms.allowedFolders)) return false
176
+ if (isAdmin) return true
177
+ const owner = owners[normalizePath(candidate)]
178
+ return owner === undefined || owner === mine
179
+ }
180
+ const filtered = filterByPathFieldWithPredicate(parsed, 'path', visible)
181
+ return sanitizeHiddenUnicodeJson(filtered)
182
+ },
183
+ }
184
+ }
185
+
186
+ if (SESSION_HISTORY_RE.test(path)) {
187
+ const allowedMode = perms && typeof perms.sandboxMode === 'string' ? perms.sandboxMode : null
188
+ return {
189
+ transform: (parsed) => {
190
+ // Clamp first (it rewrites values in place), then sanitize: a hidden
191
+ // character smuggled into a preset name must not survive the rewrite.
192
+ if (allowedMode !== null) clampSessionHistorySandbox(parsed, allowedMode)
193
+ return sanitizeHiddenUnicodeJson(parsed)
194
+ },
195
+ }
196
+ }
197
+
198
+ return null
199
+ }
200
+
201
+ const sites = (options.sites || [{ hosts: ['localhost'] }]).map((site) => ({
202
+ hosts: (site.hosts || []).map((h) => String(h).toLowerCase()),
203
+ cert: site.cert || '',
204
+ key: site.key || '',
205
+ }))
206
+ if (sites.length === 0) sites.push({ hosts: [], cert: '', key: '' })
207
+
208
+ const allowList = new Set(sites.flatMap((s) => s.hosts))
209
+ if (plaintext) {
210
+ // The signing gateway dials this listener as http://127.0.0.1:<port>, and
211
+ // a loopback literal is exactly what the auto site list omits (it skips
212
+ // internal addresses on purpose). Without these the observer would answer
213
+ // 421 to its only legitimate caller.
214
+ for (const host of ['127.0.0.1', 'localhost', '::1']) allowList.add(host)
215
+ }
216
+ const allowAll = allowList.size === 0
217
+ if (allowAll) {
218
+ warn('user-management: no hosts configured — accepting every Host header (set sites[].hosts to restrict)')
219
+ }
220
+
221
+ // Plaintext carries no certificate at all: no TLS context is built, and
222
+ // certMaterial below stays null so the cert-download endpoints answer 404
223
+ // and the client's certificate card hides itself (the single door owns TLS).
224
+ const contexts = plaintext ? [] : sites.map((site) => {
225
+ const { cert, key } = loadOrCreateSiteCert(site, options.certsDir, log)
226
+ return {
227
+ hosts: site.hosts,
228
+ context: createSecureContext({ cert, key }),
229
+ certPem: cert,
230
+ keyPem: key,
231
+ }
232
+ })
233
+ const defaultSite = contexts[0] ?? null
234
+
235
+ // Certificate material for the download endpoints. The certificate is
236
+ // public by nature (broadcast in every TLS handshake) — serving it
237
+ // unauthenticated is the whole point: a first-time visitor fetches it,
238
+ // imports it into their trust store, and the self-signed warning goes
239
+ // away for good. Computed once per gateway instance.
240
+ let certMaterial = null
241
+ try {
242
+ if (defaultSite === null) throw new Error('plaintext listener: no certificate material')
243
+ const x509 = new X509Certificate(defaultSite.certPem)
244
+ certMaterial = {
245
+ pem: defaultSite.certPem,
246
+ der: x509.raw,
247
+ fingerprint: x509.fingerprint256,
248
+ notBefore: x509.validFrom,
249
+ notAfter: x509.validTo,
250
+ subject: x509.subject,
251
+ }
252
+ } catch { /* malformed cert — the endpoints answer 404 */ }
253
+ const allHosts = [...new Set(sites.flatMap((s) => s.hosts))]
254
+
255
+ /** Host matching: exact, bare wildcard, or *.example.com wildcard. */
256
+ function hostMatches(pattern, host) {
257
+ if (pattern === host || pattern === '*') return true
258
+ if (pattern.startsWith('*.')) {
259
+ const suffix = pattern.slice(1)
260
+ return host.endsWith(suffix) && host.length > suffix.length
261
+ }
262
+ return false
263
+ }
264
+
265
+ /** Strip port and brackets from a Host header value; lowercase. */
266
+ function normalizeHost(header) {
267
+ if (!header) return ''
268
+ let host = String(header).trim().toLowerCase()
269
+ if (host.startsWith('[')) {
270
+ const end = host.indexOf(']')
271
+ return end === -1 ? host : host.slice(1, end)
272
+ }
273
+ const colon = host.lastIndexOf(':')
274
+ return colon === -1 ? host : host.slice(0, colon)
275
+ }
276
+
277
+ function selectContext(servername) {
278
+ if (defaultSite === null) return null // plaintext listener: never consulted
279
+ const name = (servername || '').toLowerCase()
280
+ for (const entry of contexts) {
281
+ if (entry.hosts.some((h) => hostMatches(h, name))) return entry.context
282
+ }
283
+ return defaultSite.context
284
+ }
285
+
286
+ function hostAllowed(host) {
287
+ if (allowAll) return true
288
+ for (const pattern of allowList) {
289
+ if (hostMatches(pattern, host)) return true
290
+ }
291
+ return false
292
+ }
293
+
294
+ const send = (res, status, headers, body) => {
295
+ res.writeHead(status, headers)
296
+ res.end(body)
297
+ }
298
+ const redirect = (res, location, extraHeaders) =>
299
+ send(res, 302, Object.assign({ Location: location, 'cache-control': 'no-store' }, extraHeaders || {}), '')
300
+
301
+ /** Buffer a request body with a hard cap; null means "could not read it". */
302
+ function readRequestBody(req, limit) {
303
+ return new Promise((resolve) => {
304
+ const chunks = []
305
+ let size = 0
306
+ let done = false
307
+ const finish = (value) => {
308
+ if (done) return
309
+ done = true
310
+ resolve(value)
311
+ }
312
+ req.on('data', (chunk) => {
313
+ size += chunk.length
314
+ if (size > limit) {
315
+ finish(null)
316
+ req.destroy()
317
+ return
318
+ }
319
+ chunks.push(chunk)
320
+ })
321
+ req.on('end', () => finish(Buffer.concat(chunks)))
322
+ req.on('error', () => finish(null))
323
+ req.on('aborted', () => finish(null))
324
+ })
325
+ }
326
+
327
+ /**
328
+ * Path-level gates — no body needed, because the decision is purely
329
+ * (method, path).
330
+ *
331
+ * Two rules, in this order:
332
+ * 1. The third-party ops surfaces are admin-only for EVERYONE (they sit
333
+ * outside the permission model, so no account setting can open them);
334
+ * 2. otherwise the owning account (role 'admin') is unrestricted — the
335
+ * upload/git switches are for the accounts it hands out, exactly how
336
+ * dsh-passwords scopes them to sub-users.
337
+ *
338
+ * @returns {string|null} a refusal reason, or null to proceed.
339
+ */
340
+ function pathGateFor(req) {
341
+ const session = req.umSession
342
+ const perms = permissionsFor(session)
343
+ if (perms === null || perms === undefined) return null
344
+ const method = (req.method || 'GET').toUpperCase()
345
+ const path = requestPath(req)
346
+ const isAdmin = !!(session && session.user && session.user.role === 'admin')
347
+
348
+ if (isAdminOnlyPluginEndpoint(method, path)) {
349
+ return isAdmin ? null : 'this endpoint is available to the owning account only'
350
+ }
351
+ if (isAdmin) return null
352
+ if (perms.allowUpload !== true && isUploadRequest(method, path)) {
353
+ return 'uploads are disabled for this account'
354
+ }
355
+ if (perms.allowGit !== true && isGitRequest(path)) {
356
+ return 'git and download operations are disabled for this account'
357
+ }
358
+ return null
359
+ }
360
+
361
+ /**
362
+ * Ask the host half whether this account has spent its allowance.
363
+ *
364
+ * The host owns the counters (it owns the store); the gateway only asks, so
365
+ * this module stays free of persistence and policy. A refusal is a plain 403
366
+ * with the reason, and a broken counter never blocks traffic — an exception
367
+ * reads as "allowed", because a quota bug must not lock everyone out.
368
+ */
369
+ function quotaGateFor(req) {
370
+ const refuse = options.deps && options.deps.quotaRefusal
371
+ if (typeof refuse !== 'function') return null
372
+ const method = (req.method || 'GET').toUpperCase()
373
+ if (method === 'OPTIONS' || method === 'HEAD') return null
374
+ try {
375
+ return refuse(req.umSession, requestPath(req))
376
+ } catch {
377
+ return null
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Which inspection, if any, this request needs — and whose rules apply.
383
+ *
384
+ * `kind` is the inspection to run: 'folder' guards where a session may be
385
+ * created, 'settings'/'command' guard against widening the sandbox, 'respond'
386
+ * forces escalation approvals to a denial.
387
+ */
388
+ function gateFor(req) {
389
+ const method = (req.method || '').toUpperCase()
390
+ if (method !== 'POST' && method !== 'PUT') return null
391
+ const perms = permissionsFor(req.umSession)
392
+ if (perms === null || perms === undefined) return null
393
+ const path = requestPath(req)
394
+ const folderScope = isWorkspaceRestricted(perms.allowedFolders)
395
+ const sandboxScope = typeof perms.sandboxMode === 'string' && perms.sandboxMode !== ''
396
+ if (folderScope && SESSION_CREATE_RE.test(path)) return { kind: 'folder', perms }
397
+ // Workspace management is judged for every non-admin: the ownership map and
398
+ // the creation switch apply even to an account with no folder allow-list.
399
+ if (!sessionIsAdmin(req.umSession) && (isWorkspaceCreate(path) || isWorkspaceDeleteOrRename(path))) {
400
+ return { kind: 'workspace', perms, path }
401
+ }
402
+ if (sandboxScope && SANDBOX_SETTINGS_RE.test(path)) return { kind: 'settings', perms }
403
+ if (sandboxScope && SANDBOX_COMMAND_RE.test(path)) return { kind: 'command', perms }
404
+ if (sandboxScope && SANDBOX_RESPOND_RE.test(path)) return { kind: 'respond', perms }
405
+ return null
406
+ }
407
+
408
+ /**
409
+ * Folder gate for session-creating requests.
410
+ *
411
+ * A restricted account may only start a session inside a folder it is allowed
412
+ * to touch. Finding that folder means reading the body, so the body is
413
+ * buffered and replayed upstream (proxy.sendRequestBody).
414
+ *
415
+ * @returns {string|null} null when the request may proceed, else a short
416
+ * reason it must be refused. Anything unverifiable is refused:
417
+ * an unreadable body is an unknown target, not a permission.
418
+ */
419
+ async function inspectGatedRequest(req, gate) {
420
+ const raw = await readRequestBody(req, MAX_GATE_BODY)
421
+ if (raw === null) return 'request body unreadable'
422
+ let parsed = null
423
+ if (raw.length > 0) {
424
+ try {
425
+ parsed = JSON.parse(raw.toString('utf8'))
426
+ } catch {
427
+ return 'request body unparseable'
428
+ }
429
+ }
430
+
431
+ if (gate.kind === 'folder') {
432
+ let target = extractPathFromBody(parsed)
433
+ if (target === null) {
434
+ const id = extractWorkspaceId(parsed)
435
+ if (id !== null) target = workspacePathById.get(id) ?? null
436
+ if (target === null) return 'target workspace not identifiable'
437
+ }
438
+ if (!folderAllowed(target, gate.perms.allowedFolders)) return 'folder outside the allowed workspace scope'
439
+ }
440
+
441
+ if (gate.kind === 'workspace') {
442
+ if (gate.perms.allowWorkspaceCreate !== true) {
443
+ return 'workspace creation and management are disabled for this account'
444
+ }
445
+ // A rename carries oldPath/newPath — keys the generic path walker does not
446
+ // know — so the dedicated extractor goes first. Ordering it the other way
447
+ // round (as dsh-passwords did) makes every rename look unidentifiable and
448
+ // fails it closed.
449
+ const isRename = /(?:rename|update)(?:[.\/]|$)/.test(gate.path)
450
+ const renamePaths = isRename ? extractWorkspaceRenamePaths(parsed) : null
451
+ let target = renamePaths !== null ? renamePaths.oldPath : extractPathFromBody(parsed)
452
+ if (target === null) {
453
+ const id = extractWorkspaceId(parsed)
454
+ if (id !== null) target = workspacePathById.get(id) ?? null
455
+ if (target === null) return 'target workspace not identifiable'
456
+ }
457
+ if (isWorkspaceRestricted(gate.perms.allowedFolders) && !folderAllowed(target, gate.perms.allowedFolders)) {
458
+ return 'folder outside the allowed workspace scope'
459
+ }
460
+ const owners = ownerMap()
461
+ const me = sessionUsername(req.umSession)
462
+ const existing = owners[normalizePath(target)] === undefined ? null : owners[normalizePath(target)]
463
+ // Somebody else's workspace is not yours to touch, whatever the switches say.
464
+ if (existing !== null && existing !== me) return 'this workspace belongs to another account'
465
+
466
+ if (isWorkspaceCreate(gate.path)) {
467
+ if (existing === null) applyWorkspaceChange('claimWorkspace', [target, me])
468
+ } else {
469
+ // A rename whose two paths cannot both be read is not authorizable, so it
470
+ // is refused rather than guessed at.
471
+ if (isRename && renamePaths === null) return 'rename target not identifiable'
472
+ if (isRename && renamePaths !== null) {
473
+ if (isWorkspaceRestricted(gate.perms.allowedFolders) && !folderAllowed(renamePaths.newPath, gate.perms.allowedFolders)) {
474
+ return 'folder outside the allowed workspace scope'
475
+ }
476
+ applyWorkspaceChange('renameWorkspace', [target, renamePaths.newPath])
477
+ } else if (existing !== null) {
478
+ // The folder is going away: stop claiming it, or the path would stay
479
+ // hidden from everyone but an administrator forever.
480
+ applyWorkspaceChange('releaseWorkspace', [target])
481
+ }
482
+ }
483
+ }
484
+
485
+ if (gate.kind === 'settings') {
486
+ const preset = presetFromSettingsMutate(parsed)
487
+ const assigned = sandboxPresetRank(gate.perms.sandboxMode)
488
+ if (preset !== null && sandboxPresetRank(preset) > assigned) {
489
+ return `sandbox preset "${preset}" is wider than the account's allowance`
490
+ }
491
+ }
492
+
493
+ if (gate.kind === 'command') {
494
+ const line = findStringField(parsed, 'line')
495
+ const preset = line === null ? null : permissionPresetFromCommand(line)
496
+ if (preset !== null && sandboxPresetRank(preset) > sandboxPresetRank(gate.perms.sandboxMode)) {
497
+ return `sandbox preset "${preset}" is wider than the account's allowance`
498
+ }
499
+ }
500
+
501
+ if (gate.kind === 'respond') {
502
+ // The body is rewritten rather than refused: the answer still has to reach
503
+ // the host, it just cannot be an escalation granted to a restricted agent.
504
+ if (parsed !== null && typeof parsed === 'object' && forceRejectApproval(parsed)) {
505
+ const rewritten = Buffer.from(JSON.stringify(parsed), 'utf8')
506
+ req.umReplayBody = rewritten
507
+ req.headers['content-length'] = String(rewritten.length)
508
+ return null
509
+ }
510
+ }
511
+
512
+ // The upstream still has to receive the body: hand it back for replay.
513
+ req.umReplayBody = raw
514
+ req.headers['content-length'] = String(raw.length)
515
+ return null
516
+ }
517
+
518
+ /** Wire the operation-audit hook to fire when the response completes. */
519
+ function wireAudit(req, res, session, path) {
520
+ const method = (req.method || 'GET').toUpperCase()
521
+ if (typeof options.auditHooks.onApiRequest === 'function' && isAuditableRequest(method, req.headers && req.headers.accept, path)) {
522
+ res.on('finish', () => {
523
+ try { options.auditHooks.onApiRequest(req, session, path, res.statusCode) } catch { /* audit must never break the gate */ }
524
+ })
525
+ }
526
+ }
527
+
528
+ async function handleRequest(req, res) {
529
+ const host = normalizeHost(req.headers.host)
530
+ if (!hostAllowed(host)) {
531
+ return send(res, 421, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }, 'user-management: unknown host')
532
+ }
533
+
534
+ const url = new URL(req.url || '/', `https://${req.headers.host || 'localhost'}`)
535
+ const path = url.pathname
536
+ const method = (req.method || 'GET').toUpperCase()
537
+
538
+ // The decider runs for every request: it checks IP bans first (403,
539
+ // login page included), resolves the session, and returns the gate
540
+ // decision. /login + /logout stay public but are still subject to bans.
541
+ let decision
542
+ try {
543
+ decision = await options.decider(req)
544
+ } catch {
545
+ return sendUnauthorized(res)
546
+ }
547
+ if (decision.action === 'forbidden') return sendForbidden(res)
548
+ // Stashed for the response-rewrite hook: the proxy runs after routing and
549
+ // has no other way to learn whose permissions apply.
550
+ req.umSession = decision.session || null
551
+
552
+ // /user-management/api/cert[?format=der] + /cert-info — public material,
553
+ // served before the auth gate so a first-time (still untrusted) visitor
554
+ // can download the cert and bootstrap trust. IP bans still apply (the
555
+ // decider above ran already).
556
+ if (certMaterial && method === 'GET' && path === `${API_PREFIX}/cert`) {
557
+ const wantDer = url.searchParams.get('format') === 'der'
558
+ return send(res, 200, {
559
+ 'content-type': wantDer ? 'application/x-x509-ca-cert' : 'application/x-pem-file',
560
+ 'content-disposition': `attachment; filename="user-management-gateway.${wantDer ? 'cer' : 'crt'}"`,
561
+ 'cache-control': 'no-store',
562
+ }, wantDer ? certMaterial.der : certMaterial.pem)
563
+ }
564
+ if (certMaterial && method === 'GET' && path === `${API_PREFIX}/cert-info`) {
565
+ return send(res, 200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify({
566
+ fingerprint: certMaterial.fingerprint,
567
+ notBefore: certMaterial.notBefore,
568
+ notAfter: certMaterial.notAfter,
569
+ subject: certMaterial.subject,
570
+ hosts: allHosts,
571
+ }))
572
+ }
573
+
574
+ // /login — standalone login/register page (register tab is default on a
575
+ // fresh system; first registrant becomes admin). Authed users bounce to /.
576
+ if (path === LOGIN_PAGE_PATH) {
577
+ if (method !== 'GET' && method !== 'HEAD') {
578
+ return send(res, 405, { 'content-type': 'text/plain; charset=utf-8', allow: 'GET, HEAD' }, 'method not allowed')
579
+ }
580
+ if (decision.session) return redirect(res, '/')
581
+ const html = options.renderLoginPage({
582
+ hasUsers: options.deps.store.listUsers().length > 0,
583
+ title: options.title || 'DSH 控制台',
584
+ })
585
+ return send(res, 200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, html)
586
+ }
587
+
588
+ // /logout — clear the cookie + drop the server session, back to /login.
589
+ if (path === '/logout') {
590
+ const cookies = parseCookies(req.headers && req.headers.cookie)
591
+ const token = cookies[SESSION_COOKIE]
592
+ if (token) {
593
+ try { await options.deps.store.dropSession(token) } catch { /* best effort */ }
594
+ }
595
+ return redirect(res, LOGIN_PAGE_PATH, { 'Set-Cookie': options.clearedCookie() })
596
+ }
597
+
598
+ // /user-management/api/* — handled locally by the existing API (login,
599
+ // register, session, self-service, admin user-mgmt, audit, bans). The
600
+ // decider already gated non-public sub-paths (401 anonymous); handleApi
601
+ // re-checks authority too.
602
+ if (path === API_PREFIX || path.startsWith(`${API_PREFIX}/`)) {
603
+ if (decision.action !== 'allow') {
604
+ if (decision.action === 'redirect') return redirect(res, decision.location || LOGIN_PAGE_PATH)
605
+ return sendUnauthorized(res)
606
+ }
607
+ wireAudit(req, res, decision.session, path)
608
+ try {
609
+ await options.handleApi(req, res, options.deps)
610
+ } catch (error) {
611
+ // The host half maps its own error types to status codes (a malformed
612
+ // request body is a 400, not a server fault); anything it does not name
613
+ // stays a 500.
614
+ const toStatus = options.deps && options.deps.statusForError
615
+ let status = 500
616
+ if (typeof toStatus === 'function') {
617
+ try {
618
+ const mapped = toStatus(error)
619
+ if (typeof mapped === 'number' && mapped >= 400 && mapped <= 599) status = mapped
620
+ } catch { /* fall back to 500 */ }
621
+ }
622
+ if (!res.headersSent) send(res, status, { 'content-type': 'application/json; charset=utf-8' }, JSON.stringify({ error: String((error && error.message) || error) }))
623
+ else res.end()
624
+ }
625
+ return
626
+ }
627
+
628
+ // /plugins/* — dsh web SPA client-plugin bundles. The SPA's client-modules
629
+ // loader fetches these WITHOUT the session cookie (crossorigin), so the
630
+ // auth gate (401 anonymous) breaks plugin loading ("Failed to load
631
+ // plugins"). They are static SPA assets (no secrets) — proxy publicly to
632
+ // the loopback dsh web. IP bans already enforced above (the decider ran).
633
+ if (path.startsWith('/plugins/')) {
634
+ return proxy.handleRequest(req, res)
635
+ }
636
+
637
+ // /manifest.webmanifest + /favicon.svg — PWA metadata referenced by the
638
+ // SPA's index.html <link rel="manifest">. Chrome fetches the manifest
639
+ // WITHOUT credentials (the link carries no crossorigin="use-credentials"),
640
+ // so the auth gate 401s it even for logged-in users ("Manifest fetch from
641
+ // ... failed, code 401"). Static, no secrets — proxy publicly. IP bans
642
+ // already enforced above (the decider ran).
643
+ if (path === '/manifest.webmanifest' || path === '/favicon.svg') {
644
+ return proxy.handleRequest(req, res)
645
+ }
646
+
647
+ // Everything else (the SPA, dsh /api, static) is proxied to the loopback
648
+ // dsh webserver — but only past the auth gate.
649
+ if (decision.action === 'allow') {
650
+ const quotaRefusal = quotaGateFor(req)
651
+ if (quotaRefusal !== null) {
652
+ return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
653
+ JSON.stringify({ error: 'usage allowance exhausted', reason: quotaRefusal }))
654
+ }
655
+ const pathRefusal = pathGateFor(req)
656
+ if (pathRefusal !== null) {
657
+ return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
658
+ JSON.stringify({ error: 'request not allowed for this account', reason: pathRefusal }))
659
+ }
660
+ const gate = gateFor(req)
661
+ if (gate !== null) {
662
+ const reason = await inspectGatedRequest(req, gate)
663
+ if (reason !== null) {
664
+ return send(res, 403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
665
+ JSON.stringify({ error: 'request not allowed for this account', reason }))
666
+ }
667
+ }
668
+ wireAudit(req, res, decision.session, path)
669
+ return proxy.handleRequest(req, res)
670
+ }
671
+ if (decision.action === 'redirect') return redirect(res, decision.location || LOGIN_PAGE_PATH)
672
+ return sendUnauthorized(res)
673
+ }
674
+
675
+ function handleUpgrade(req, socket, head) {
676
+ const host = normalizeHost(req.headers.host)
677
+ if (!hostAllowed(host)) {
678
+ socket.end('HTTP/1.1 421 Misdirected Request\r\nConnection: close\r\n\r\n')
679
+ return
680
+ }
681
+ Promise.resolve()
682
+ .then(() => options.decider(req))
683
+ .then((decision) => {
684
+ if (decision.action === 'allow') {
685
+ if (typeof options.auditHooks.onWsOpen === 'function') {
686
+ try { options.auditHooks.onWsOpen(req, decision.session, decision.path) } catch { /* audit must never break the gate */ }
687
+ }
688
+ return proxy.handleUpgrade(req, socket, head)
689
+ }
690
+ try {
691
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n')
692
+ } catch { /* socket may already be gone */ }
693
+ socket.destroy()
694
+ })
695
+ .catch(() => {
696
+ try { socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch {}
697
+ socket.destroy()
698
+ })
699
+ }
700
+
701
+ let server = null
702
+ let boundPort = null
703
+
704
+ async function start() {
705
+ // The default site's cert/key are passed directly: a server created
706
+ // with only a SecureContext (no cert/key) sends handshake_failure to
707
+ // clients that omit SNI — which is every browser connecting to an IP
708
+ // literal (https://192.168.x.x). cert/key keeps the no-SNI default
709
+ // context alive; SNICallback then selects per-host contexts.
710
+ const onRequest = (req, res) => {
711
+ handleRequest(req, res).catch(() => {
712
+ if (!res.headersSent) sendUnauthorized(res)
713
+ })
714
+ }
715
+ server = plaintext
716
+ ? createHttpServer(onRequest)
717
+ : createHttpsServer(
718
+ {
719
+ cert: defaultSite.certPem,
720
+ key: defaultSite.keyPem,
721
+ SNICallback: (servername, callback) => callback(null, selectContext(servername)),
722
+ },
723
+ onRequest,
724
+ )
725
+ server.on('upgrade', handleUpgrade)
726
+ // TLS-only event: an HTTP listener can never emit it.
727
+ if (!plaintext) server.on('tlsClientError', (_err, tlsSocket) => tlsSocket && tlsSocket.destroy())
728
+ await new Promise((resolve, reject) => {
729
+ const onListening = () => { server.removeListener('error', onError); resolve() }
730
+ const onError = (err) => { server.removeListener('listening', onListening); reject(err) }
731
+ server.once('listening', onListening)
732
+ server.once('error', onError)
733
+ server.listen({ host: options.listenHost || '0.0.0.0', port: options.port != null ? options.port : 19843 })
734
+ })
735
+ boundPort = server.address().port
736
+ server.on('error', (err) => {
737
+ warn(`user-management: listener error — ${err.code || err.message}`)
738
+ if (server !== null && typeof options.onError === 'function') options.onError(err)
739
+ })
740
+ log(
741
+ `user-management: https://${options.listenHost === '0.0.0.0' ? '0.0.0.0' : options.listenHost}:${boundPort} ` +
742
+ `-> ${options.upstream} (hosts: ${allowAll ? '*' : [...allowList].join(',')})`,
743
+ )
744
+ return boundPort
745
+ }
746
+
747
+ function stop() {
748
+ try { proxy.close() } catch { /* agent teardown must never take the listener down */ }
749
+ const old = server
750
+ server = null
751
+ boundPort = null
752
+ if (old) {
753
+ try { old.close() } catch { /* already closed */ }
754
+ old.closeAllConnections && old.closeAllConnections()
755
+ }
756
+ }
757
+
758
+ return {
759
+ start,
760
+ stop,
761
+ get port() { return boundPort },
762
+ /** SHA-256 fingerprint of the certificate presented when no SNI name matches. */
763
+ defaultCertFingerprint() {
764
+ try { return new X509Certificate(defaultSite.certPem).fingerprint256 } catch { return '' }
765
+ },
766
+ }
767
+ }
768
+
769
+ export { createGateway }