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:
1 parent
042cb116ea
commit
0ef2d53a65
17 files changed
+1497
-79
No files matched your search
+54
-6
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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'}.`
|
||||
}
|
||||
@@ -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/')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user