// @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 { 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 = {} const ROLES = ['viewer', 'operator', 'apps-admin', 'admin'] async function start(extra: Record = {}): Promise { 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((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) { 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 } } const writes = (f: Fake) => f.calls.filter((c) => c.method === 'POST') async function auditEntries(): Promise[]> { const res = await fetch(`${base}/api/hub/audit/list?limit=200`, { headers: { authorization: `Bearer ${tokens.admin}` } }) return ((await res.json()) as { response: { entries: Record[] } }).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 = {}) => { 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, Record][] = [ [{ 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[] = [ { 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') }) })