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 <noreply@anthropic.com>
373 lines
18 KiB
TypeScript
373 lines
18 KiB
TypeScript
// @vitest-environment node
|
|
/*
|
|
Stage 8, profiles: making one from the hub's baselines, what a viewer of it may see, export and
|
|
import, the preview, and the order and the stop of an apply, on two fake servers that keep their
|
|
apps, configs, settings and lists.
|
|
*/
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
|
import { fake, startHub, ROLES, type Call, type Fake, type TestHub } from './fake-servers.ts'
|
|
import { cleanProfile } from './profiles.ts'
|
|
import { writeBaseline, writeListsBaseline, writeSettingsBaseline } from './store.ts'
|
|
|
|
interface Box {
|
|
apps: Record<string, string>
|
|
configs: Record<string, string>
|
|
store: { name: string; version: string }[]
|
|
settings: Record<string, unknown>
|
|
blocked: string[]
|
|
allowed: string[]
|
|
failSettings: boolean
|
|
}
|
|
|
|
const boxes = new Map<Fake, Box>()
|
|
const urlOf = (f: Fake, name: string): string => `${f.url}/store/${name}.zip`
|
|
|
|
function install(f: Fake, box: Box): void {
|
|
boxes.set(f, box)
|
|
f.on('apps/list', () => ({ response: { apps: Object.entries(box.apps).map(([name, version]) => ({ name, version })) } }))
|
|
f.on('apps/listStoreApps', () => ({ response: { storeApps: box.store.map((a) => ({ ...a, url: urlOf(f, a.name) })) } }))
|
|
f.on('apps/downloadAndInstall', (c: Call) => {
|
|
const name = c.form.get('name') ?? c.query.get('name') ?? ''
|
|
const url = c.form.get('url') ?? c.query.get('url') ?? ''
|
|
const entry = box.store.find((a) => a.name === name)
|
|
if (entry === undefined || url !== urlOf(f, name)) return { error: 'The app was not found in the store.' }
|
|
box.apps[name] = entry.version
|
|
return { response: {} }
|
|
})
|
|
f.on('apps/config/get', (c: Call) => ({ response: { config: box.configs[c.query.get('name') ?? ''] ?? null } }))
|
|
f.on('apps/config/set', (c: Call) => {
|
|
box.configs[c.form.get('name') ?? c.query.get('name') ?? ''] = c.form.get('config') ?? ''
|
|
return { response: {} }
|
|
})
|
|
f.on('settings/get', () => ({ response: box.settings }))
|
|
f.on('settings/set', (c: Call) => {
|
|
if (box.failSettings) return { error: 'The settings were refused.' }
|
|
for (const [k, v] of c.form) {
|
|
if (k === 'tsigKeys') {
|
|
const cells = v.split('|')
|
|
box.settings.tsigKeys = v === 'false' ? [] : [{ keyName: cells[0], sharedSecret: cells[1], algorithmName: cells[2] }]
|
|
} else if (k in box.settings) box.settings[k] = v
|
|
}
|
|
return { response: {} }
|
|
})
|
|
for (const list of ['blocked', 'allowed'] as const) {
|
|
f.on(`${list}/export`, () => ({ text: box[list].join('\n') + '\n' }))
|
|
f.on(`${list}/add`, (c: Call) => {
|
|
box[list].push(c.form.get('domain') ?? '')
|
|
return { response: {} }
|
|
})
|
|
f.on(`${list}/delete`, (c: Call) => {
|
|
box[list] = box[list].filter((d) => d !== c.form.get('domain'))
|
|
return { response: {} }
|
|
})
|
|
}
|
|
}
|
|
|
|
let a: Fake
|
|
let b: Fake
|
|
let hub: TestHub
|
|
const boxA = (): Box => boxes.get(a)!
|
|
const boxB = (): Box => boxes.get(b)!
|
|
const order = (f: Fake): string[] => f.calls.filter((c) => c.method === 'POST').map((c) => c.path)
|
|
|
|
const CONFIG = JSON.stringify({ ttl: 30 })
|
|
const settingsOf = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
dnsServerDomain: 'a.example',
|
|
blockingType: 'NxDomain',
|
|
tsigKeys: [{ keyName: 'k1', sharedSecret: 'REALSECRET', algorithmName: 'hmac-sha256' }],
|
|
...over,
|
|
})
|
|
|
|
function baselines(): void {
|
|
writeBaseline(hub.dataDir, { app: 'Blocker', text: CONFIG, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
|
writeSettingsBaseline(hub.dataDir, { settings: settingsOf(), updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
|
writeListsBaseline(hub.dataDir, { blocked: ['ads.example'], allowed: ['ok.example'], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 't' })
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
a = await fake()
|
|
b = await fake()
|
|
hub = await startHub(a, b)
|
|
})
|
|
|
|
afterAll(() => {
|
|
hub.close()
|
|
a.server.close()
|
|
b.server.close()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
a.calls = []
|
|
b.calls = []
|
|
install(a, {
|
|
apps: { Blocker: '2.0', Zipped: '1.0' },
|
|
configs: { Blocker: CONFIG },
|
|
store: [{ name: 'Blocker', version: '2.0' }],
|
|
settings: settingsOf(),
|
|
blocked: ['ads.example'],
|
|
allowed: ['ok.example'],
|
|
failSettings: false,
|
|
})
|
|
// b is a fresh server: no apps, different settings, a missing domain, an extra one.
|
|
install(b, {
|
|
apps: {},
|
|
configs: {},
|
|
store: [{ name: 'Blocker', version: '2.0' }],
|
|
settings: settingsOf({ dnsServerDomain: 'b.example', blockingType: 'AnyAddress', tsigKeys: [] }),
|
|
blocked: ['mine.example'],
|
|
allowed: ['ok.example'],
|
|
failSettings: false,
|
|
})
|
|
baselines()
|
|
for (const f of readdirSync(path.join(hub.dataDir)).filter((n) => n === 'profiles')) void f
|
|
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
|
})
|
|
|
|
const snapshot = (over: Record<string, string> = {}) =>
|
|
hub.call('admin', 'hub/profiles/snapshot', { name: 'Fresh', apps: 'Blocker', settings: 'true', lists: 'true', ...over })
|
|
|
|
describe('making a profile', () => {
|
|
it('copies the baselines at that moment: store apps with version and config, shared settings fields, lists', async () => {
|
|
const done = await snapshot()
|
|
expect(done.code).toBe(200)
|
|
expect(done.body.status).toBe('ok')
|
|
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
|
expect(file.apps).toEqual([{ name: 'Blocker', version: '2.0', config: CONFIG }])
|
|
// The host field stays out; the real secret is kept, as the baseline file has it.
|
|
expect(file.settings.dnsServerDomain).toBeUndefined()
|
|
expect(file.settings.tsigKeys[0].sharedSecret).toBe('REALSECRET')
|
|
expect(file.lists).toEqual({ blocked: ['ads.example'], allowed: ['ok.example'] })
|
|
expect(file.updatedBy).toBe('admin')
|
|
// Later changes of the baselines do not change the profile.
|
|
writeBaseline(hub.dataDir, { app: 'Blocker', text: '{"ttl":1}', updatedAt: 'x', updatedBy: 't' })
|
|
const again = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
|
expect(again.apps[0].config).toBe(CONFIG)
|
|
})
|
|
|
|
it('lets the admin choose the parts, and refuses an empty choice', async () => {
|
|
expect((await snapshot({ settings: 'false', lists: 'false' })).body.status).toBe('ok')
|
|
const file = JSON.parse(readFileSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'), 'utf8'))
|
|
expect(file.settings).toBeNull()
|
|
expect(file.lists).toBeNull()
|
|
const none = await snapshot({ apps: '', settings: 'false', lists: 'false' })
|
|
expect(none.code).toBe(400)
|
|
expect(none.body.errorMessage).toMatch(/at least one part/)
|
|
})
|
|
|
|
it('refuses an app that is a zip, one that is installed nowhere, and a bad name', async () => {
|
|
const zip = await snapshot({ apps: 'Zipped' })
|
|
expect(zip.code).toBe(400)
|
|
expect(zip.body.errorMessage).toMatch(/store listing/)
|
|
expect((await snapshot({ apps: 'Ghost' })).body.errorMessage).toMatch(/not installed/)
|
|
expect((await snapshot({ name: '../evil' })).code).toBe(400)
|
|
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
|
})
|
|
|
|
it('replaces a profile of the same name and lists it with counts only', async () => {
|
|
await snapshot()
|
|
await snapshot({ lists: 'false' })
|
|
const list = await hub.call('admin', 'hub/profiles/list', {}, 'GET')
|
|
expect(list.body.response.profiles).toMatchObject([{ name: 'Fresh', apps: 1, configs: 1, lists: null }])
|
|
expect(list.body.response.profiles[0].settings).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('viewing a profile', () => {
|
|
it('masks secrets and never returns an app config', async () => {
|
|
await snapshot()
|
|
const got = await hub.call('admin', 'hub/profiles/get', { name: 'Fresh' }, 'GET')
|
|
const text = JSON.stringify(got.body)
|
|
expect(text).not.toContain('REALSECRET')
|
|
expect(text).toContain('<secret>')
|
|
expect(text).not.toContain('"ttl"')
|
|
expect(got.body.response.apps).toEqual([{ name: 'Blocker', version: '2.0', hasConfig: true, bytes: CONFIG.length }])
|
|
expect(got.body.response.lists).toEqual({ blocked: 1, allowed: 1 })
|
|
})
|
|
|
|
it('answers an unknown profile with an error', async () => {
|
|
expect((await hub.call('admin', 'hub/profiles/get', { name: 'Nope' }, 'GET')).code).toBe(400)
|
|
})
|
|
})
|
|
|
|
describe('roles', () => {
|
|
it('refuses every profile endpoint to every role but admin, and answers none of them from a server', async () => {
|
|
const reads = ['list', 'get', 'export', 'preview']
|
|
const writes = ['snapshot', 'delete', 'import', 'apply']
|
|
for (const role of ROLES.filter((r) => r !== 'admin')) {
|
|
for (const p of reads) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b' }, 'GET')).code).toBe(403)
|
|
for (const p of writes) expect((await hub.call(role, `hub/profiles/${p}`, { name: 'Fresh', targets: 'b', data: '{}' })).code).toBe(403)
|
|
}
|
|
// An unlisted profile path is refused, not forwarded.
|
|
expect((await hub.call('admin', 'hub/profiles/nothing', {}, 'GET')).code).toBe(403)
|
|
expect(a.calls).toEqual([])
|
|
expect(b.calls).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('export and import', () => {
|
|
it('exports with the real secret, and imports it dry first, then for real', async () => {
|
|
await snapshot()
|
|
const res = await fetch(`${hub.base}/api/hub/profiles/export`, { headers: { authorization: `Bearer ${hub.tokens.admin}` } })
|
|
expect(res.headers.get('content-disposition')).toMatch(/hub-profiles-/)
|
|
const text = await res.text()
|
|
expect(text).toContain('REALSECRET')
|
|
const file = JSON.parse(text)
|
|
expect(file).toMatchObject({ hub: 'technitium-hub', type: 'profiles', version: 1 })
|
|
|
|
await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })
|
|
const dry = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'false' })
|
|
expect(dry.body.response).toMatchObject({ applied: false, profiles: [{ name: 'Fresh', status: 'new', apps: 1, settings: true, lists: true }] })
|
|
expect(existsSync(path.join(hub.dataDir, 'profiles', 'Fresh.json'))).toBe(false)
|
|
const done = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
|
expect(done.body.response.applied).toBe(true)
|
|
const again = await hub.call('admin', 'hub/profiles/import', { data: text, apply: 'true' })
|
|
expect(again.body.response.profiles[0].status).toBe('same')
|
|
expect(JSON.stringify(await hub.audit())).not.toContain('REALSECRET')
|
|
})
|
|
|
|
it('refuses a file of another kind, a placeholder where a secret should be, and local fields are dropped', async () => {
|
|
const bad = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify({ hub: 'technitium-hub', version: 1, apps: [] }) })
|
|
expect(bad.code).toBe(400)
|
|
const masked = { hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'M', apps: [], settings: { tsigKeys: [{ keyName: 'k', sharedSecret: '<secret>' }] }, lists: null }] }
|
|
const refused = await hub.call('admin', 'hub/profiles/import', { data: JSON.stringify(masked) })
|
|
expect(refused.code).toBe(400)
|
|
expect(refused.body.errorMessage).toMatch(/<secret>/)
|
|
expect(() => cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).not.toThrow()
|
|
expect(cleanProfile({ name: 'x y', apps: [], settings: { dnsServerDomain: 'h', blockingType: 'NxDomain' }, lists: null }).settings).toEqual({ blockingType: 'NxDomain' })
|
|
})
|
|
|
|
it('accepts a body above the usual limit for an admin', async () => {
|
|
const big = JSON.stringify({ hub: 'technitium-hub', type: 'profiles', version: 1, profiles: [{ name: 'Big', apps: [{ name: 'X', version: '1', config: JSON.stringify({ pad: 'x'.repeat(200_000) }) }], settings: null, lists: null }] })
|
|
expect(big.length).toBeGreaterThan(64 * 1024)
|
|
const done = await hub.call('admin', 'hub/profiles/import', { data: big, apply: 'false' })
|
|
expect(done.body.response.profiles[0]).toMatchObject({ name: 'Big', status: 'new' })
|
|
})
|
|
})
|
|
|
|
describe('preview', () => {
|
|
it('says what each part would change on a target, and writes nothing', async () => {
|
|
await snapshot()
|
|
const got = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b,a' })
|
|
expect(got.code).toBe(200)
|
|
const [tb, ta] = got.body.response.targets
|
|
expect(tb.server).toBe('b')
|
|
expect(tb.apps).toEqual([{ name: 'Blocker', state: 'install', profileVersion: '2.0', storeVersion: '2.0' }])
|
|
expect(tb.configs).toEqual([{ name: 'Blocker', state: 'after-install' }])
|
|
expect(tb.settings.fields).toContain('blockingType')
|
|
expect(tb.settings.secretFields).toContain('tsigKeys')
|
|
expect(JSON.stringify(got.body)).not.toContain('REALSECRET')
|
|
expect(tb.lists.blocked.missing).toBe(1)
|
|
expect(ta.apps).toEqual([{ name: 'Blocker', state: 'present', profileVersion: '2.0', installedVersion: '2.0' }])
|
|
expect(ta.configs).toEqual([{ name: 'Blocker', state: 'same' }])
|
|
expect(ta.settings.unchanged).toBe(true)
|
|
expect(order(a).concat(order(b))).toEqual([])
|
|
})
|
|
|
|
it('marks an installed app whose version differs, and an app the store does not offer', async () => {
|
|
await snapshot()
|
|
boxB().apps.Blocker = '1.0'
|
|
const one = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
|
expect(one.body.response.targets[0].apps[0]).toMatchObject({ state: 'version-differs', installedVersion: '1.0', profileVersion: '2.0' })
|
|
boxB().apps = {}
|
|
boxB().store = []
|
|
const two = await hub.call('admin', 'hub/profiles/preview', { name: 'Fresh', targets: 'b' })
|
|
expect(two.body.response.targets[0].apps[0].state).toBe('not-in-store')
|
|
})
|
|
})
|
|
|
|
describe('apply', () => {
|
|
it('runs the steps in order on a fresh server: install, config, settings, lists', async () => {
|
|
await snapshot()
|
|
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
|
expect(done.body.response.results[0]).toMatchObject({ server: 'b', ok: true })
|
|
expect(done.body.response.results[0].steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
|
['apps', 'ok'],
|
|
['configs', 'ok'],
|
|
['settings', 'ok'],
|
|
['lists', 'ok'],
|
|
])
|
|
expect(order(b)).toEqual(['apps/downloadAndInstall', 'apps/config/set', 'settings/set', 'blocked/add'])
|
|
expect(boxB().apps.Blocker).toBe('2.0')
|
|
expect(JSON.parse(boxB().configs.Blocker)).toEqual({ ttl: 30 })
|
|
expect(boxB().settings.blockingType).toBe('NxDomain')
|
|
// The host field was left alone, and the extra domain stays unless asked.
|
|
expect(boxB().settings.dnsServerDomain).toBe('b.example')
|
|
expect(boxB().blocked).toEqual(['mine.example', 'ads.example'])
|
|
// The old config of a server that had one goes to the history; b had none, so nothing is claimed.
|
|
expect(order(a)).toEqual([])
|
|
})
|
|
|
|
it('takes the install URL from the target own store, never from the request', async () => {
|
|
await snapshot()
|
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', url: 'https://evil.example/x.zip' })
|
|
const install = b.calls.find((c) => c.path === 'apps/downloadAndInstall')!
|
|
expect(install.query.get('url') ?? install.form.get('url')).toBe(urlOf(b, 'Blocker'))
|
|
expect(a.calls.some((c) => c.path === 'apps/downloadAndInstall')).toBe(false)
|
|
// Each server is asked for its own listing, with its own read token.
|
|
expect(b.calls.find((c) => c.path === 'apps/listStoreApps')!.token).toBe('read-b')
|
|
})
|
|
|
|
it('removes extra domains only when asked', async () => {
|
|
await snapshot()
|
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', removeExtras: 'true' })
|
|
expect(boxB().blocked).toEqual(['ads.example'])
|
|
})
|
|
|
|
it('stops the later steps of a server when one fails, says so, and leaves the other server alone', async () => {
|
|
await snapshot()
|
|
boxB().failSettings = true
|
|
boxA().settings.blockingType = 'AnyAddress'
|
|
const done = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'a,b' })
|
|
const [ra, rb] = done.body.response.results
|
|
expect(ra).toMatchObject({ server: 'a', ok: true })
|
|
expect(rb).toMatchObject({ server: 'b', ok: false })
|
|
expect(rb.message).toMatch(/Stopped at settings/)
|
|
expect(rb.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
|
['apps', 'ok'],
|
|
['configs', 'ok'],
|
|
['settings', 'failed'],
|
|
['lists', 'not-run'],
|
|
])
|
|
expect(order(b)).not.toContain('blocked/add')
|
|
const audit = await hub.audit()
|
|
const lines = audit.filter((e) => e.action === 'profiles.apply' && e.server === 'b').slice(0, 3)
|
|
expect(lines.map((e) => [e.outcome, e.detail?.split(':')[0]])).toEqual([['failed', 'settings'], ['ok', 'configs'], ['ok', 'apps']])
|
|
expect(JSON.stringify(audit)).not.toContain('REALSECRET')
|
|
})
|
|
|
|
it('retries from the failed step without redoing the earlier ones', async () => {
|
|
await snapshot()
|
|
boxB().failSettings = true
|
|
await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b' })
|
|
b.calls = []
|
|
boxB().failSettings = false
|
|
const again = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'b', from: JSON.stringify({ b: 'settings' }) })
|
|
const r = again.body.response.results[0]
|
|
expect(r.ok).toBe(true)
|
|
expect(r.steps.map((s: Record<string, string>) => [s.step, s.status])).toEqual([
|
|
['apps', 'skipped'],
|
|
['configs', 'skipped'],
|
|
['settings', 'ok'],
|
|
['lists', 'ok'],
|
|
])
|
|
expect(order(b)).toEqual(['settings/set', 'blocked/add'])
|
|
})
|
|
|
|
it('fails a server with no admin token on its first step, and refuses an unknown server or profile', async () => {
|
|
await snapshot()
|
|
const unknown = await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: 'zzz' })
|
|
expect(unknown.code).toBe(400)
|
|
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Nope', targets: 'b' })).code).toBe(400)
|
|
expect((await hub.call('admin', 'hub/profiles/apply', { name: 'Fresh', targets: '' })).code).toBe(400)
|
|
})
|
|
|
|
it('deletes a profile', async () => {
|
|
await snapshot()
|
|
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).body.response).toEqual({ deleted: true })
|
|
expect((await hub.call('admin', 'hub/profiles/delete', { name: 'Fresh' })).code).toBe(400)
|
|
expect((await hub.call('admin', 'hub/profiles/list', {}, 'GET')).body.response.profiles).toEqual([])
|
|
})
|
|
})
|