Files
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:19 +07:00

440 lines
21 KiB
TypeScript

// @vitest-environment node
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import { acceptServerConfig } from './apps-sync.ts'
import { addUser, writeUsersFile } from './users.ts'
const json = (o: unknown): string => JSON.stringify(o, null, 2)
interface Fake {
server: http.Server
url: string
apps: Record<string, { version: string; config: string | null }>
calls: { path: string; token: string; method: string }[]
rejectSet: string | null
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', apps: {}, calls: [], rejectSet: null }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
const url = new URL(req.url ?? '/', 'http://fake')
const apiPath = url.pathname.replace(/^\/api\//, '')
const token = url.searchParams.get('token') ?? ''
const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
f.calls.push({ path: apiPath, token, method: req.method ?? '' })
const name = url.searchParams.get('name') ?? form.get('name') ?? ''
res.setHeader('content-type', 'application/json')
const ok = (response: unknown = {}) => res.end(JSON.stringify({ status: 'ok', response }))
const error = (m: string) => res.end(JSON.stringify({ status: 'error', errorMessage: m }))
if (apiPath === 'apps/list') return ok({ apps: Object.entries(f.apps).map(([n, a]) => ({ name: n, version: a.version })) })
if (apiPath === 'apps/config/get') return f.apps[name] ? ok({ config: f.apps[name].config }) : error('not found')
if (apiPath === 'apps/config/set') {
if (!token.startsWith('admin-')) return error('Access was denied.')
if (f.rejectSet !== null) return error(f.rejectSet)
f.apps[name].config = form.get('config')
return ok()
}
ok()
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const V1 = json({ ttl: 30, db: '/base.db' })
const V2 = json({ ttl: 45, db: '/base.db' })
function reset(): void {
for (const f of [a, b]) {
f.calls = []
f.rejectSet = null
}
a.apps = { Blocker: { version: '1.0', config: V1 } }
b.apps = { Blocker: { version: '1.0', config: V1 } }
}
async function start(dir: string = dataDir): Promise<void> {
hub?.close()
hub = createHub(
parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'read-a',
B_TOKEN: 'read-b',
A_ADMIN_TOKEN: 'admin-a',
B_ADMIN_TOKEN: 'admin-b',
HUB_DATA: dir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
HUB_DRIFT_MINUTES: '0',
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
for (const role of ['viewer', 'apps-admin', 'admin']) {
const res = await fetch(`${base}/api/user/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: `user=${role}&pass=password-${role}`,
})
tokens[role] = ((await res.json()) as { token: string }).token
}
}
async function raw(role: string, apiPath: string, init: { params?: Record<string, string>; json?: unknown; form?: Record<string, string> } = {}) {
const headers: Record<string, string> = { authorization: `Bearer ${tokens[role]}` }
let body: string | undefined
if (init.json !== undefined) {
headers['content-type'] = 'application/json'
body = JSON.stringify(init.json)
} else if (init.form !== undefined) {
headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(init.form).toString()
}
return fetch(`${base}/api/${apiPath}?${new URLSearchParams(init.params ?? {})}`, { method: body === undefined ? 'GET' : 'POST', headers, body })
}
async function api(role: string, apiPath: string, init: Parameters<typeof raw>[2] = {}) {
const res = await raw(role, apiPath, init)
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const dirOf = (d: string = dataDir) => path.join(d, 'apps', 'Blocker')
const historyDir = (d: string = dataDir) => path.join(dirOf(d), 'history')
const writes = (f: Fake) => f.calls.filter((c) => c.path === 'apps/config/set')
const list = async (role = 'viewer') => (await api(role, 'hub/apps/history/list', { params: { name: 'Blocker' } })).body.response.entries as Record<string, any>[]
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-hist-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
beforeEach(async () => {
reset()
rmSync(path.join(dataDir, 'apps'), { recursive: true, force: true })
rmSync(path.join(dataDir, 'audit.log'), { force: true })
rmSync(path.join(dataDir, 'drift.json'), { force: true })
await start()
})
afterEach(() => {
hub?.close()
})
const post = (role: string, p: string, body: Record<string, unknown>) => api(role, p, { json: body })
const importFrom = (server: string) => post('apps-admin', 'hub/apps/baseline/import', { name: 'Blocker', server })
const saveBaseline = (text: string) => post('apps-admin', 'hub/apps/baseline/save', { name: 'Blocker', text })
describe('history', () => {
it('keeps the old baseline text on every save and import, and lists it without the text', async () => {
await importFrom('a')
expect(await list()).toEqual([])
await saveBaseline(V2)
await importFrom('b')
const entries = await list()
expect(entries).toHaveLength(2)
expect(entries[0]).toMatchObject({ server: 'baseline', kind: 'before-save', size: V2.length })
expect(entries[0].at).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/)
expect(entries[0].hash).toMatch(/^[0-9a-f]{16}$/)
expect(entries[0].text).toBeUndefined()
expect(entries[1].size).toBe(V1.length)
expect(entries[0].id > entries[1].id).toBe(true)
})
it('does not add an entry when the baseline text did not change', async () => {
await importFrom('a')
await saveBaseline(V1)
expect(await list()).toEqual([])
})
it('reads one entry by the id the listing gives and refuses anything else', async () => {
await importFrom('a')
await saveBaseline(V2)
const [entry] = await list()
const got = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })
expect(got.body.response.text).toBe(V1)
for (const id of ['../../../users.json', '..\\..\\users.json', entry.id.replace('2', '3'), entry.id + '/', '/etc/passwd', '', `../history/${entry.id}`, 'baseline.json']) {
const r = await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id } })
expect([id, r.code]).toEqual([id, 400])
}
// The app name cannot walk out either.
const r = await api('viewer', 'hub/apps/history/get', { params: { name: '../..', id: 'users.json' } })
expect(r.code).toBe(400)
})
it('ignores files in the folder that are not history entries', async () => {
await importFrom('a')
mkdirSync(historyDir(), { recursive: true })
writeFileSync(path.join(historyDir(), 'notes.txt'), 'x')
expect(await list()).toEqual([])
})
it('shows what a saved version would change on a server', async () => {
await importFrom('a')
await saveBaseline(V2)
const [entry] = await list()
const r = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'b' } })
expect(r.body.response).toMatchObject({ server: 'b', writable: true, unchanged: true, current: V1 })
a.apps.Blocker.config = V2
const d = await api('viewer', 'hub/apps/history/diff', { params: { name: 'Blocker', id: entry.id, server: 'a' } })
expect(d.body.response.changes.changes.map((c: any) => [c.path, c.a, c.b])).toEqual([['ttl', 45, 30]])
expect(d.body.response.unchanged).toBe(false)
})
it('keeps at most 50 entries per app', async () => {
await importFrom('a')
for (let i = 0; i < 55; i++) await saveBaseline(json({ n: i }))
expect((await list()).length).toBe(50)
})
it('also records the config a single-server set replaced', async () => {
await api('apps-admin', 'apps/config/set', { params: { node: 'a' }, json: { name: 'Blocker', config: '{}' } })
const entries = await list()
expect(entries[0]).toMatchObject({ server: 'a', kind: 'before-set' })
})
})
describe('rollback and restore', () => {
async function twoVersions() {
// Both servers were changed by the hub once: their old config (V1) is in the history.
await importFrom('a')
await saveBaseline(V2)
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['a', 'b'] })
return (await list()).filter((e) => e.kind === 'before-apply')
}
it('rolls a version back to the chosen servers, one result each, history first, verified', async () => {
const applied = await twoVersions()
expect(applied.map((e) => e.server).sort()).toEqual(['a', 'b'])
expect(a.apps.Blocker.config).toBe(V2)
const entry = applied.find((e) => e.server === 'a') as Record<string, any>
b.rejectSet = 'Disk full.'
const r = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a', 'b'] })
expect(r.body.response.results).toEqual([
{ server: 'a', ok: true, message: 'Applied and verified.' },
{ server: 'b', ok: false, message: 'Disk full.' },
])
expect(a.apps.Blocker.config).toBe(V1)
expect(b.apps.Blocker.config).toBe(V2)
// The config rolled away from is kept too.
const files = readdirSync(historyDir()).filter((f) => f.endsWith('__a__before-apply.txt'))
expect(files).toHaveLength(2)
expect(writes(a).at(-1)?.token).toBe('admin-a')
})
it('refuses a rollback when the config changed since the preview, and for an old baseline entry', async () => {
const applied = await twoVersions()
const entry = applied[0]
const stale = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'], expect: { a: 'deadbeefdeadbeef' } })
expect(stale.body.response.results[0].message).toMatch(/changed on the server since the preview/)
const baselineEntry = (await list()).find((e) => e.kind === 'before-save') as Record<string, any>
const refused = await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: baselineEntry.id, targets: ['a'] })
expect(refused.code).toBe(400)
expect(refused.body.errorMessage).toMatch(/Restore it as the baseline/)
})
it('restores an old baseline as the baseline, keeping the current one in the history', async () => {
await importFrom('a')
await saveBaseline(V2)
const old = (await list())[0]
const r = await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: old.id })
expect(r.body.response.baseline.text).toBe(V1)
const entries = await list()
expect(entries[0]).toMatchObject({ kind: 'before-save', size: V2.length })
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: 'nope' })).code).toBe(400)
// A server entry is not a baseline.
b.apps.Blocker.config = V2
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
const serverEntry = (await list()).find((e) => e.kind === 'before-apply') as Record<string, any>
expect((await post('apps-admin', 'hub/apps/baseline/restore', { name: 'Blocker', id: serverEntry.id })).code).toBe(400)
})
it('lets a viewer look but not roll back or restore', async () => {
await twoVersions()
const [entry] = await list()
expect((await post('viewer', 'hub/apps/history/rollback', { name: 'Blocker', id: entry.id, targets: ['a'] })).code).toBe(403)
expect((await post('viewer', 'hub/apps/baseline/restore', { name: 'Blocker', id: entry.id })).code).toBe(403)
expect((await api('viewer', 'hub/apps/history/get', { params: { name: 'Blocker', id: entry.id } })).code).toBe(200)
})
it('audits rollbacks and restores without any config text', async () => {
const applied = await twoVersions()
await post('apps-admin', 'hub/apps/history/rollback', { name: 'Blocker', id: applied[0].id, targets: ['a'] })
const text = readFileSync(path.join(dataDir, 'audit.log'), 'utf8')
expect(text).toContain('apps.history-rollback')
for (const secret of ['/base.db', 'admin-a', 'admin-b']) expect(text).not.toContain(secret)
})
})
describe('drift over the API', () => {
it('counts a drifted server in the session, shows it in drift/get and clears it when fixed', async () => {
await importFrom('a')
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
expect((await post('viewer', 'hub/drift/check', {})).code).toBe(403)
const checked = await post('apps-admin', 'hub/drift/check', {})
expect(checked.body.response.apps).toMatchObject([{ kind: 'app', app: 'Blocker', name: 'Blocker', server: 'b', status: 'differs' }])
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
const got = await api('viewer', 'hub/drift/get')
expect(got.body.response.apps).toHaveLength(1)
expect(got.body.response.checking).toBe(false)
// Revert to the baseline is the sync apply on that server.
await post('apps-admin', 'hub/apps/config/apply', { name: 'Blocker', targets: ['b'] })
await post('apps-admin', 'hub/drift/check', {})
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(0)
expect((await api('viewer', 'hub/drift/get')).body.response.apps).toEqual([])
expect(readFileSync(path.join(dataDir, 'drift.json'), 'utf8')).toContain('"apps": []')
})
it('accepts a drifted config as the baseline', async () => {
await importFrom('a')
b.apps.Blocker.config = json({ ttl: 99, db: '/base.db' })
const r = await post('apps-admin', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })
expect(JSON.parse(r.body.response.baseline.text)).toEqual({ ttl: 99, db: '/base.db' })
expect(r.body.response.baseline.importedFrom).toBe('b')
expect((await list())[0]).toMatchObject({ server: 'baseline', kind: 'before-save' })
expect((await api('viewer', 'user/session/get')).body.info.hubDrift).toBe(1)
expect((await post('viewer', 'hub/apps/drift/accept', { name: 'Blocker', server: 'b' })).code).toBe(403)
})
it('on accept keeps local fields out of the baseline and moves the server values into the overrides', () => {
const out = acceptServerConfig(
json({ ttl: 99, db: '/b.db' }),
json({ ttl: 30, db: '/base.db' }),
{ paths: ['db'], values: { a: { db: '/a.db' } } },
'b',
)
expect(JSON.parse(out.baselineText)).toEqual({ ttl: 99, db: '/base.db' })
expect(out.overrides.values).toEqual({ a: { db: '/a.db' }, b: { db: '/b.db' } })
expect(() => acceptServerConfig('plain', null, { paths: ['db'], values: {} }, 'b')).toThrow(/plain text/)
})
})
describe('export and import of baselines', () => {
async function setup() {
await importFrom('a')
await post('apps-admin', 'hub/apps/overrides/save', { name: 'Blocker', paths: ['db'], values: JSON.stringify({ a: { db: '/a.db' }, b: { db: '/b.db' } }) })
}
it('exports one file with every baseline and its local fields, as a download', async () => {
await setup()
const res = await raw('apps-admin', 'hub/apps/export')
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="hub-baselines-\d{4}-\d\d-\d\d\.json"$/)
const file = (await res.json()) as Record<string, any>
expect(file).toMatchObject({ hub: 'technitium-hub', version: 1 })
expect(file.apps).toHaveLength(1)
expect(file.apps[0]).toMatchObject({ app: 'Blocker', baseline: { text: V1 }, overrides: { paths: ['db'], values: { a: { db: '/a.db' } } } })
expect((await api('viewer', 'hub/apps/export')).code).toBe(403)
expect(readFileSync(path.join(dataDir, 'audit.log'), 'utf8')).toContain('apps.export')
})
it('imports into another hub: a dry run says what is new, applying writes it, and no server is touched', async () => {
await setup()
const file = await (await raw('apps-admin', 'hub/apps/export')).text()
const other = mkdtempSync(path.join(tmpdir(), 'hub-hist-other-'))
writeUsersFile(other, [...(await import('./users.ts')).readUsersFile(dataDir)!])
try {
await start(other)
const before = a.calls.length + b.calls.length
const dry = await post('apps-admin', 'hub/apps/import', { data: file })
expect(dry.body.response).toEqual({ apps: [{ app: 'Blocker', status: 'new', ignoredServers: [] }], applied: false })
expect(readdirSync(other)).not.toContain('apps')
const done = await post('apps-admin', 'hub/apps/import', { data: file, apply: true })
expect(done.body.response.applied).toBe(true)
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'baseline.json'), 'utf8')).text).toBe(V1)
expect(JSON.parse(readFileSync(path.join(dirOf(other), 'overrides.json'), 'utf8')).values.b).toEqual({ db: '/b.db' })
expect(a.calls.length + b.calls.length).toBe(before)
// The same file again changes nothing.
expect((await post('apps-admin', 'hub/apps/import', { data: file })).body.response.apps[0].status).toBe('same')
} finally {
rmSync(other, { recursive: true, force: true })
}
})
it('says changed, and keeps the replaced baseline in the history when applying', async () => {
await setup()
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
file.apps[0].baseline.text = V2
await saveBaseline('{"x":1}')
const dry = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
expect(dry.body.response.apps[0].status).toBe('changed')
await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file), apply: true })
expect(JSON.parse(readFileSync(path.join(dirOf(), 'baseline.json'), 'utf8')).text).toBe(V2)
expect((await list()).some((e) => e.kind === 'before-save' && e.size === '{"x":1}'.length)).toBe(true)
})
it('ignores local values of servers this hub does not have, and says so', async () => {
await setup()
const file = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
file.apps[0].overrides.values.zzz = { db: '/z.db' }
const r = await post('apps-admin', 'hub/apps/import', { data: JSON.stringify(file) })
expect(r.body.response.apps[0].ignoredServers).toEqual(['zzz'])
})
it('refuses a file that is not valid, writing nothing', async () => {
await setup()
const good = JSON.parse(await (await raw('apps-admin', 'hub/apps/export')).text())
const cases: [string, unknown][] = [
['not json', 'this is not json'],
['another file', { hello: 1 }],
['wrong version', { ...good, version: 2 }],
['no apps', { ...good, apps: 'x' }],
['bad json baseline', { ...good, apps: [{ app: 'X', baseline: { text: '{ nope' }, overrides: { paths: [], values: {} } }] }],
['no text', { ...good, apps: [{ app: 'X', baseline: {}, overrides: {} }] }],
['bad path', { ...good, apps: [{ app: 'X', baseline: { text: '{}' }, overrides: { paths: ['a b'], values: {} } }] }],
['duplicate app', { ...good, apps: [good.apps[0], good.apps[0]] }],
['text with local fields', { ...good, apps: [{ app: 'X', baseline: { text: 'plain' }, overrides: { paths: ['a'], values: {} } }] }],
]
for (const [label, content] of cases) {
const r = await post('apps-admin', 'hub/apps/import', { data: typeof content === 'string' ? content : JSON.stringify(content), apply: true })
expect([label, r.code]).toEqual([label, 400])
}
expect(readdirSync(path.join(dataDir, 'apps'))).toEqual(['Blocker'])
})
it('is for writers only, and accepts a file up to 5 MB from a signed-in caller', async () => {
const big = JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [{ app: 'Big', baseline: { text: JSON.stringify({ pad: 'x'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }, ...[1, 2, 3].map((n) => ({ app: `Big${n}`, baseline: { text: JSON.stringify({ pad: 'y'.repeat(900_000) }) }, overrides: { paths: [], values: {} } }))] })
expect(big.length).toBeGreaterThan(2 * 1024 * 1024)
expect((await post('viewer', 'hub/apps/import', { data: big })).code).toBe(403)
const r = await post('apps-admin', 'hub/apps/import', { data: big })
expect(r.body.response.apps).toHaveLength(4)
})
})