Hub stage 0: own users with roles, login limit, audit log, permission table

Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator,
apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a
bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock
the address or the name for 15 minutes. Logins, logouts and every refused request go
to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are
listed in routes.ts with their kind and capability; writes stay refused for now.
Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan
is in docs/hub-management-plan.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 15:25:38 +07:00
1 parent 042cb116ea
commit 0ef2d53a65
17 files changed
+1497 -79

No files matched your search

+74 -22
View File
@@ -17,6 +17,10 @@ import { fileURLToPath } from 'node:url'
import { loadConfig, HUB_VERSION, type HubConfig, type HubServer } from './config.ts'
import { Upstream, UpstreamError, type Params } from './upstream.ts'
import { Sessions } from './session.ts'
import { Users, can } from './users.ts'
import { LoginLimiter, lockedMessage } from './ratelimit.ts'
import { Audit } from './audit.ts'
import { routeFor } from './routes.ts'
import {
mergeQueryLogs,
mergeSettled,
@@ -32,19 +36,6 @@ type Json = Record<string, unknown>
const MAX_BODY = 64 * 1024
/* Paths relative to `/api/`. Anything not listed here is refused with 403. */
const READ_ENDPOINTS = new Set([
'dashboard/stats/get',
'dashboard/stats/getTop',
'dashboard/metrics/json',
'apps/list',
'logs/query',
'logs/list',
'settings/get',
'user/checkForUpdate',
'admin/cluster/state',
])
const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
@@ -102,9 +93,20 @@ function positive(value: string | undefined, fallback: number): number {
return Number.isInteger(n) && n > 0 ? n : fallback
}
export function createHub(config: HubConfig): http.Server {
/* The client's address: the socket's, or with HUB_TRUST_PROXY the first X-Forwarded-For entry. */
function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = String(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim()
if (forwarded !== '') return forwarded
}
return req.socket.remoteAddress ?? 'unknown'
}
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
const upstream = new Upstream(config)
const sessions = new Sessions(config)
const sessions = new Sessions(config, users)
const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir)
const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -186,30 +188,66 @@ export function createHub(config: HubConfig): http.Server {
send(res, 200, { status: 'ok', hasDefaultCredentials: false, ssoEnabled: false })
return
}
const ip = clientIp(req, config.trustProxy)
if (apiPath === 'user/login') {
const token = sessions.login(params.user ?? '', params.pass ?? '')
send(res, 200, token === null ? fail(`Invalid username or password for user: ${params.user ?? ''}`) : sessions.body(token))
const name = params.user ?? ''
const locked = limiter.lockedFor(ip, name)
if (locked > 0) {
audit.log({ user: name, ip, action: 'login', outcome: 'refused', detail: 'locked' })
send(res, 429, fail(lockedMessage(locked)))
return
}
const user = await users.authenticate(name, params.pass ?? '')
if (user === null) {
limiter.failure(ip, name)
audit.log({ user: name, ip, action: 'login', outcome: 'failed', detail: users.isDisabled(name) ? 'disabled' : undefined })
send(res, 200, fail(`Invalid username or password for user: ${name}`))
return
}
limiter.success(name)
audit.log({ user: user.name, role: user.role, ip, action: 'login', outcome: 'ok' })
const token = sessions.open(user)
send(res, 200, sessions.body(token, { user: user.name, role: user.role }))
return
}
const token = bearer(req) ?? params.token ?? null
if (!sessions.touch(token)) {
const who = sessions.touch(token)
if (who === null) {
send(res, 200, { status: 'invalid-token', errorMessage: 'Invalid token or session expired.' })
return
}
if (apiPath === 'user/session/get') {
send(res, 200, sessions.body(token as string))
send(res, 200, sessions.body(token as string, who))
return
}
if (apiPath === 'user/logout') {
sessions.logout(token as string)
audit.log({ user: who.user, role: who.role, ip, action: 'logout', outcome: 'ok' })
send(res, 200, { status: 'ok' })
return
}
if (!READ_ENDPOINTS.has(apiPath)) {
const route = routeFor(apiPath)
if (route === undefined) {
audit.log({
user: who.user, role: who.role, ip, action: 'refused-write', target: apiPath,
server: params.node || undefined, outcome: 'refused', detail: 'not in the permission table',
})
send(res, 403, fail('The hub is read-only.'))
return
}
if (!can(who.role, route.capability)) {
audit.log({
user: who.user, role: who.role, ip, action: route.kind === 'read' ? 'denied-read' : 'denied-write',
target: apiPath, outcome: 'refused', detail: `needs ${route.capability}`,
})
send(res, 403, fail('Your role does not allow this.'))
return
}
if (apiPath === 'hub/audit/list') {
send(res, 200, ok({ entries: audit.list(Number(params.limit ?? 200), params.before) }))
return
}
if (apiPath === 'user/checkForUpdate') {
send(res, 200, ok({ updateAvailable: false }))
return
@@ -302,7 +340,10 @@ export function createHub(config: HubConfig): http.Server {
createReadStream(file).on('error', () => res.destroy()).pipe(res)
}
return http.createServer((req, res) => {
const sweeper = setInterval(() => limiter.sweep(), 5 * 60_000)
sweeper.unref()
const server = http.createServer((req, res) => {
void (async () => {
try {
const url = new URL(req.url ?? '/', 'http://hub')
@@ -317,6 +358,8 @@ export function createHub(config: HubConfig): http.Server {
}
})()
})
server.on('close', () => clearInterval(sweeper))
return server
}
/* Run directly (not imported by the tests). */
@@ -328,7 +371,16 @@ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLTo
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
createHub(config).listen(config.port, () => {
let hub: http.Server
try {
const users = Users.load(config.dataDir, config.bootstrap)
if (users.notice !== null) console.log(`hub: ${users.notice}`)
hub = createHub(config, users)
} catch (e) {
console.error(`hub: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
}
hub.listen(config.port, () => {
console.log(`Hub ${HUB_VERSION} on :${config.port}, servers: ${config.servers.map((s) => s.id).join(', ')}`)
})
}