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]>
377 lines
17 KiB
TypeScript
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')
|
|
})
|
|
})
|