Files
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:37 +07:00

377 lines
17 KiB
TypeScript

// @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, 'admin/users/delete', { user: '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: 'admin/users/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',
'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('logs/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')
})
})