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>
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, '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')
|
|
})
|
|
})
|