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]>
344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { parseConfig } from './config.ts'
|
|
import { Drift } from './drift.ts'
|
|
import { writeBaseline, writeListsBaseline, writeOverrides, writeSettingsBaseline } from './store.ts'
|
|
import { Webhook } from './webhook.ts'
|
|
import { Upstream } from './upstream.ts'
|
|
|
|
const json = (o: unknown): string => JSON.stringify(o, null, 2)
|
|
|
|
interface FakeServer {
|
|
apps: Record<string, string>
|
|
down: boolean
|
|
denyConfig: boolean
|
|
settings: Record<string, unknown>
|
|
blocked: string[]
|
|
allowed: string[]
|
|
}
|
|
|
|
let dir: string
|
|
let servers: Record<string, FakeServer>
|
|
let calls: string[]
|
|
let gate: Promise<void> | null
|
|
let clock: number
|
|
|
|
/* A fetch that answers from memory: no sockets, no timers. */
|
|
const fakeFetch = (async (input: string | URL | Request) => {
|
|
const url = new URL(String(input))
|
|
const id = url.port === '1' ? 'a' : 'b'
|
|
const server = servers[id]
|
|
const apiPath = url.pathname.replace(/^\/api\//, '')
|
|
calls.push(`${id}:${apiPath}`)
|
|
if (gate !== null) await gate
|
|
if (server.down) throw new TypeError('fetch failed')
|
|
const name = url.searchParams.get('name') ?? ''
|
|
const reply = (response: unknown) => Response.json({ status: 'ok', response })
|
|
if (apiPath === 'apps/list') return reply({ apps: Object.keys(server.apps).map((n) => ({ name: n, version: '1.0' })) })
|
|
if (apiPath === 'apps/config/get') {
|
|
if (server.denyConfig) return Response.json({ status: 'error', errorMessage: 'Access was denied.' })
|
|
return reply({ config: server.apps[name] ?? null })
|
|
}
|
|
if (apiPath === 'settings/get') return reply(server.settings)
|
|
if (apiPath === 'blocked/export' || apiPath === 'allowed/export') {
|
|
return new Response((apiPath === 'blocked/export' ? server.blocked : server.allowed).join('\n') + '\n', { headers: { 'content-type': 'text/plain' } })
|
|
}
|
|
return reply({})
|
|
}) as typeof fetch
|
|
|
|
function make(webhook?: Webhook) {
|
|
const config = parseConfig({
|
|
HUB_SERVERS: 'a=http://127.0.0.1:1,b=http://127.0.0.1:2',
|
|
A_TOKEN: 'ra',
|
|
B_TOKEN: 'rb',
|
|
HUB_DATA: dir,
|
|
HUB_CACHE_SECONDS: '0',
|
|
})
|
|
const drift = new Drift(config, new Upstream(config, fakeFetch, () => clock), () => clock, { webhook })
|
|
return { config, drift }
|
|
}
|
|
|
|
const baseline = (app: string, text: string) =>
|
|
writeBaseline(dir, { app, text, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(path.join(tmpdir(), 'hub-drift-'))
|
|
const blank = (): FakeServer => ({ apps: {}, down: false, denyConfig: false, settings: {}, blocked: [], allowed: [] })
|
|
servers = { a: blank(), b: blank() }
|
|
calls = []
|
|
gate = null
|
|
clock = Date.parse('2026-10-31T10:00:00Z')
|
|
})
|
|
|
|
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
|
|
describe('drift check', () => {
|
|
it('finds nothing when no app has a baseline, and calls no server', async () => {
|
|
servers.a.apps.X = json({ a: 1 })
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps).toEqual([])
|
|
expect(state.checkedAt).toBe('2026-10-31T10:00:00.000Z')
|
|
expect(calls).toEqual([])
|
|
expect(drift.count()).toBe(0)
|
|
})
|
|
|
|
it('reports a server that differs from the baseline and not one that matches', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }])
|
|
expect(drift.count()).toBe(1)
|
|
})
|
|
|
|
it('compares by meaning, not by spacing or key order', async () => {
|
|
baseline('Blocker', json({ a: 1, b: 2 }))
|
|
servers.a.apps.Blocker = '{"b":2,"a":1}'
|
|
servers.b.apps.Blocker = json({ a: 1, b: 2 })
|
|
expect((await make().drift.check()).apps).toEqual([])
|
|
})
|
|
|
|
it('uses each server own local values, so a local difference is not drift', async () => {
|
|
baseline('Blocker', json({ ttl: 30, db: '/base' }))
|
|
writeOverrides(dir, 'Blocker', { paths: ['db'], values: { a: { db: '/a.db' }, b: { db: '/b.db' } } })
|
|
servers.a.apps.Blocker = json({ ttl: 30, db: '/a.db' })
|
|
servers.b.apps.Blocker = json({ ttl: 30, db: '/b.db' })
|
|
const { drift } = make()
|
|
expect((await drift.check()).apps).toEqual([])
|
|
// A wrong local value is drift.
|
|
servers.b.apps.Blocker = json({ ttl: 30, db: '/elsewhere.db' })
|
|
expect((await drift.check()).apps.map((e) => [e.server, e.status])).toEqual([['b', 'differs']])
|
|
})
|
|
|
|
it('lists a server that does not answer as unreadable, and does not count it', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.down = true
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps).toEqual([{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'unreadable' }])
|
|
expect(drift.count()).toBe(0)
|
|
})
|
|
|
|
it('lists a refused config read as unreadable and an app that is missing as not-installed', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.a.denyConfig = true
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps.map((e) => [e.server, e.status])).toEqual([['a', 'unreadable'], ['b', 'not-installed']])
|
|
})
|
|
|
|
it('keeps `since` across checks and drops the entry once the problem is fixed', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
const { drift } = make()
|
|
await drift.check()
|
|
clock += 15 * 60_000
|
|
const second = await drift.check()
|
|
expect(second.apps[0].since).toBe('2026-10-31T10:00:00.000Z')
|
|
expect(second.checkedAt).toBe('2026-10-31T10:15:00.000Z')
|
|
|
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
|
clock += 15 * 60_000
|
|
expect((await drift.check()).apps).toEqual([])
|
|
expect(drift.count()).toBe(0)
|
|
|
|
// It drifts again: a new `since`.
|
|
servers.b.apps.Blocker = json({ ttl: 5 })
|
|
clock += 15 * 60_000
|
|
expect((await drift.check()).apps[0].since).toBe('2026-10-31T10:45:00.000Z')
|
|
})
|
|
|
|
it('changes the status (and `since`) when the problem changes', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
const { drift } = make()
|
|
await drift.check()
|
|
servers.b.down = true
|
|
clock += 60_000
|
|
const state = await drift.check()
|
|
expect(state.apps[0]).toMatchObject({ status: 'unreadable', since: '2026-10-31T10:01:00.000Z' })
|
|
})
|
|
|
|
it('never runs two checks at once', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
|
let open!: () => void
|
|
gate = new Promise<void>((resolve) => (open = resolve))
|
|
const { drift } = make()
|
|
const first = drift.check()
|
|
const second = drift.check()
|
|
expect(drift.get().checking).toBe(true)
|
|
open()
|
|
expect(await second).toBe(await first)
|
|
// Two servers, two app lists and two config reads: one check's worth of calls.
|
|
expect(calls).toHaveLength(4)
|
|
expect(drift.get().checking).toBe(false)
|
|
gate = null
|
|
await drift.check()
|
|
expect(calls).toHaveLength(8)
|
|
})
|
|
|
|
it('keeps the last result in drift.json and loads it again', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
await make().drift.check()
|
|
const file = JSON.parse(readFileSync(path.join(dir, 'drift.json'), 'utf8'))
|
|
expect(file).toEqual({
|
|
checkedAt: '2026-10-31T10:00:00.000Z',
|
|
apps: [{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs' }],
|
|
})
|
|
const again = make().drift
|
|
expect(again.get()).toMatchObject({ checkedAt: '2026-10-31T10:00:00.000Z', checking: false })
|
|
expect(again.count()).toBe(1)
|
|
})
|
|
})
|
|
|
|
const baselineSettings = (settings: Record<string, unknown>) => writeSettingsBaseline(dir, { settings, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
|
const baselineLists = (blocked: string[], allowed: string[]) => writeListsBaseline(dir, { blocked, allowed, updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'test' })
|
|
|
|
describe('drift of settings and lists', () => {
|
|
it('reports shared settings fields that differ from the baseline, by field name only', async () => {
|
|
baselineSettings({ blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'x.example' })
|
|
servers.a.settings = { blockingType: 'NxDomain', defaultRecordTtl: 3600, dnsServerDomain: 'a.example' }
|
|
servers.b.settings = { blockingType: 'AnyAddress', defaultRecordTtl: 60, dnsServerDomain: 'b.example' }
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
// dnsServerDomain is local: server a is fine even though its value differs.
|
|
expect(state.apps).toEqual([{ kind: 'settings', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blockingType', 'defaultRecordTtl'] }])
|
|
expect(JSON.stringify(state)).not.toContain('AnyAddress')
|
|
expect(drift.count()).toBe(1)
|
|
})
|
|
|
|
it('reports Blocked and Allowed lists that differ, without a domain', async () => {
|
|
baselineLists(['ads.example'], ['ok.example'])
|
|
servers.a.blocked = ['ads.example']
|
|
servers.a.allowed = ['ok.example']
|
|
servers.b.blocked = ['ads.example', 'extra.example']
|
|
servers.b.allowed = ['ok.example']
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps).toEqual([{ kind: 'lists', server: 'b', since: '2026-10-31T10:00:00.000Z', status: 'differs', fields: ['blocked'] }])
|
|
expect(JSON.stringify(state)).not.toContain('extra.example')
|
|
})
|
|
|
|
it('reads no settings and no lists when there is no baseline for them', async () => {
|
|
const { drift } = make()
|
|
await drift.check()
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
it('lists a server that does not answer as unreadable for each baseline, and does not count it', async () => {
|
|
baselineSettings({ blockingType: 'NxDomain' })
|
|
baselineLists([], [])
|
|
servers.b.down = true
|
|
const { drift } = make()
|
|
const state = await drift.check()
|
|
expect(state.apps.map((e) => [e.kind, e.server, e.status])).toEqual([['lists', 'b', 'unreadable'], ['settings', 'b', 'unreadable']])
|
|
expect(drift.count()).toBe(0)
|
|
})
|
|
|
|
it('counts every kind in one number', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
baselineSettings({ blockingType: 'NxDomain' })
|
|
baselineLists(['x.example'], [])
|
|
servers.a.apps.Blocker = json({ ttl: 1 })
|
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
|
servers.a.settings = { blockingType: 'AnyAddress' }
|
|
servers.b.settings = { blockingType: 'NxDomain' }
|
|
servers.a.blocked = ['x.example']
|
|
const { drift } = make()
|
|
await drift.check()
|
|
// a: app and settings; b: lists (the baseline has x.example, b has none).
|
|
expect(drift.get().apps.map((e) => `${e.kind}:${e.server}`).sort()).toEqual(['app:a', 'lists:b', 'settings:a'])
|
|
expect(drift.count()).toBe(3)
|
|
})
|
|
|
|
it('loads a drift.json written before stage 8, where `kind` held the status', () => {
|
|
writeFileSync(
|
|
path.join(dir, 'drift.json'),
|
|
JSON.stringify({
|
|
checkedAt: '2026-10-31T09:00:00.000Z',
|
|
apps: [
|
|
{ app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', kind: 'differs' },
|
|
{ app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', kind: 'not-installed' },
|
|
{ server: 'a', since: 'x', kind: 'bogus', app: 'Z' },
|
|
],
|
|
}),
|
|
)
|
|
const { drift } = make()
|
|
expect(drift.get().apps).toEqual([
|
|
{ kind: 'app', name: 'Blocker', app: 'Blocker', server: 'b', since: '2026-10-31T08:00:00.000Z', status: 'differs' },
|
|
{ kind: 'app', name: 'Other', app: 'Other', server: 'a', since: '2026-10-31T08:00:00.000Z', status: 'not-installed' },
|
|
])
|
|
expect(drift.count()).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('drift notices', () => {
|
|
const sent: { event: string; items: Record<string, unknown>[] }[] = []
|
|
const hook = () =>
|
|
new Webhook(
|
|
{ webhookUrl: 'https://hooks.example/x?token=SECRET', webhookSecret: null, domain: 'hub' },
|
|
{
|
|
retryDelayMs: 0,
|
|
fetch: (async (_url: unknown, init: RequestInit) => {
|
|
sent.push(JSON.parse(String(init.body)))
|
|
return new Response('ok')
|
|
}) as typeof fetch,
|
|
},
|
|
)
|
|
|
|
beforeEach(() => {
|
|
sent.length = 0
|
|
})
|
|
|
|
it('tells what newly differs once, and what is cleared, and nothing when nothing changed', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
const webhook = hook()
|
|
const { drift } = make(webhook)
|
|
await drift.check()
|
|
await webhook.settled()
|
|
expect(sent.map((s) => s.event)).toEqual(['drift.found'])
|
|
expect(sent[0].items).toMatchObject([{ kind: 'app', name: 'Blocker', server: 'b' }])
|
|
|
|
clock += 15 * 60_000
|
|
await drift.check()
|
|
await webhook.settled()
|
|
expect(sent).toHaveLength(1)
|
|
|
|
servers.b.apps.Blocker = json({ ttl: 30 })
|
|
await drift.check()
|
|
await webhook.settled()
|
|
expect(sent.map((s) => s.event)).toEqual(['drift.found', 'drift.cleared'])
|
|
})
|
|
|
|
it('does not repeat a notice after a restart, and does not call an unreadable server cleared', async () => {
|
|
baseline('Blocker', json({ ttl: 30 }))
|
|
servers.a.apps.Blocker = json({ ttl: 30 })
|
|
servers.b.apps.Blocker = json({ ttl: 99 })
|
|
await make().drift.check()
|
|
const webhook = hook()
|
|
const restarted = make(webhook).drift
|
|
await restarted.check()
|
|
await webhook.settled()
|
|
expect(sent).toEqual([])
|
|
servers.b.down = true
|
|
await restarted.check()
|
|
await webhook.settled()
|
|
expect(sent).toEqual([])
|
|
})
|
|
})
|