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 <noreply@anthropic.com>
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

+54 -6
View File
@@ -8,7 +8,7 @@ read-only, Technitium-compatible `/api/*`:
- No `node` (or `node=cluster`) asks every server in parallel and merges the answers
(statistics are summed, top lists and query logs are merged). If a server does not
answer, the others are shown and the response carries `hubWarnings`.
- Anything that would change data is refused with `403`. The hub has its own sign-in.
- Anything that would change data is refused with `403` (and written to the audit log). The hub has its own accounts with roles, a login rate limit and an audit log.
The servers themselves are not changed: each only needs a read-only API token. Create it
for a user whose group has View, and nothing else, on Dashboard, Logs, Apps (the logging
@@ -20,22 +20,70 @@ apps Query Logs reads from) and Settings (the blocking state).
|---|---|---|
| `HUB_SERVERS` | required | Ordered `id=url` list, e.g. `dns=https://dns.example.net,ndns=https://ndns.example.net`. The first server is shown as the Primary node. |
| `<ID>_TOKEN` | required | API token of each server; the id is uppercased with non-alphanumerics as `_` (`dns` gives `DNS_TOKEN`, `my-dns` gives `MY_DNS_TOKEN`). |
| `HUB_USER`, `HUB_PASSWORD` | required | The hub's own sign-in. It refuses to start without them. |
| `HUB_USER`, `HUB_PASSWORD` | see Users | Bootstrap: one `admin`, used only while `${HUB_DATA}/users.json` does not exist. Set both or neither. |
| `HUB_DATA` | `/data` | Folder of `users.json` and `audit.log`. Mount a volume on it. |
| `HUB_TRUST_PROXY` | off | `1` believes the first `X-Forwarded-For` entry as the client address (set it behind Traefik). Leave it off when clients reach the hub directly, or anyone can fake their address. |
| `<ID>_ADMIN_TOKEN` | optional | A write-capable token per server (`MDNS_ADMIN_TOKEN`). Read into the config for the coming write stages; not used yet and never logged. |
| `HUB_PORT` | `8080` | Listening port. |
| `HUB_CACHE_SECONDS` | `15` | How long successful answers of the servers are cached. |
| `HUB_TIMEOUT_MS` | `8000` | Per-request timeout towards a server. |
| `HUB_DOMAIN` | `hub` | Shown as the server's domain in the console. |
| `HUB_STATIC` | `./dist` | The console's build to serve. |
## Users and roles
The hub has its own accounts in `${HUB_DATA}/users.json` (`{ "users": [{ "name", "role", "hash", "disabled"? }] }`,
hash `scrypt$N$r$p$salt$key`). Names are compared case-insensitively.
- If the file exists, it is the only source; `HUB_USER` / `HUB_PASSWORD` are ignored (a line says so at start).
- If it does not exist and `HUB_USER` / `HUB_PASSWORD` are set, that one user signs in as `admin`. The file is not written.
- If neither exists, the hub refuses to start.
Manage the file with the CLI (`npm run hub:user -- <command>` locally):
```sh
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
```
`add <name> <role>`, `passwd <name>`, `role <name> <role>`, `disable <name>`, `enable <name>`, `list`. The password is asked
twice without echo; for scripts use `HUB_NEW_PASSWORD=... users-cli.ts add ...` (minimum 8 characters). The file is written
atomically with mode 0600. The running hub notices a change on the next request and ends the sessions of the users it touched
(disabled, removed, new role or password).
| Role | Capabilities |
|---|---|
| `viewer` | read |
| `operator` | read, operate (blocking, cache) |
| `apps-admin` | read, apps.write |
| `admin` | everything, including audit.read |
Only reads exist today; the write capabilities are the machinery for the next stages. Every response also carries
`hubRole` (and `info.hubRole`).
## Login protection
After 5 failed logins within 15 minutes from one client address, or for one user name, further attempts are refused for 15
minutes with HTTP 429 and `Too many failed logins. Try again in N minutes.` (the console shows that message). A good login
resets the user's counter. The state is in memory.
## Audit log
`${HUB_DATA}/audit.log` is JSON Lines: `{ts, user, role, ip, action, server?, target?, outcome, detail?}`. It records login
(ok, failed, locked), logout, and every refused request outside the permission table as `refused-write` with its api path.
Passwords, tokens and request bodies are never written. At 10 MB it rotates to `audit.log.1` (one kept). If the folder is not
writable the hub says so once on stderr and keeps the last 1000 entries in memory. Admins read it with
`GET /api/hub/audit/list?limit=200&before=<ISO time>`, newest first.
## Endpoints
Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logout`,
`user/checkForUpdate` (always "no update") and these reads:
`dashboard/stats/get`, `dashboard/stats/getTop`, `dashboard/metrics/json`, `apps/list`,
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized).
`logs/query`, `logs/list`, `settings/get`, `admin/cluster/state` (synthesized), and the hub's own `hub/audit/list` (role with
`audit.read`). They are listed in `routes.ts` with a kind (`read`, `write-one`, `write-all`) and the capability they need.
Everything else answers `403 The hub is read-only.`
Everything else answers `403 The hub is read-only.` and is audited.
## Run locally
@@ -44,7 +92,7 @@ npm run build
node dev/mock-api.mjs --port 5380 &
node dev/mock-api.mjs --port 5381 --scale 2 &
HUB_SERVERS=a=http://127.0.0.1:5380,b=http://127.0.0.1:5381 A_TOKEN=x B_TOKEN=y \
HUB_USER=admin HUB_PASSWORD=admin HUB_PORT=5390 npm run hub
HUB_USER=admin HUB_PASSWORD=admin HUB_DATA=./hub-data HUB_PORT=5390 npm run hub
```
Then open http://localhost:5390. Tests: `npx vitest run hub`.
@@ -54,7 +102,7 @@ Then open http://localhost:5390. Tests: `npx vitest run hub`.
```sh
docker run --rm -p 8080:8080 \
-e HUB_SERVERS=dns=https://dns.example.net,ndns=https://ndns.example.net \
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... \
-e DNS_TOKEN=... -e NDNS_TOKEN=... -e HUB_USER=admin -e HUB_PASSWORD=... -v hub-data:/data \
git.shx.one/denozord/technitium-hub:latest
```
+125
View File
@@ -0,0 +1,125 @@
/*
The audit log: JSON Lines in `${HUB_DATA}/audit.log`, one entry per line, newest last on
disk and newest first from `list`. Rotated at 10 MB to audit.log.1 (one kept). If the
folder is not writable, one line goes to stderr and the log lives in memory (last 1000).
Entries carry fixed fields only. Callers never pass passwords, tokens or request bodies.
*/
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
import path from 'node:path'
export const MAX_BYTES = 10 * 1024 * 1024
const RING = 1000
const DETAIL_MAX = 300
export interface AuditEntry {
ts: string
user: string
role?: string
ip: string
action: string
server?: string
target?: string
outcome: 'ok' | 'failed' | 'refused'
detail?: string
}
export type AuditInput = Omit<AuditEntry, 'ts'>
const cut = (value: string, max: number): string => value.slice(0, max)
export class Audit {
#file: string
#maxBytes: number
#now: () => number
#ring: AuditEntry[] = []
#size = 0
#memoryOnly = false
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
this.#maxBytes = options.maxBytes ?? MAX_BYTES
this.#now = options.now ?? Date.now
this.#file = path.join(dir, 'audit.log')
try {
mkdirSync(dir, { recursive: true })
this.#size = existsSync(this.#file) ? statSync(this.#file).size : 0
appendFileSync(this.#file, '')
} catch (e) {
this.#fallBack(e)
}
}
get inMemory(): boolean {
return this.#memoryOnly
}
#fallBack(e: unknown): void {
if (!this.#memoryOnly) {
console.error(
`hub: audit log is not writable (${e instanceof Error ? e.message : String(e)}); keeping the last ${RING} entries in memory.`,
)
}
this.#memoryOnly = true
}
log(input: AuditInput): void {
const entry: AuditEntry = {
ts: new Date(this.#now()).toISOString(),
user: cut(input.user, 64),
...(input.role === undefined ? {} : { role: input.role }),
ip: cut(input.ip, 64),
action: input.action,
...(input.server === undefined ? {} : { server: input.server }),
...(input.target === undefined ? {} : { target: cut(input.target, 200) }),
outcome: input.outcome,
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
}
this.#ring.push(entry)
if (this.#ring.length > RING) this.#ring.shift()
if (this.#memoryOnly) return
const line = JSON.stringify(entry) + '\n'
try {
if (this.#size + Buffer.byteLength(line) > this.#maxBytes) this.#rotate()
appendFileSync(this.#file, line)
this.#size += Buffer.byteLength(line)
} catch (e) {
this.#fallBack(e)
}
}
#rotate(): void {
rmSync(`${this.#file}.1`, { force: true })
renameSync(this.#file, `${this.#file}.1`)
this.#size = 0
}
#read(file: string): AuditEntry[] {
if (!existsSync(file)) return []
const out: AuditEntry[] = []
for (const line of readFileSync(file, 'utf8').split('\n')) {
if (line === '') continue
try {
out.push(JSON.parse(line) as AuditEntry)
} catch {
// A torn line is skipped.
}
}
return out
}
/** Newest first. `before` is an ISO time or epoch milliseconds: only older entries are returned. */
list(limit = 200, before?: string): AuditEntry[] {
const max = Math.min(Math.max(Math.trunc(limit) || 200, 1), 1000)
let cutoff = Infinity
if (before !== undefined && before !== '') {
const t = /^\d+$/.test(before) ? Number(before) : Date.parse(before)
if (Number.isFinite(t)) cutoff = t
}
const all = this.#memoryOnly ? this.#ring : [...this.#read(`${this.#file}.1`), ...this.#read(this.#file)]
const result: AuditEntry[] = []
for (let i = all.length - 1; i >= 0 && result.length < max; i--) {
if (Date.parse(all[i].ts) < cutoff) result.push(all[i])
}
return result
}
}
+26 -6
View File
@@ -9,12 +9,24 @@ export interface HubServer {
id: string
url: string
token: string
/** Optional write-capable token (`<ID>_ADMIN_TOKEN`). Not used yet; never logged. */
adminToken?: string
}
/** The single admin from HUB_USER and HUB_PASSWORD, used only while there is no users file. */
export interface Bootstrap {
user: string
password: string
}
export interface HubConfig {
servers: HubServer[]
user: string
password: string
/** From HUB_USER and HUB_PASSWORD; null when they are not set. Whether it is used depends on the users file. */
bootstrap: Bootstrap | null
/** Where users.json and audit.log live (HUB_DATA). */
dataDir: string
/** Believe the first X-Forwarded-For entry as the client address (HUB_TRUST_PROXY=1). */
trustProxy: boolean
port: number
cacheSeconds: number
timeoutMs: number
@@ -31,6 +43,11 @@ export function tokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_TOKEN`
}
/* `ndns` -> `NDNS_ADMIN_TOKEN`. */
export function adminTokenVariable(id: string): string {
return `${id.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_ADMIN_TOKEN`
}
function number(env: Env, name: string, fallback: number, min: number): number {
const raw = env[name]
if (raw === undefined || raw.trim() === '') return fallback
@@ -40,9 +57,10 @@ function number(env: Env, name: string, fallback: number, min: number): number {
}
export function parseConfig(env: Env): HubConfig {
// The users file decides whether these are needed; the config does not read the file.
const user = env.HUB_USER ?? ''
const password = env.HUB_PASSWORD ?? ''
if (user === '' || password === '') throw new Error('HUB_USER and HUB_PASSWORD are required.')
if ((user === '') !== (password === '')) throw new Error('HUB_USER and HUB_PASSWORD must be set together.')
const servers: HubServer[] = []
for (const part of (env.HUB_SERVERS ?? '').split(',')) {
@@ -58,7 +76,8 @@ export function parseConfig(env: Env): HubConfig {
const variable = tokenVariable(id)
const token = env[variable] ?? ''
if (token === '') throw new Error(`${variable} is required for the server "${id}".`)
servers.push({ id, url, token })
const adminToken = env[adminTokenVariable(id)] ?? ''
servers.push(adminToken === '' ? { id, url, token } : { id, url, token, adminToken })
}
if (servers.length === 0) {
throw new Error('HUB_SERVERS is required, e.g. dns=https://dns.example.net,ndns=https://ndns.example.net.')
@@ -66,8 +85,9 @@ export function parseConfig(env: Env): HubConfig {
return {
servers,
user,
password,
bootstrap: user === '' ? null : { user, password },
dataDir: env.HUB_DATA?.trim() || '/data',
trustProxy: ['1', 'true'].includes((env.HUB_TRUST_PROXY ?? '').trim().toLowerCase()),
port: number(env, 'HUB_PORT', 8080, 0),
cacheSeconds: number(env, 'HUB_CACHE_SECONDS', 15, 0),
timeoutMs: number(env, 'HUB_TIMEOUT_MS', 8000, 1),
+71
View File
@@ -0,0 +1,71 @@
/*
Login rate limiting. Failures are counted per client address and per user name: after
MAX_FAILURES within WINDOW_MS, further attempts are refused for LOCK_MS. A success clears
the user's counter. In memory; `sweep` drops what has expired.
*/
export const MAX_FAILURES = 5
export const WINDOW_MS = 15 * 60_000
export const LOCK_MS = 15 * 60_000
interface Entry {
failures: number[]
lockedUntil: number
}
const userKey = (user: string): string => user.trim().toLowerCase().slice(0, 128)
export class LoginLimiter {
#now: () => number
#ips = new Map<string, Entry>()
#users = new Map<string, Entry>()
constructor(now: () => number = Date.now) {
this.#now = now
}
/** Milliseconds left of a lock on this address or this user, or 0 when attempts are allowed. */
lockedFor(ip: string, user: string): number {
const now = this.#now()
const until = Math.max(this.#ips.get(ip)?.lockedUntil ?? 0, this.#users.get(userKey(user))?.lockedUntil ?? 0)
return until > now ? until - now : 0
}
failure(ip: string, user: string): void {
this.#record(this.#ips, ip)
this.#record(this.#users, userKey(user))
this.sweep()
}
/** A good login clears the user's counter (the address keeps its own). */
success(user: string): void {
this.#users.delete(userKey(user))
}
#record(map: Map<string, Entry>, key: string): void {
const now = this.#now()
const entry = map.get(key) ?? { failures: [], lockedUntil: 0 }
entry.failures = entry.failures.filter((t) => now - t < WINDOW_MS)
entry.failures.push(now)
if (entry.failures.length >= MAX_FAILURES) {
entry.lockedUntil = now + LOCK_MS
entry.failures = []
}
map.set(key, entry)
}
sweep(): void {
const now = this.#now()
for (const map of [this.#ips, this.#users]) {
for (const [key, entry] of map) {
const recent = entry.failures.some((t) => now - t < WINDOW_MS)
if (entry.lockedUntil <= now && !recent) map.delete(key)
}
}
}
}
/** Technitium-like message for a locked attempt. */
export const lockedMessage = (ms: number): string => {
const minutes = Math.max(1, Math.ceil(ms / 60_000))
return `Too many failed logins. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`
}
+38
View File
@@ -0,0 +1,38 @@
/*
What each `/api/<path>` does and who may call it. `kind` says what it touches:
`read` (no change), `write-one` (changes one server) and `write-all` (changes every server).
A path that is not listed is refused. Today every entry is a read.
Not in the table: `status`, `user/login`, `user/session/get` and `user/logout`, which the
server answers itself before the table is consulted.
*/
import type { Capability } from './users.ts'
export type RouteKind = 'read' | 'write-one' | 'write-all'
export interface Route {
kind: RouteKind
capability: Capability
}
const read = (capability: Capability = 'read'): Route => ({ kind: 'read', capability })
export const ROUTES: Record<string, Route> = {
'dashboard/stats/get': read(),
'dashboard/stats/getTop': read(),
'dashboard/metrics/json': read(),
'apps/list': read(),
'logs/query': read(),
'logs/list': read(),
'settings/get': read(),
'user/checkForUpdate': read(),
'admin/cluster/state': read(),
// The hub's own namespace; never forwarded to a server.
'hub/audit/list': read('audit.read'),
}
export function routeFor(apiPath: string): Route | undefined {
return Object.hasOwn(ROUTES, apiPath) ? ROUTES[apiPath] : undefined
}
export const isHubPath = (apiPath: string): boolean => apiPath.startsWith('hub/')
+376
View File
@@ -0,0 +1,376 @@
// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import { Audit } from './audit.ts'
import { LoginLimiter, LOCK_MS, lockedMessage } from './ratelimit.ts'
import { ROUTES, routeFor } from './routes.ts'
import { run } from './users-cli.ts'
import {
Users,
addUser,
can,
hashPassword,
parseUsersFile,
readUsersFile,
requires,
setDisabled,
setPassword,
setRole,
verifyPassword,
writeUsersFile,
type Role,
} from './users.ts'
let dir: string
let servers: http.Server[] = []
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), 'hub-sec-'))
})
afterEach(() => {
for (const s of servers) s.close()
servers = []
rmSync(dir, { recursive: true, force: true })
})
const env = (over: Record<string, string> = {}) => ({
HUB_SERVERS: 'a=http://127.0.0.1:1',
A_TOKEN: 'token-a',
A_ADMIN_TOKEN: 'admin-secret-a',
HUB_DATA: dir,
HUB_STATIC: dir,
...over,
})
async function start(over: Record<string, string> = {}): Promise<string> {
const hub = createHub(parseConfig(env(over)))
servers.push(hub)
await new Promise<void>((resolve) => hub.listen(0, '127.0.0.1', resolve))
return `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
}
async function call(base: string, apiPath: string, params: Record<string, string> = {}, token?: string, headers: Record<string, string> = {}) {
const headerSet: Record<string, string> = { ...headers }
if (token) headerSet.authorization = `Bearer ${token}`
const isLogin = apiPath === 'user/login'
const res = await fetch(`${base}/api/${apiPath}${isLogin ? '' : `?${new URLSearchParams(params)}`}`, {
method: isLogin ? 'POST' : 'GET',
headers: isLogin ? { ...headerSet, 'content-type': 'application/x-www-form-urlencoded' } : headerSet,
body: isLogin ? new URLSearchParams(params).toString() : undefined,
})
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const login = (base: string, user: string, pass: string, headers: Record<string, string> = {}) =>
call(base, 'user/login', { user, pass }, undefined, headers)
function seed(...rows: [string, Role, string][]): void {
let users = readUsersFile(dir) ?? []
for (const [name, role, pass] of rows) users = addUser(users, name, role, pass)
writeUsersFile(dir, users)
}
describe('roles and capabilities', () => {
it('ranks the roles and answers capabilities', () => {
expect(requires('admin', 'operator')).toBe(true)
expect(requires('viewer', 'operator')).toBe(false)
expect(requires('apps-admin', 'operator')).toBe(true)
expect(can('viewer', 'read')).toBe(true)
expect(can('viewer', 'operate')).toBe(false)
expect(can('operator', 'operate')).toBe(true)
expect(can('operator', 'apps.write')).toBe(false)
expect(can('apps-admin', 'apps.write')).toBe(true)
expect(can('apps-admin', 'audit.read')).toBe(false)
for (const c of ['read', 'apps.write', 'operate', 'audit.read', 'admin'] as const) expect(can('admin', c)).toBe(true)
})
})
describe('passwords and the users file', () => {
it('hashes with scrypt and verifies', async () => {
const hash = hashPassword('correct horse')
expect(hash).toMatch(/^scrypt\$16384\$8\$1\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/)
expect(hash.split('$')[5].length).toBeGreaterThan(80)
expect(await verifyPassword('correct horse', hash)).toBe(true)
expect(await verifyPassword('wrong', hash)).toBe(false)
expect(await verifyPassword('x', 'not a hash')).toBe(false)
expect(hashPassword('correct horse')).not.toBe(hash)
})
it('parses and rejects bad files', () => {
const hash = hashPassword('longenough')
const ok = parseUsersFile(JSON.stringify({ users: [{ name: 'Alice', role: 'admin', hash, disabled: true }] }))
expect(ok).toEqual([{ name: 'Alice', role: 'admin', hash, disabled: true }])
expect(() => parseUsersFile('nope')).toThrow(/JSON/)
expect(() => parseUsersFile('{}')).toThrow(/users/)
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'root', hash }] }))).toThrow(/role/)
expect(() => parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash: 'x' }] }))).toThrow(/hash/)
expect(() =>
parseUsersFile(JSON.stringify({ users: [{ name: 'a', role: 'admin', hash }, { name: 'A', role: 'viewer', hash }] })),
).toThrow(/twice/)
})
it('edits users (the functions the CLI calls)', async () => {
let users = addUser([], 'bob', 'viewer', 'password1')
expect(() => addUser(users, 'BOB', 'viewer', 'password1')).toThrow(/exists/)
expect(() => addUser(users, 'x', 'root', 'password1')).toThrow(/role/)
expect(() => addUser(users, 'x', 'viewer', 'short')).toThrow(/8/)
users = setRole(users, 'Bob', 'operator')
expect(users[0].role).toBe('operator')
const before = users[0].hash
users = setPassword(users, 'bob', 'password2')
expect(users[0].hash).not.toBe(before)
expect(await verifyPassword('password2', users[0].hash)).toBe(true)
users = setDisabled(users, 'bob', true)
expect(users[0].disabled).toBe(true)
users = setDisabled(users, 'bob', false)
expect('disabled' in users[0]).toBe(false)
expect(() => setRole(users, 'nobody', 'viewer')).toThrow(/No user/)
})
it('writes atomically, leaving no temp file', () => {
writeUsersFile(dir, addUser([], 'bob', 'viewer', 'password1'))
const file = path.join(dir, 'users.json')
expect(readUsersFile(dir)?.[0].name).toBe('bob')
expect(existsSync(`${file}.${process.pid}.tmp`)).toBe(false)
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
})
it('runs CLI commands', async () => {
const pw = async () => 'password1'
expect(await run(['list'], dir, pw)).toEqual(['No users.'])
await run(['add', 'alice', 'apps-admin'], dir, pw)
await run(['role', 'alice', 'operator'], dir, pw)
await run(['disable', 'alice'], dir, pw)
expect(await run(['list'], dir, pw)).toEqual(['alice\toperator\tdisabled'])
await run(['enable', 'alice'], dir, pw)
await run(['passwd', 'alice'], dir, async () => 'password2')
expect(await verifyPassword('password2', readUsersFile(dir)![0].hash)).toBe(true)
await expect(run(['bogus'], dir, pw)).rejects.toThrow(/Usage/)
await expect(run(['add', 'bob'], dir, pw)).rejects.toThrow(/role/)
})
})
describe('bootstrap', () => {
it('uses the environment user as admin when there is no file, and does not write it', async () => {
const users = Users.load(dir, { user: 'Root', password: 'secret' })
expect(existsSync(path.join(dir, 'users.json'))).toBe(false)
expect(users.notice).toMatch(/HUB_USER/)
expect((await users.authenticate('root', 'secret'))?.role).toBe('admin')
expect(await users.authenticate('root', 'nope')).toBeNull()
})
it('ignores the environment user when the file exists', async () => {
seed(['alice', 'viewer', 'password1'])
const users = Users.load(dir, { user: 'root', password: 'secret' })
expect(users.notice).toMatch(/ignored/)
expect(await users.authenticate('root', 'secret')).toBeNull()
expect((await users.authenticate('ALICE', 'password1'))?.role).toBe('viewer')
})
it('refuses to start with neither', () => {
expect(() => Users.load(dir, null)).toThrow(/No users/)
expect(() => createHub(parseConfig(env()))).toThrow(/No users/)
})
it('config reads no file and takes the optional variables', () => {
const c = parseConfig(env({ HUB_TRUST_PROXY: '1' }))
expect(c.bootstrap).toBeNull()
expect(c.dataDir).toBe(dir)
expect(c.trustProxy).toBe(true)
expect(c.servers[0].adminToken).toBe('admin-secret-a')
expect(parseConfig({ ...env(), HUB_DATA: '' }).dataDir).toBe('/data')
expect(parseConfig(env()).trustProxy).toBe(false)
})
})
describe('rate limit', () => {
it('locks after 5 failures, expires, and a success resets the user', () => {
let now = 1_000_000
const limiter = new LoginLimiter(() => now)
for (let i = 0; i < 4; i++) limiter.failure('1.1.1.1', 'Bob')
expect(limiter.lockedFor('1.1.1.1', 'bob')).toBe(0)
limiter.success('bob')
for (let i = 0; i < 4; i++) limiter.failure('2.2.2.2', 'bob')
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
limiter.failure('2.2.2.2', 'bob')
expect(limiter.lockedFor('3.3.3.3', 'BOB')).toBe(LOCK_MS)
expect(limiter.lockedFor('2.2.2.2', 'other')).toBe(LOCK_MS)
now += LOCK_MS + 1
expect(limiter.lockedFor('2.2.2.2', 'bob')).toBe(0)
expect(lockedMessage(LOCK_MS)).toBe('Too many failed logins. Try again in 15 minutes.')
expect(lockedMessage(1000)).toBe('Too many failed logins. Try again in 1 minute.')
})
it('forgets failures older than the window', () => {
let now = 0
const limiter = new LoginLimiter(() => now)
for (let i = 0; i < 4; i++) limiter.failure('ip', 'u')
now += 16 * 60_000
limiter.failure('ip', 'u')
expect(limiter.lockedFor('ip', 'u')).toBe(0)
})
it('locks the hub login with 429 and the message, even for the right password', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
for (let i = 0; i < 5; i++) expect((await login(base, 'alice', 'bad')).body.status).toBe('error')
const locked = await login(base, 'alice', 'password1')
expect(locked.code).toBe(429)
expect(locked.body).toEqual({ status: 'error', errorMessage: 'Too many failed logins. Try again in 15 minutes.' })
})
it('counts X-Forwarded-For only when the proxy is trusted', async () => {
seed(['alice', 'admin', 'password1'])
// Untrusted: the header is ignored, so five attempts from one socket lock that socket.
const plain = await start()
for (let i = 0; i < 5; i++) await login(plain, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
expect((await login(plain, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8' })).code).toBe(429)
// Trusted: each forwarded address has its own count.
const trusted = await start({ HUB_TRUST_PROXY: '1' })
for (let i = 0; i < 5; i++) await login(trusted, `u${i}`, 'bad', { 'x-forwarded-for': `9.9.9.${i}` })
expect((await login(trusted, 'alice', 'password1', { 'x-forwarded-for': '8.8.8.8, 10.0.0.1' })).code).toBe(200)
})
})
describe('audit', () => {
it('appends JSON lines, lists newest first and filters with before', () => {
let now = Date.parse('2026-01-01T00:00:00Z')
const audit = new Audit(dir, { now: () => now })
for (const action of ['one', 'two', 'three']) {
audit.log({ user: 'u', ip: 'i', action, outcome: 'ok' })
now += 1000
}
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n')
expect(lines).toHaveLength(3)
expect(JSON.parse(lines[0])).toMatchObject({ user: 'u', ip: 'i', action: 'one', outcome: 'ok' })
expect(audit.list().map((e) => e.action)).toEqual(['three', 'two', 'one'])
expect(audit.list(1).map((e) => e.action)).toEqual(['three'])
expect(audit.list(200, '2026-01-01T00:00:02.000Z').map((e) => e.action)).toEqual(['two', 'one'])
})
it('rotates once the file is too big and keeps one old file', () => {
const audit = new Audit(dir, { maxBytes: 600 })
for (let i = 0; i < 12; i++) audit.log({ user: 'u', ip: 'i', action: `a${i}`, outcome: 'ok' })
expect(existsSync(path.join(dir, 'audit.log.1'))).toBe(true)
expect(existsSync(path.join(dir, 'audit.log.2'))).toBe(false)
expect(statSync(path.join(dir, 'audit.log')).size).toBeLessThanOrEqual(600)
expect(audit.list(1)[0].action).toBe('a11')
})
it('falls back to memory when the folder is not writable', () => {
const file = path.join(dir, 'blocker')
writeFileSync(file, 'x')
const err = vi.spyOn(console, 'error').mockImplementation(() => {})
const audit = new Audit(path.join(file, 'sub'))
audit.log({ user: 'u', ip: 'i', action: 'x', outcome: 'ok' })
audit.log({ user: 'u', ip: 'i', action: 'y', outcome: 'ok' })
expect(err).toHaveBeenCalledTimes(1)
err.mockRestore()
expect(audit.inMemory).toBe(true)
expect(audit.list().map((e) => e.action)).toEqual(['y', 'x'])
})
it('records login, logout and refusals without passwords or tokens, and is for audit.read only', async () => {
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
const base = await start()
await login(base, 'alice', 'wrong-password-xyz')
const adminLogin = await login(base, 'alice', 'password1')
const admin = adminLogin.body.token as string
expect(adminLogin.body.hubRole).toBe('admin')
const viewer = (await login(base, 'vic', 'password2')).body.token as string
expect((await call(base, 'zones/delete', { zone: 'x', node: 'a' }, admin)).code).toBe(403)
expect((await call(base, 'hub/audit/list', {}, viewer)).code).toBe(403)
await call(base, 'user/logout', {}, viewer)
const list = await call(base, 'hub/audit/list', { limit: '50' }, admin)
expect(list.body.status).toBe('ok')
const entries = list.body.response.entries as Record<string, string>[]
const actions = entries.map((e) => `${e.action}:${e.outcome}`)
expect(actions).toEqual(expect.arrayContaining(['login:failed', 'login:ok', 'refused-write:refused', 'denied-read:refused', 'logout:ok']))
const refused = entries.find((e) => e.action === 'refused-write')
expect(refused).toMatchObject({ user: 'alice', role: 'admin', target: 'zones/delete', server: 'a' })
const ts = entries.map((e) => e.ts)
expect([...ts].sort().reverse()).toEqual(ts)
const raw = readFileSync(path.join(dir, 'audit.log'), 'utf8')
for (const secret of ['wrong-password-xyz', 'password1', 'password2', admin, viewer, 'token-a', 'admin-secret-a']) {
expect(raw).not.toContain(secret)
}
})
it('logs a locked attempt', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
for (let i = 0; i < 6; i++) await login(base, 'alice', 'bad')
const lines = readFileSync(path.join(dir, 'audit.log'), 'utf8').trim().split('\n').map((l) => JSON.parse(l))
expect(lines.filter((l) => l.outcome === 'failed')).toHaveLength(5)
expect(lines[5]).toMatchObject({ action: 'login', outcome: 'refused', detail: 'locked' })
})
})
describe('routes table', () => {
it('lists every old read endpoint as a read and hub/audit/list as audit.read', () => {
for (const p of [
'dashboard/stats/get', 'dashboard/stats/getTop', 'dashboard/metrics/json', 'apps/list', 'logs/query', 'logs/list',
'settings/get', 'user/checkForUpdate', 'admin/cluster/state',
]) expect(ROUTES[p]).toEqual({ kind: 'read', capability: 'read' })
expect(routeFor('hub/audit/list')).toEqual({ kind: 'read', capability: 'audit.read' })
expect(routeFor('zones/delete')).toBeUndefined()
expect(routeFor('constructor')).toBeUndefined()
expect(routeFor('hub/nothing')).toBeUndefined()
})
})
describe('sessions by role', () => {
it('puts hubRole in the body and in info', async () => {
seed(['vic', 'viewer', 'password2'], ['op', 'operator', 'password3'])
const base = await start()
const a = await login(base, 'vic', 'password2')
expect(a.body.hubRole).toBe('viewer')
expect(a.body.info.hubRole).toBe('viewer')
expect(a.body.info.permissions.Dashboard.canView).toBe(true)
const b = await call(base, 'user/session/get', {}, (await login(base, 'op', 'password3')).body.token)
expect(b.body.hubRole).toBe('operator')
expect(b.body.info.hubRole).toBe('operator')
})
it('drops the sessions of a disabled user and refuses a new login', async () => {
seed(['alice', 'admin', 'password1'], ['vic', 'viewer', 'password2'])
const base = await start()
const vic = (await login(base, 'vic', 'password2')).body.token as string
const alice = (await login(base, 'alice', 'password1')).body.token as string
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('ok')
// The file changes: vic is disabled, alice is untouched.
const users = setDisabled(readUsersFile(dir)!, 'vic', true)
writeUsersFile(dir, users)
const future = new Date(Date.now() + 5000)
const { utimesSync } = await import('node:fs')
utimesSync(path.join(dir, 'users.json'), future, future)
expect((await call(base, 'user/session/get', {}, vic)).body.status).toBe('invalid-token')
expect((await call(base, 'user/session/get', {}, alice)).body.status).toBe('ok')
expect((await login(base, 'vic', 'password2')).body.status).toBe('error')
})
it('drops sessions when the role or password changes', async () => {
seed(['alice', 'admin', 'password1'])
const base = await start()
const t = (await login(base, 'alice', 'password1')).body.token as string
writeUsersFile(dir, setRole(readUsersFile(dir)!, 'alice', 'viewer'))
const { utimesSync } = await import('node:fs')
const future = new Date(Date.now() + 5000)
utimesSync(path.join(dir, 'users.json'), future, future)
expect((await call(base, 'user/session/get', {}, t)).body.status).toBe('invalid-token')
const again = await login(base, 'ALICE', 'password1')
expect(again.body.hubRole).toBe('viewer')
})
})
+6 -2
View File
@@ -77,6 +77,7 @@ let b: Fake
let hub: http.Server
let base: string
let staticDir: string
let dataDir: string
function makeConfig(over: Record<string, string> = {}) {
return parseConfig({
@@ -86,6 +87,7 @@ function makeConfig(over: Record<string, string> = {}) {
HUB_USER: 'admin',
HUB_PASSWORD: 'secret',
HUB_STATIC: staticDir,
HUB_DATA: dataDir,
HUB_CACHE_SECONDS: '15',
HUB_TIMEOUT_MS: '2000',
...over,
@@ -117,6 +119,7 @@ async function login(): Promise<string> {
beforeAll(async () => {
a = await fakeUpstream(canned(100, ['2026-01-01T10:00:04Z', '2026-01-01T10:00:02Z']))
b = await fakeUpstream(canned(300, ['2026-01-01T10:00:03Z', '2026-01-01T10:00:01Z']))
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-static-'))
writeFileSync(path.join(staticDir, 'index.html'), '<html>root</html>')
mkdirSync(path.join(staticDir, 'logs', 'query-logs'), { recursive: true })
@@ -129,6 +132,7 @@ afterAll(() => {
a.server.close()
b.server.close()
rmSync(staticDir, { recursive: true, force: true })
rmSync(dataDir, { recursive: true, force: true })
})
beforeEach(async () => {
@@ -142,9 +146,9 @@ beforeEach(async () => {
})
describe('config', () => {
it('refuses to start without credentials or a token', () => {
it('refuses to start with half the credentials or without a token', () => {
const env = { HUB_SERVERS: 'a=http://x', A_TOKEN: 't' }
expect(() => parseConfig(env)).toThrow(/HUB_USER/)
expect(() => parseConfig({ ...env, HUB_USER: 'u' })).toThrow(/HUB_USER/)
expect(() => parseConfig({ ...env, HUB_USER: 'u', HUB_PASSWORD: 'p', A_TOKEN: '' })).toThrow(/A_TOKEN/)
})
+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(', ')}`)
})
}
+40 -33
View File
@@ -1,18 +1,23 @@
/*
The hub's own sign-in. One user from the environment, sessions kept in memory with an
idle expiry, and the `info` object the console reads from `user/login` and
`user/session/get` (flat, as the real server sends it).
The hub's own sign-in. Sessions are kept in memory with an idle expiry; each remembers who
signed in and what that user looked like (role, hash, disabled flag) at that moment, so a
change to the users file ends the sessions of the users it touched. `info` is the object
the console reads from `user/login` and `user/session/get` (flat, as the real server sends it).
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { randomBytes } from 'node:crypto'
import { HUB_VERSION, type HubConfig } from './config.ts'
import { signature, type Role, type UserRecord, type Users } from './users.ts'
export const IDLE_MS = 30 * 60_000
/* Compared as hashes so the lengths always match and the time does not depend on the content. */
function sameSecret(a: string, b: string): boolean {
const ha = createHash('sha256').update(a).digest()
const hb = createHash('sha256').update(b).digest()
return timingSafeEqual(ha, hb)
export interface Identity {
user: string
role: Role
}
interface Session extends Identity {
last: number
sig: string
}
/*
@@ -28,39 +33,39 @@ const VIEWABLE = new Set(['Dashboard', 'Logs'])
export class Sessions {
#config: HubConfig
#users: Users
#now: () => number
#started: string
#seen = new Map<string, number>()
#seen = new Map<string, Session>()
constructor(config: HubConfig, now: () => number = Date.now) {
constructor(config: HubConfig, users: Users, now: () => number = Date.now) {
this.#config = config
this.#users = users
this.#now = now
this.#started = new Date(now()).toISOString()
}
/** A new session token, or null when the credentials are wrong. */
login(user: string, password: string): string | null {
// Both are always compared, so a wrong user and a wrong password cost the same.
const userOk = sameSecret(user.trim().toLowerCase(), this.#config.user.toLowerCase())
const passOk = sameSecret(password, this.#config.password)
if (!(userOk && passOk)) return null
/** Opens a session for a user already authenticated by `Users.authenticate`. */
open(user: UserRecord): string {
this.#sweep()
const token = randomBytes(32).toString('hex')
this.#seen.set(token, this.#now())
this.#seen.set(token, { user: user.name, role: user.role, last: this.#now(), sig: signature(user) })
return token
}
/** True while the token is valid; each use extends it. */
touch(token: string | null | undefined): boolean {
if (!token) return false
const last = this.#seen.get(token)
if (last === undefined) return false
if (this.#now() - last > IDLE_MS) {
/* The session's identity while it is valid (extending it), else null. A user who was removed,
disabled or edited since sign-in ends the session. */
touch(token: string | null | undefined): Identity | null {
if (!token) return null
const session = this.#seen.get(token)
if (session === undefined) return null
const record = this.#users.find(session.user)
if (this.#now() - session.last > IDLE_MS || record === undefined || signature(record) !== session.sig) {
this.#seen.delete(token)
return false
return null
}
this.#seen.set(token, this.#now())
return true
session.last = this.#now()
return { user: session.user, role: session.role }
}
logout(token: string): void {
@@ -69,10 +74,10 @@ export class Sessions {
#sweep(): void {
const now = this.#now()
for (const [token, last] of this.#seen) if (now - last > IDLE_MS) this.#seen.delete(token)
for (const [token, session] of this.#seen) if (now - session.last > IDLE_MS) this.#seen.delete(token)
}
info() {
info(role: Role) {
const { servers, domain } = this.#config
return {
version: HUB_VERSION,
@@ -92,20 +97,22 @@ export class Sessions {
useSoaSerialDateScheme: true,
dnssecValidation: true,
hub: true,
hubRole: role,
}
}
/** The flat body of `user/login` and `user/session/get`. */
body(token: string) {
body(token: string, identity: Identity) {
return {
status: 'ok',
token,
username: this.#config.user,
displayName: this.#config.user,
username: identity.user,
displayName: identity.user,
type: 'Local',
isSsoUser: false,
totpEnabled: false,
info: this.info(),
hubRole: identity.role,
info: this.info(identity.role),
}
}
}
+134
View File
@@ -0,0 +1,134 @@
/*
Manage the hub's users file.
node --experimental-strip-types hub/users-cli.ts add <name> <role>
node --experimental-strip-types hub/users-cli.ts passwd <name>
node --experimental-strip-types hub/users-cli.ts role <name> <role>
node --experimental-strip-types hub/users-cli.ts disable <name>
node --experimental-strip-types hub/users-cli.ts enable <name>
node --experimental-strip-types hub/users-cli.ts list
The file is `${HUB_DATA}/users.json` (default /data). On a terminal the password is asked
twice without echo; in a script, put it in HUB_NEW_PASSWORD. In the container:
docker exec -it technitium-hub node --experimental-strip-types /app/hub/users-cli.ts add alice apps-admin
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
ROLES,
addUser,
describeUsers,
readUsersFile,
setDisabled,
setPassword,
setRole,
writeUsersFile,
type UserRecord,
} from './users.ts'
const USAGE = `Usage: users-cli <command>
add <name> <role> roles: ${ROLES.join(', ')}
passwd <name>
role <name> <role>
disable <name>
enable <name>
list
The password is asked twice, or taken from HUB_NEW_PASSWORD.`
type Env = Record<string, string | undefined>
/* Reads one line from the terminal without echoing it. */
function askHidden(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const stdin = process.stdin
process.stderr.write(prompt)
let line = ''
stdin.setRawMode(true)
stdin.resume()
stdin.setEncoding('utf8')
const done = (): void => {
stdin.setRawMode(false)
stdin.pause()
stdin.off('data', onData)
process.stderr.write('\n')
}
const onData = (chunk: string): void => {
for (const ch of chunk) {
if (ch === '\r' || ch === '\n') {
done()
resolve(line)
return
}
if (ch === '\u0003') {
done()
reject(new Error('Cancelled.'))
return
}
if (ch === '\u007f' || ch === '\b') line = line.slice(0, -1)
else line += ch
}
}
stdin.on('data', onData)
})
}
async function newPassword(env: Env): Promise<string> {
if (env.HUB_NEW_PASSWORD !== undefined && env.HUB_NEW_PASSWORD !== '') return env.HUB_NEW_PASSWORD
if (!process.stdin.isTTY) throw new Error('No terminal: set HUB_NEW_PASSWORD.')
const first = await askHidden('New password: ')
const second = await askHidden('Repeat it: ')
if (first !== second) throw new Error('The passwords differ.')
return first
}
/*
Runs one command against the users in `dir` and returns the lines to print. `password`
supplies the password for add and passwd, so the tests need no terminal.
*/
export async function run(
args: string[],
dir: string,
password: () => Promise<string>,
): Promise<string[]> {
const [command, name, extra] = args
const users: UserRecord[] = readUsersFile(dir) ?? []
const need = (value: string | undefined, what: string): string => {
if (value === undefined || value === '') throw new Error(`Missing ${what}.\n${USAGE}`)
return value
}
switch (command) {
case 'add':
writeUsersFile(dir, addUser(users, need(name, 'name'), need(extra, 'role'), await password()))
return [`Added ${name} (${extra}).`]
case 'passwd':
writeUsersFile(dir, setPassword(users, need(name, 'name'), await password()))
return [`Password of ${name} changed. Their sessions end on their next request.`]
case 'role':
writeUsersFile(dir, setRole(users, need(name, 'name'), need(extra, 'role')))
return [`${name} is now ${extra}.`]
case 'disable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), true))
return [`Disabled ${name}.`]
case 'enable':
writeUsersFile(dir, setDisabled(users, need(name, 'name'), false))
return [`Enabled ${name}.`]
case 'list':
return users.length === 0 ? ['No users.'] : describeUsers(users)
default:
throw new Error(USAGE)
}
}
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const dir = process.env.HUB_DATA?.trim() || '/data'
run(process.argv.slice(2), dir, () => newPassword(process.env)).then(
(lines) => {
for (const line of lines) console.log(line)
},
(e: unknown) => {
console.error(`users: ${e instanceof Error ? e.message : String(e)}`)
process.exit(1)
},
)
}
+299
View File
@@ -0,0 +1,299 @@
/*
The hub's own accounts: roles, password hashes and the users file.
`${HUB_DATA}/users.json` holds `{ users: [{ name, role, hash, disabled? }] }`, where hash is
`scrypt$N$r$p$saltB64$keyB64`. The pure functions below are what the CLI (users-cli.ts)
and the tests call; `Users` is what the running hub asks.
*/
import { randomBytes, scrypt, scryptSync, timingSafeEqual } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, chmodSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { Bootstrap } from './config.ts'
export type Role = 'viewer' | 'operator' | 'apps-admin' | 'admin'
export type Capability = 'read' | 'apps.write' | 'operate' | 'audit.read' | 'admin'
export const ROLES: readonly Role[] = ['viewer', 'operator', 'apps-admin', 'admin']
/* A simple rank. operator and apps-admin are siblings: use `can` to ask about a capability. */
const RANK: Record<Role, number> = { viewer: 1, operator: 2, 'apps-admin': 2, admin: 3 }
const CAPABILITIES: Record<Role, readonly Capability[]> = {
viewer: ['read'],
operator: ['read', 'operate'],
'apps-admin': ['read', 'apps.write'],
admin: ['read', 'apps.write', 'operate', 'audit.read', 'admin'],
}
export const isRole = (value: unknown): value is Role => typeof value === 'string' && (ROLES as readonly string[]).includes(value)
export const rank = (role: Role): number => RANK[role]
/** True when `role` ranks at least as high as `min`. */
export const requires = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]
export const can = (role: Role, capability: Capability): boolean => CAPABILITIES[role].includes(capability)
export interface UserRecord {
name: string
role: Role
hash: string
disabled?: boolean
}
/* ---- Passwords ---- */
const N = 16384
const R = 8
const P = 1
const KEYLEN = 64
export function hashPassword(password: string): string {
const salt = randomBytes(16)
const key = scryptSync(password, salt, KEYLEN, { N, r: R, p: P })
return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}`
}
interface Parsed {
n: number
r: number
p: number
salt: Buffer
key: Buffer
}
function parseHash(stored: string): Parsed | null {
const parts = stored.split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return null
const [n, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]
if (![n, r, p].every((v) => Number.isInteger(v) && v > 0) || n > 1 << 20 || r > 32 || p > 16) return null
const salt = Buffer.from(parts[4], 'base64')
const key = Buffer.from(parts[5], 'base64')
return salt.length === 0 || key.length === 0 ? null : { n, r, p, salt, key }
}
export const isHash = (stored: string): boolean => parseHash(stored) !== null
function derive(password: string, h: Parsed): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, h.salt, h.key.length, { N: h.n, r: h.r, p: h.p, maxmem: 256 * h.n * h.r }, (err, key) =>
err ? reject(err) : resolve(key),
)
})
}
/** Checks a password against a stored hash; false for a malformed hash. Does not block the loop. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const h = parseHash(stored)
if (h === null) return false
try {
return timingSafeEqual(await derive(password, h), h.key)
} catch {
return false
}
}
/* ---- The file: parse and pure edits ---- */
const NAME = /^[A-Za-z0-9._@-]{1,64}$/
export function validateName(name: string): void {
if (!NAME.test(name)) throw new Error('A user name is 1 to 64 letters, digits, . _ @ -.')
}
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
export function parseUsersFile(text: string): UserRecord[] {
let data: unknown
try {
data = JSON.parse(text)
} catch {
throw new Error('users.json is not valid JSON.')
}
const list = (data as { users?: unknown } | null)?.users
if (!Array.isArray(list)) throw new Error('users.json must be {"users": [...]}.')
const users: UserRecord[] = []
for (const item of list as Record<string, unknown>[]) {
const name = item?.name
if (typeof name !== 'string' || !NAME.test(name)) throw new Error(`users.json: invalid user name ${JSON.stringify(name)}.`)
if (!isRole(item.role)) throw new Error(`users.json: user "${name}" has an unknown role.`)
if (typeof item.hash !== 'string' || !isHash(item.hash)) throw new Error(`users.json: user "${name}" has no valid hash.`)
if (users.some((u) => same(u.name, name))) throw new Error(`users.json lists "${name}" twice.`)
const record: UserRecord = { name, role: item.role, hash: item.hash }
if (item.disabled === true) record.disabled = true
users.push(record)
}
return users
}
export const serializeUsers = (users: UserRecord[]): string => JSON.stringify({ users }, null, 2) + '\n'
export const findUser = (users: UserRecord[], name: string): UserRecord | undefined => users.find((u) => same(u.name, name))
function must(users: UserRecord[], name: string): UserRecord {
const found = findUser(users, name)
if (found === undefined) throw new Error(`No user "${name}".`)
return found
}
export function checkPassword(password: string): void {
if (password.length < 8) throw new Error('The password must be at least 8 characters.')
}
function checkRole(role: string): Role {
if (!isRole(role)) throw new Error(`The role must be one of: ${ROLES.join(', ')}.`)
return role
}
/* Each edit returns a new list; the caller writes it. */
export function addUser(users: UserRecord[], name: string, role: string, password: string): UserRecord[] {
validateName(name)
const checked = checkRole(role)
checkPassword(password)
if (findUser(users, name) !== undefined) throw new Error(`User "${name}" already exists.`)
return [...users, { name, role: checked, hash: hashPassword(password) }]
}
export function setPassword(users: UserRecord[], name: string, password: string): UserRecord[] {
checkPassword(password)
const target = must(users, name)
return users.map((u) => (u === target ? { ...u, hash: hashPassword(password) } : u))
}
export function setRole(users: UserRecord[], name: string, role: string): UserRecord[] {
const checked = checkRole(role)
const target = must(users, name)
return users.map((u) => (u === target ? { ...u, role: checked } : u))
}
export function setDisabled(users: UserRecord[], name: string, disabled: boolean): UserRecord[] {
const target = must(users, name)
return users.map((u) => {
if (u !== target) return u
const { disabled: _old, ...rest } = u
return disabled ? { ...rest, disabled: true } : rest
})
}
/** One line per user, never the hash. */
export const describeUsers = (users: UserRecord[]): string[] =>
users.map((u) => `${u.name}\t${u.role}${u.disabled === true ? '\tdisabled' : ''}`)
/* ---- The file: disk ---- */
export const usersPath = (dir: string): string => path.join(dir, 'users.json')
/** The users, or null when the file does not exist. Throws when it exists but is invalid. */
export function readUsersFile(dir: string): UserRecord[] | null {
const file = usersPath(dir)
if (!existsSync(file)) return null
return parseUsersFile(readFileSync(file, 'utf8'))
}
/** Writes a temp file and renames it over users.json; mode 0600 where the platform honours it. */
export function writeUsersFile(dir: string, users: UserRecord[]): void {
mkdirSync(dir, { recursive: true })
const file = usersPath(dir)
const temp = `${file}.${process.pid}.tmp`
try {
writeFileSync(temp, serializeUsers(users), { mode: 0o600 })
try {
chmodSync(temp, 0o600)
} catch {
// Not every platform has modes.
}
renameSync(temp, file)
} catch (e) {
rmSync(temp, { force: true })
throw e
}
}
/* ---- The running hub's view ---- */
/* Compared when the role, hash or disabled flag changes, so a session can tell its user was edited. */
export const signature = (u: UserRecord): string => `${u.role}|${u.hash}|${u.disabled === true ? 'x' : ''}`
export class Users {
#dir: string
#users: UserRecord[]
#fromFile: boolean
#mtime = 0
#warned = false
/** A one-line notice for the start-up log, or null. */
readonly notice: string | null
/* Checked when a name is unknown, so an unknown name costs the same as a wrong password. */
#dummy = hashPassword(randomBytes(8).toString('hex'))
private constructor(dir: string, users: UserRecord[], fromFile: boolean, notice: string | null) {
this.#dir = dir
this.#users = users
this.#fromFile = fromFile
this.notice = notice
if (fromFile) this.#mtime = this.#stat()
}
/*
users.json when it exists (HUB_USER and HUB_PASSWORD are then ignored); else the one admin
from the environment; else an error. The file is never written here.
*/
static load(dir: string, bootstrap: Bootstrap | null): Users {
const fromFile = readUsersFile(dir)
if (fromFile !== null) {
const notice =
bootstrap === null ? null : `${usersPath(dir)} exists: HUB_USER and HUB_PASSWORD are ignored.`
return new Users(dir, fromFile, true, notice)
}
if (bootstrap === null) {
throw new Error(
`No users: ${usersPath(dir)} does not exist and HUB_USER / HUB_PASSWORD are not set. ` +
'Create a user with: node --experimental-strip-types hub/users-cli.ts add <name> admin',
)
}
validateName(bootstrap.user)
const record: UserRecord = { name: bootstrap.user, role: 'admin', hash: hashPassword(bootstrap.password) }
return new Users(dir, [record], false, `No ${usersPath(dir)}: the single admin from HUB_USER / HUB_PASSWORD is in use.`)
}
#stat(): number {
try {
return statSync(usersPath(this.#dir)).mtimeMs
} catch {
return 0
}
}
/* Cheap enough to run on every request: one stat. A broken file keeps the last good list. */
refresh(): void {
if (!this.#fromFile) return
const mtime = this.#stat()
if (mtime === this.#mtime) return
this.#mtime = mtime
try {
const next = readUsersFile(this.#dir)
this.#users = next ?? []
this.#warned = false
} catch (e) {
if (!this.#warned) console.error(`hub: ${e instanceof Error ? e.message : String(e)} Keeping the previous users.`)
this.#warned = true
}
}
find(name: string): UserRecord | undefined {
this.refresh()
return findUser(this.#users, name)
}
/** The enabled user when the password is right, else null. Costs the same either way. */
async authenticate(name: string, password: string): Promise<UserRecord | null> {
const user = this.find(name.trim())
const good = await verifyPassword(password, user?.hash ?? this.#dummy)
return user !== undefined && good && user.disabled !== true ? user : null
}
/** True when the user was found and is disabled (for the audit log only). */
isDisabled(name: string): boolean {
return this.find(name.trim())?.disabled === true
}
}