Files
DenozordecandClaude Opus 5.5 34de910e04 Hub stage 6: blocking and cache actions on several servers
One endpoint, hub/servers/run (operate), runs a fixed set of actions on
the chosen servers in parallel with each server's admin token: add or
remove a domain in Blocked or Allowed, disable blocking for a time or
enable it, update block lists, replace the block list URLs, flush the
cache or delete a domain from it. Each server gets its own result and
the console offers Retry failed.

In "All servers", Blocking, Cache and Query Logs rows open this flow
instead of being disabled. The hub validates domains, minutes and the
server's list grammar (allow lists with !, switched-off lines with #)
and logs no URL list. Operators can now use the blocking switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:50:09 +07:00

355 lines
17 KiB
TypeScript

// @vitest-environment node
/*
Stage 6: the same change on several servers at once, outside Apps (`hub/servers/run`). The servers
are fakes that record how they were called.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { parseConfig } from './config.ts'
import { createHub } from './server.ts'
import { addUser, writeUsersFile } from './users.ts'
import { MAX_MINUTES, MAX_LINES, SERVER_ACTIONS, cleanDomain, cleanUrls, planAction } from './servers-run.ts'
import { routeFor } from './routes.ts'
interface Call {
path: string
method: string
token: string
query: URLSearchParams
form: URLSearchParams
}
interface Fake {
server: http.Server
url: string
calls: Call[]
failWith: string | null
down: boolean
}
function fake(): Promise<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', calls: [], failWith: null, down: false }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
if (f.down) return req.socket.destroy()
const url = new URL(req.url ?? '/', 'http://fake')
const contentType = String(req.headers['content-type'] ?? '')
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? Buffer.concat(chunks).toString('utf8') : '')
f.calls.push({ path: url.pathname.replace(/^\/api\//, ''), method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form })
res.setHeader('content-type', 'application/json')
if (f.failWith !== null && req.method === 'POST') return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
res.end(JSON.stringify({ status: 'ok', response: {} }))
})
})
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 ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
async function start(extra: Record<string, string> = {}): 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: dataDir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
...extra,
}),
)
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 ROLES) {
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 run(role: string, params: Record<string, string>) {
const res = await fetch(`${base}/api/hub/servers/run`, {
method: 'POST',
headers: { authorization: `Bearer ${tokens[role]}`, 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
})
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const writes = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
async function auditEntries(): Promise<Record<string, string>[]> {
const res = await fetch(`${base}/api/hub/audit/list?limit=200`, { headers: { authorization: `Bearer ${tokens.admin}` } })
return ((await res.json()) as { response: { entries: Record<string, string>[] } }).response.entries
}
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'operator', 'operator', 'password-operator')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
await start()
})
beforeEach(() => {
for (const f of [a, b]) {
f.calls = []
f.failWith = null
f.down = false
}
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
describe('the plan of an action', () => {
const plan = (action: string, raw: Record<string, string> = {}) => {
const out = planAction(action, raw)
if ('error' in out) throw new Error(out.error)
return out.plan
}
it('maps each action to its Technitium call and sends only what it needs', () => {
expect(plan('blocked.add', { domain: 'Ads.Example.' })).toMatchObject({ path: 'blocked/add', params: { domain: 'Ads.Example' } })
expect(plan('blocked.delete', { domain: 'ads.example' })).toMatchObject({ path: 'blocked/delete', params: { domain: 'ads.example' } })
expect(plan('allowed.add', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/add' })
expect(plan('allowed.delete', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/delete' })
expect(plan('blocking.disable', { minutes: '15' })).toMatchObject({ path: 'settings/temporaryDisableBlocking', params: { minutes: '15' }, target: 'minutes=15' })
expect(plan('blocking.enable')).toMatchObject({ path: 'settings/set', params: { enableBlocking: 'true' } })
expect(plan('blocking.updateLists')).toMatchObject({ path: 'settings/forceUpdateBlockLists', params: {} })
expect(plan('blocking.setListUrls', { urls: 'https://a.example/x\nhttp://b.example/y' })).toMatchObject({
path: 'settings/set',
params: { blockListUrls: 'https://a.example/x,http://b.example/y' },
target: 'urls=2',
})
expect(plan('cache.flush')).toMatchObject({ path: 'cache/flush', params: {} })
expect(plan('cache.delete', { domain: 'x.example' })).toMatchObject({ path: 'cache/delete', params: { domain: 'x.example' } })
expect(SERVER_ACTIONS).toHaveLength(10)
})
it('refuses an action that is not on the list', () => {
for (const bad of [undefined, '', 'settings.set', 'zones/delete', 'cache.flush ', '__proto__']) expect(planAction(bad, {})).toHaveProperty('error')
})
it('checks the domain', () => {
expect(cleanDomain('example.com')).toBe('example.com')
expect(cleanDomain('*.example.com')).toBe('*.example.com')
for (const bad of ['', ' ', 'a b.example', 'a..example', '-a.example', 'a.example/x', 'a.example?x=1', 'a,b.example', 'a'.repeat(64) + '.example', ('a.'.repeat(130)) + 'com']) {
expect(cleanDomain(bad)).toBeNull()
expect(planAction('blocked.add', { domain: bad })).toHaveProperty('error')
}
})
it('checks the minutes', () => {
for (const bad of [undefined, '', '0', '-5', '1.5', 'abc', '5m', String(MAX_MINUTES + 1), '99999999']) {
expect(planAction('blocking.disable', { minutes: bad })).toHaveProperty('error')
}
expect(planAction('blocking.disable', { minutes: String(MAX_MINUTES) })).toHaveProperty('plan')
})
it('checks the lines', () => {
for (const bad of ['', ' \n ', 'ftp://a.example/x', '!ftp://a.example/x', '! javascript:alert(1)', 'not a url', 'https://a.example/x,y', '# a, b', 'https://a.example/a b', '#' + 'x'.repeat(300)]) {
expect(cleanUrls(bad), bad).toHaveProperty('error')
}
expect(cleanUrls(Array.from({ length: MAX_LINES + 1 }, (_, i) => `https://a.example/${i}`).join('\n'))).toHaveProperty('error')
})
it('takes the grammar of the lists: allow lines, switched-off lines, headings, file addresses', () => {
const text = '# Privacy\nhttps://a.example/hosts\n!https://b.example/allow.txt\n#https://c.example/off\n\nfile:///etc/extra.txt\r\n http://d.example/x \n'
expect(cleanUrls(text)).toEqual({
urls: ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', 'file:///etc/extra.txt', 'http://d.example/x'],
})
})
it('sends the same blockListUrls the console Settings save sends (the lines joined with commas, in order)', () => {
const lines = ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', '# Ads', 'https://d.example/x']
const out = planAction('blocking.setListUrls', { urls: lines.join('\n') })
if ('error' in out) throw new Error(out.error)
// cleanList in src/screens/settings/model.ts: newlines to commas, repeated commas collapsed, none at the ends.
expect(out.plan.params).toEqual({ blockListUrls: lines.join(',') })
expect(out.plan.target).toBe('urls=6')
})
})
describe('who may run it', () => {
it('is a write-all route for operate', () => {
expect(routeFor('hub/servers/run')).toMatchObject({ kind: 'write-all', capability: 'operate' })
})
it('refuses a viewer and an apps admin, calling no server', async () => {
for (const role of ['viewer', 'apps-admin']) {
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(403)
}
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(0)
})
it('lets an operator and an admin run it', async () => {
for (const role of ['operator', 'admin']) {
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(200)
expect(r.body.response.results).toHaveLength(2)
}
})
it('needs a POST', async () => {
const res = await fetch(`${base}/api/hub/servers/run?action=cache.flush&targets=a`, { headers: { authorization: `Bearer ${tokens.operator}` } })
expect(res.status).toBe(405)
expect(writes(a)).toHaveLength(0)
})
})
describe('running it', () => {
it('sends each action to each server with its admin token, parameters in the body', async () => {
const cases: [Record<string, string>, string, Record<string, string>][] = [
[{ action: 'blocked.add', domain: 'ads.example' }, 'blocked/add', { domain: 'ads.example' }],
[{ action: 'blocked.delete', domain: 'ads.example' }, 'blocked/delete', { domain: 'ads.example' }],
[{ action: 'allowed.add', domain: 'ok.example' }, 'allowed/add', { domain: 'ok.example' }],
[{ action: 'allowed.delete', domain: 'ok.example' }, 'allowed/delete', { domain: 'ok.example' }],
[{ action: 'blocking.disable', minutes: '15' }, 'settings/temporaryDisableBlocking', { minutes: '15' }],
[{ action: 'blocking.enable' }, 'settings/set', { enableBlocking: 'true' }],
[{ action: 'blocking.updateLists' }, 'settings/forceUpdateBlockLists', {}],
[{ action: 'blocking.setListUrls', urls: 'https://a.example/x\nhttps://b.example/y' }, 'settings/set', { blockListUrls: 'https://a.example/x,https://b.example/y' }],
[{ action: 'cache.flush' }, 'cache/flush', {}],
[{ action: 'cache.delete', domain: 'x.example' }, 'cache/delete', { domain: 'x.example' }],
]
for (const [params, upstreamPath, sent] of cases) {
a.calls = []
b.calls = []
const r = await run('operator', { ...params, targets: 'a,b' })
expect(r.code, params.action).toBe(200)
expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]])
for (const [f, token] of [[a, 'admin-a'], [b, 'admin-b']] as const) {
const call = writes(f).filter((c) => c.path !== 'settings/get')
expect(call, params.action).toHaveLength(1)
expect(call[0].path).toBe(upstreamPath)
expect(call[0].token).toBe(token)
expect(Object.fromEntries(call[0].form)).toEqual(sent)
// Nothing but the token in the URL.
expect([...call[0].query.keys()]).toEqual(['token'])
}
}
})
it('sends only the one setting, and never reads or writes anything else', async () => {
await run('operator', { action: 'blocking.setListUrls', urls: 'https://a.example/x', targets: 'a' })
expect(a.calls.map((c) => c.path)).toEqual(['settings/set'])
expect([...(a.calls[0].form.keys())]).toEqual(['blockListUrls'])
})
it('shows a partial failure per server, never a bare ok', async () => {
b.failWith = 'Access was denied to user x for Blocked.'
const r = await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
expect(r.code).toBe(200)
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: true })
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: false })
expect(results.find((x) => x.server === 'b')?.message).toContain('Access was denied')
expect(results.every((x) => typeof x.message === 'string' && x.message !== '')).toBe(true)
})
it('reports a server that does not answer, and still runs the others', async () => {
b.down = true
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
const results = r.body.response.results as { server: string; ok: boolean }[]
expect(results.map((x) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
expect(writes(a)).toHaveLength(1)
})
it('fails only the target that has no admin token, and one that is unknown refuses the request', async () => {
await start({ A_ADMIN_TOKEN: '' })
for (const f of [a, b]) f.calls = []
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(200)
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: false, message: 'no admin token configured' })
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: true })
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(1)
const unknown = await run('operator', { action: 'cache.flush', targets: 'a,nope' })
expect(unknown.code).toBe(400)
expect(writes(a)).toHaveLength(0)
await start()
})
it('refuses empty targets and a bad parameter, calling no server', async () => {
const bad: Record<string, string>[] = [
{ action: 'cache.flush', targets: '' },
{ action: 'cache.flush' },
{ action: 'cache.flush', targets: ' , ' },
{ action: 'blocked.add', domain: 'a b', targets: 'a' },
{ action: 'blocking.disable', minutes: '0', targets: 'a' },
{ action: 'blocking.setListUrls', urls: 'ftp://x.example', targets: 'a' },
{ action: 'settings.set', targets: 'a' },
]
for (const params of bad) {
const r = await run('operator', params)
expect(r.code, JSON.stringify(params)).toBe(400)
expect(r.body.status).toBe('error')
}
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(0)
})
it('runs on a server once even when it is named twice', async () => {
await run('operator', { action: 'cache.flush', targets: 'a,a' })
expect(writes(a)).toHaveLength(1)
})
})
describe('the audit log', () => {
it('has one line per server with the target, and never the URL list', async () => {
b.failWith = 'Disk full.'
await run('operator', { action: 'blocking.setListUrls', urls: 'https://secret.example/token123/list.txt\nhttps://x.example/y', targets: 'a,b' })
await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
await run('operator', { action: 'blocking.disable', minutes: '15', targets: 'a' })
await run('operator', { action: 'cache.flush', targets: '' })
const entries = (await auditEntries()).filter((e) => e.action?.startsWith('servers.'))
const lists = entries.filter((e) => e.action === 'servers.blocking.setListUrls')
expect(lists.map((e) => [e.server, e.outcome, e.target])).toEqual(expect.arrayContaining([['a', 'ok', 'urls=2'], ['b', 'failed', 'urls=2']]))
expect(entries.filter((e) => e.action === 'servers.blocked.add').map((e) => [e.server, e.target])).toEqual(expect.arrayContaining([['a', 'ads.example'], ['b', 'ads.example']]))
expect(entries.find((e) => e.action === 'servers.blocking.disable')).toMatchObject({ server: 'a', target: 'minutes=15', outcome: 'ok', user: 'operator' })
expect(entries.find((e) => e.action === 'servers.cache.flush')).toMatchObject({ outcome: 'refused' })
const text = JSON.stringify(entries)
expect(text).not.toContain('secret.example')
expect(text).not.toContain('token123')
expect(text).not.toContain('admin-a')
})
})