From 34de910e040753c12dbe78cd2f09eeed0838a2fe Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 31 Oct 2026 22:50:09 +0700 Subject: [PATCH] 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 --- dev/mock-api.mjs | 10 +- docs/hub-deploy.md | 6 +- docs/hub-management-plan.md | 2 +- hub/README.md | 27 +- hub/routes.ts | 16 +- hub/server-write.test.ts | 6 +- hub/server.ts | 23 ++ hub/servers-run.test.ts | 354 +++++++++++++++++++ hub/servers-run.ts | 183 ++++++++++ hub/session.ts | 6 +- src/api/hub-run.ts | 26 ++ src/app/Shell.tsx | 9 +- src/lib/hub-all.tsx | 64 ++++ src/screens/blocking/AddDomainBar.tsx | 74 ++++ src/screens/blocking/AllServers.hub.test.tsx | 207 +++++++++++ src/screens/blocking/AllServersActions.tsx | 93 +++++ src/screens/blocking/BlockLists.tsx | 19 +- src/screens/blocking/Blocking.tsx | 6 +- src/screens/blocking/Overview.tsx | 19 +- src/screens/blocking/StatusPanel.tsx | 18 +- src/screens/blocking/blocking-state.ts | 10 +- src/screens/blocking/permissions.ts | 10 + src/screens/dashboard/BlockingMenu.tsx | 41 ++- src/screens/lists/Cache.hub.test.tsx | 51 ++- src/screens/lists/CacheAllServers.tsx | 65 ++++ src/screens/lists/Lists.tsx | 13 +- src/screens/logs/Logs.tsx | 27 +- src/screens/logs/QueryLogs.hubAll.test.tsx | 103 ++++++ src/screens/logs/QueryLogs.tsx | 11 +- src/ui/AskDialog.tsx | 92 +++++ src/ui/HubWriteHint.tsx | 9 +- 31 files changed, 1560 insertions(+), 40 deletions(-) create mode 100644 hub/servers-run.test.ts create mode 100644 hub/servers-run.ts create mode 100644 src/api/hub-run.ts create mode 100644 src/lib/hub-all.tsx create mode 100644 src/screens/blocking/AllServers.hub.test.tsx create mode 100644 src/screens/blocking/AllServersActions.tsx create mode 100644 src/screens/lists/CacheAllServers.tsx create mode 100644 src/screens/logs/QueryLogs.hubAll.test.tsx create mode 100644 src/ui/AskDialog.tsx diff --git a/dev/mock-api.mjs b/dev/mock-api.mjs index d26d011..30f3548 100644 --- a/dev/mock-api.mjs +++ b/dev/mock-api.mjs @@ -300,11 +300,19 @@ function route(path, q) { for (const [k, v] of q) { if (!(k in settings) || k === 'node' || k === 'token') continue const old = settings[k] + if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v } return ok(settings) } - case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) }) + case 'settings/forceUpdateBlockLists': return ok() + case 'settings/temporaryDisableBlocking': { + // Blocking is off until the time given, as on a server; `settings/set enableBlocking=true` turns it back on. + const minutes = Number(q.get('minutes') ?? 10) + settings.enableBlocking = false + settings.temporaryDisableBlockingTill = iso(Date.now() + minutes * 60_000) + return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill }) + } case 'user/createSingleUseToken': return ok({ token: 'mock-once' }) case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] }) case 'dhcp/leases/list': return ok({ leases }) diff --git a/docs/hub-deploy.md b/docs/hub-deploy.md index 9f25a89..c5b91b3 100644 --- a/docs/hub-deploy.md +++ b/docs/hub-deploy.md @@ -93,7 +93,7 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0 | Zones | View, Modify, Delete | зоны, записи, DNSSEC, импорт | | Cache | View, Delete | сброс кеша и удаление домена из кеша | | Allowed, Blocked | View, Modify, Delete | добавить, удалить, очистить, импортировать | - | Settings | View, Modify | сохранение настроек, временное отключение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты | + | Settings | View, Modify | сохранение настроек и URL списков блокировки, временное отключение блокировки, включение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты | | DnsClient | View, Modify | Import из DNS Client пишет ответ в зону | | DhcpServer | View, Modify, Delete | области и аренды | | Dashboard, Logs | View | чтобы этот токен мог читать то же, что `hub-reader`. Delete не нужен: удаление логов и статистики закрыто | @@ -107,7 +107,9 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0 Запись идёт на один выбранный сервер. В режиме «All servers» (Dashboard, Logs, Blocking › Overview) кнопки записи неактивны с подсказкой «Choose a server». -Какие роли хаба что могут: `operator` меняет кеш и списки Blocked и Allowed, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают. +Действия на несколько серверов сразу (этап 6): в «All servers» кнопки Block, Allow, Disable в Blocking › Overview, «Update lists on all servers», «Edit list URLs on all servers», «Remove from Blocked» и «Remove from Allowed», а также «Flush on all servers» и «Delete a domain on all servers» в Cache и Block/Allow в строках Query Logs открывают выбор серверов. По умолчанию отмечены все серверы с токеном записи; сервер без токена виден, но отключён, с причиной. Итог показывается по каждому серверу, «Retry failed» повторяет только неудавшиеся. Для этого нужны те же права на серверах: Blocked и Allowed (Modify, Delete), Cache (Delete), Settings (Modify: URL списков и переключатель блокировки). Роли `operator` и `admin`; в аудит попадает по строке на сервер, список URL в аудит не пишется. Заменённый список URL полностью перезаписывает прежний на каждом выбранном сервере. + +Какие роли хаба что могут: `operator` меняет кеш, списки Blocked и Allowed, переключатель блокировки (включить, временно отключить) и обновляет списки, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают. Хаб отклоняет всё, чего нет в таблице путей: `admin/*` и `user/*` (кроме собственного входа хаба), `logs/delete`, `logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup`, `settings/restore`, права на зоны (`zones/permissions/*`). Сохранение настроек с изменённым полем веб-службы сервера (`webService*`) или с полем пользователей, SSO и LDAP хаб тоже отклоняет с сообщением «Change the web service settings in that server's own console.» diff --git a/docs/hub-management-plan.md b/docs/hub-management-plan.md index 28f6f16..46d44ed 100644 --- a/docs/hub-management-plan.md +++ b/docs/hub-management-plan.md @@ -4,7 +4,7 @@ - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **упор на Apps**: приложения на всех серверах и синхронизация их конфигов. -Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения) и 5 (управление каждым сервером) сделаны. Этапы 6 и далее пока планы (`docs/hub-deploy.md`). +Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером) и 6 (действия на все серверы вне Apps) сделаны. Этапы 7 и далее пока планы (`docs/hub-deploy.md`). --- diff --git a/hub/README.md b/hub/README.md index edc92ea..786f0e6 100644 --- a/hub/README.md +++ b/hub/README.md @@ -58,7 +58,7 @@ atomically with mode 0600. The running hub notices a change on the next request | `apps-admin` | read, apps.write | | `admin` | everything, including audit.read | -`apps.write` is what the app writes below need; `operate` and the rest are still unused. Every response also carries +`apps.write` is what the app writes below need; `operate` and `admin` are what the per-server and several-server changes need. Every response also carries `hubRole` (and `info.hubRole`). ## Login protection @@ -86,6 +86,31 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo Everything else answers `403 The hub is read-only.` and is audited. +## Changing several servers at once + +`POST hub/servers/run` (`write-all`, role with `operate`) runs one fixed action on the chosen servers, in parallel, each with +that server's admin token. Parameters: `action`, `targets` (comma separated server ids) and the action's own `domain`, +`minutes` or `urls` (lines in the grammar of the block lists, up to 300: ``, `!` for an allow list, `#...` for a switched-off list or a heading; http, https or file URLs; a line with a comma is refused). The answer is `{results: [{server, ok, message}]}`, +one per server; a server with no admin token or one that does not answer fails on its own and the others still run. Empty +targets or an unknown server are refused with `400`, and so is a bad parameter, before any server is called. + +| action | Technitium call | parameters sent | +|---|---|---| +| `blocked.add`, `blocked.delete` | `blocked/add`, `blocked/delete` | `domain` | +| `allowed.add`, `allowed.delete` | `allowed/add`, `allowed/delete` | `domain` | +| `blocking.disable` | `settings/temporaryDisableBlocking` | `minutes` (1 to 1440) | +| `blocking.enable` | `settings/set` | `enableBlocking=true` only | +| `blocking.updateLists` | `settings/forceUpdateBlockLists` | none | +| `blocking.setListUrls` | `settings/set` | `blockListUrls` only (comma joined) | +| `cache.flush` | `cache/flush` | none | +| `cache.delete` | `cache/delete` | `domain` | + +The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest (the per-server +`settings/set` of any other field still needs `admin`). `settings/set` with `enableBlocking` as its only field is `operate` +too, so an operator can use the blocking switch on one server. The audit log gets one line per server with the action, the +server, the domain or `minutes=N` or `urls=N` and the outcome, never the URL list. On the servers, the hub-admin needs +Blocked and Allowed Modify and Delete, Cache Delete, and Settings Modify (list URLs, blocking switch). + ## Managing one server The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a diff --git a/hub/routes.ts b/hub/routes.ts index 5913eee..c7460f4 100644 --- a/hub/routes.ts +++ b/hub/routes.ts @@ -1,7 +1,8 @@ /* What each `/api/` does and who may call it. `kind` says what it touches: `read` (no change), `write-one` (changes one server) and `write-all` (changes every server). -A path that is not listed is refused. The app writes need `apps.write`. +A path that is not listed is refused. The app writes need `apps.write`. `hub/servers/run` (blocking, Blocked/Allowed and cache on several +servers at once) needs `operate`. The sections a server is managed from (stage 5) are listed by what the console calls. A write there goes to ONE server (`node=`) with that server's admin token. Capabilities: @@ -116,7 +117,7 @@ export const ROUTES: Record = { 'dnsClient/resolve': readOne(), // Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node. 'settings/getTsigKeyNames': readOne(), - 'settings/set': writeOne('admin'), + 'settings/set': writeOne('admin'), // see `effectiveRoute` for the blocking switch 'settings/temporaryDisableBlocking': writeOne('operate'), 'settings/forceUpdateBlockLists': writeOne('operate'), // Logs (no delete). @@ -156,6 +157,8 @@ export const ROUTES: Record = { 'hub/apps/import': writeAll('apps.write'), 'hub/apps/run': writeAll('apps.write'), 'hub/apps/upload': writeAll('apps.write'), + // The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions. + 'hub/servers/run': writeAll('operate'), } export function routeFor(apiPath: string): Route | undefined { @@ -173,6 +176,15 @@ export function effectiveRoute(apiPath: string, params: Record): const flag = (params.import ?? '').trim().toLowerCase() if (flag !== '' && flag !== 'false') return writeOne('admin') } + /* + The blocking switch is blocking, not server settings: a `settings/set` that carries nothing but + `enableBlocking` needs `operate`, as the temporary switch does. Any other field in the call + makes it the usual `admin` write. (`node` and `token` are the hub's own.) + */ + if (apiPath === 'settings/set') { + const fields = Object.keys(params).filter((k) => k !== 'node' && k !== 'token') + if (fields.length === 1 && fields[0] === 'enableBlocking') return writeOne('operate') + } return routeFor(apiPath) } diff --git a/hub/server-write.test.ts b/hub/server-write.test.ts index 5d4b65c..15b3ad7 100644 --- a/hub/server-write.test.ts +++ b/hub/server-write.test.ts @@ -196,13 +196,17 @@ const OPERATE = [ ['allowed/delete', { domain: 'ok.example' }], ['settings/temporaryDisableBlocking', { minutes: '5' }], ['settings/forceUpdateBlockLists', {}], + // The blocking switch alone is blocking, not server settings (routes.ts `effectiveRoute`). + ['settings/set', { enableBlocking: 'false' }], ] as const const ADMIN = [ ['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }], ['zones/delete', { zone: 'example.com' }], ['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }], ['dhcp/scopes/enable', { name: 'Default' }], - ['settings/set', { enableBlocking: 'true' }], + ['settings/set', { dnsServerDomain: 'x.example' }], + // The switch plus any other field is a settings change. + ['settings/set', { enableBlocking: 'true', dnsServerDomain: 'x.example' }], ] as const describe('who may write what', () => { diff --git a/hub/server.ts b/hub/server.ts index 949cd82..09605d2 100644 --- a/hub/server.ts +++ b/hub/server.ts @@ -27,6 +27,7 @@ import { Audit } from './audit.ts' import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts' import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts' import { buildMatrix } from './apps-matrix.ts' +import { ServersRunner, planAction } from './servers-run.ts' import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts' import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { AppsConfig, UserError } from './apps-config.ts' @@ -144,6 +145,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da const limiter = new LoginLimiter() const audit = new Audit(config.dataDir) const writer = new AppsWriter(config, upstream) + const runner = new ServersRunner(config, upstream) const appsConfig = new AppsConfig(config, upstream) const byId = new Map(config.servers.map((s) => [s.id, s])) @@ -384,6 +386,27 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da await configWrite(res, apiPath, params, who, ip) return } + if (apiPath === 'hub/servers/run') { + const planned = planAction(params.action, params) + const line = { user: who.user, role: who.role, ip, action: 'error' in planned ? 'servers.run' : `servers.${planned.plan.action}` } + if ('error' in planned) { + audit.log({ ...line, outcome: 'refused', detail: planned.error }) + send(res, 400, fail(planned.error)) + return + } + const outcome = await runner.run(planned.plan, csv(params.targets)) + if ('error' in outcome) { + audit.log({ ...line, target: planned.plan.target, outcome: 'refused', detail: outcome.error }) + send(res, 400, fail(outcome.error)) + return + } + // One line per server. The target is a domain or a count, never the list of URLs. + for (const r of outcome.results) { + audit.log({ ...line, server: r.server, target: planned.plan.target, outcome: r.ok ? 'ok' : 'failed', detail: r.message }) + } + send(res, 200, ok({ results: outcome.results })) + return + } if (apiPath === 'hub/apps/run') { const action = params.action if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') { diff --git a/hub/servers-run.test.ts b/hub/servers-run.test.ts new file mode 100644 index 0000000..eb095ad --- /dev/null +++ b/hub/servers-run.test.ts @@ -0,0 +1,354 @@ +// @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') + }) +}) diff --git a/hub/servers-run.ts b/hub/servers-run.ts new file mode 100644 index 0000000..6412ea0 --- /dev/null +++ b/hub/servers-run.ts @@ -0,0 +1,183 @@ +/* +Actions on several servers at once, outside Apps (stage 6): Blocked and Allowed, the temporary +blocking switch, the block lists, and the cache. One endpoint, `hub/servers/run`, takes a fixed +`action` and the action's own parameters, checks them HERE, and runs the one matching Technitium +call on every chosen server, in parallel, with that server's admin token. + + * `planAction` is pure: the allowlist, the validation, and the path and parameters to send. + * `ServersRunner` runs a plan on targets. A server that is unknown or has no admin token fails + on its own; the others still run. The answer is one result per server, never a bare "ok". + +The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest, +not `admin` like the per-server `settings/set`. Only `blockListUrls` is sent, so nothing else of +the server's settings is touched, and the plan still goes through the checks of guards.ts. +*/ +import type { HubConfig } from './config.ts' +import { isForbiddenSettingsField } from './guards.ts' +import { Upstream } from './upstream.ts' + +export const SERVER_ACTIONS = [ + 'blocked.add', + 'blocked.delete', + 'allowed.add', + 'allowed.delete', + 'blocking.disable', + 'blocking.enable', + 'blocking.updateLists', + 'blocking.setListUrls', + 'cache.flush', + 'cache.delete', +] as const + +export type ServerAction = (typeof SERVER_ACTIONS)[number] + +export interface TargetResult { + server: string + ok: boolean + message?: string +} + +export interface Plan { + action: ServerAction + /* The Technitium endpoint. */ + path: string + /* Exactly what is sent (a form body). */ + params: Record + /* What the audit line may say about it: a domain, `minutes=15`, `urls=3`. Never the URLs. */ + target?: string + /* The sentence for a server where it worked. */ + done: string +} + +export const MAX_MINUTES = 1440 +const MAX_URL_LENGTH = 2000 + +const LABEL = '[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?' +const DOMAIN = new RegExp(`^(?:\\*\\.)?(?:${LABEL}\\.)*${LABEL}$`, 'i') + +/* A domain as the hub accepts it (a trailing dot is dropped), or null. */ +export function cleanDomain(value: string | undefined): string | null { + const domain = (value ?? '').trim().replace(/\.$/, '') + return domain.length > 0 && domain.length <= 253 && DOMAIN.test(domain) ? domain : null +} + +/* +The lines of a block list request, one per line, in the grammar of the servers and of the console's +Lists tab (src/screens/blocking/list-lines.ts): `` a block list, `!` an allow list, +`#...` a switched-off list or a comment (a group heading), free text. Each URL is http, https or +file. Blank lines go. They travel as the console's own Settings save sends them: joined with a +comma. A comma cannot be told from the separator, so a line that has one is refused, not split. +*/ +export const MAX_LINES = 300 +export const MAX_COMMENT = 200 +const SCHEMES = new Set(['http:', 'https:', 'file:']) + +export function cleanUrls(value: string | undefined): { urls: string[] } | { error: string } { + const lines = (value ?? '') + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l !== '') + if (lines.length === 0) return { error: 'Give at least one block list URL.' } + if (lines.length > MAX_LINES) return { error: `At most ${MAX_LINES} lines.` } + for (const line of lines) { + if (line.includes(',')) return { error: `A line cannot contain a comma: ${line.slice(0, 60)}` } + if (line.startsWith('#')) { + if (line.length > MAX_COMMENT) return { error: 'A comment line is too long.' } + continue + } + if (line.length > MAX_URL_LENGTH) return { error: 'A block list URL is too long.' } + const text = line.startsWith('!') ? line.slice(1).trim() : line + let url: URL + try { + url = new URL(text) + } catch { + return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` } + } + if (!SCHEMES.has(url.protocol) || /\s/.test(text)) return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` } + } + return { urls: lines } +} + +export function planAction(action: string | undefined, raw: Record): { error: string } | { plan: Plan } { + if (action === undefined || !(SERVER_ACTIONS as readonly string[]).includes(action)) { + return { error: `action must be one of: ${SERVER_ACTIONS.join(', ')}.` } + } + const kind = action as ServerAction + switch (kind) { + case 'blocked.add': + case 'blocked.delete': + case 'allowed.add': + case 'allowed.delete': + case 'cache.delete': { + const domain = cleanDomain(raw.domain) + if (domain === null) return { error: 'A valid domain name is required.' } + const [list, verb] = kind.split('.') + const path = list === 'cache' ? 'cache/delete' : `${list}/${verb}` + const done = + kind === 'cache.delete' ? 'Deleted from the cache.' : `${verb === 'add' ? 'Added to' : 'Removed from'} ${list === 'blocked' ? 'Blocked' : 'Allowed'}.` + return { plan: { action: kind, path, params: { domain }, target: domain, done } } + } + case 'blocking.disable': { + const text = (raw.minutes ?? '').trim() + const minutes = /^\d{1,5}$/.test(text) ? Number(text) : 0 + if (minutes < 1 || minutes > MAX_MINUTES) return { error: `minutes must be a whole number from 1 to ${MAX_MINUTES}.` } + return { + plan: { action: kind, path: 'settings/temporaryDisableBlocking', params: { minutes: String(minutes) }, target: `minutes=${minutes}`, done: `Blocking disabled for ${minutes} minute(s).` }, + } + } + case 'blocking.enable': + return settingsPlan(kind, { enableBlocking: 'true' }, undefined, 'Blocking enabled.') + case 'blocking.setListUrls': { + const cleaned = cleanUrls(raw.urls) + if ('error' in cleaned) return cleaned + return settingsPlan(kind, { blockListUrls: cleaned.urls.join(',') }, `urls=${cleaned.urls.length}`, `Block list URLs replaced (${cleaned.urls.length} line(s)).`) + } + case 'blocking.updateLists': + return { plan: { action: kind, path: 'settings/forceUpdateBlockLists', params: {}, done: 'Block list update started.' } } + case 'cache.flush': + return { plan: { action: kind, path: 'cache/flush', params: {}, done: 'Cache flushed.' } } + } +} + +/* A `settings/set` of one field. The same checks as the per-server route, applied to what the hub itself builds. */ +function settingsPlan(action: ServerAction, params: Record, target: string | undefined, done: string): { error: string } | { plan: Plan } { + if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return { error: 'That setting is not changed from the hub.' } + return { plan: { action, path: 'settings/set', params, target, done } } +} + +const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') + +export class ServersRunner { + #config: HubConfig + #upstream: Upstream + + constructor(config: HubConfig, upstream: Upstream) { + this.#config = config + this.#upstream = upstream + } + + /* + The plan on every target, in parallel. Returns `{ error }` when the request itself is refused + (no targets, an unknown server), else one result per target. + */ + async run(plan: Plan, targets: string[]): Promise<{ error: string } | { results: TargetResult[] }> { + const known = new Map(this.#config.servers.map((s) => [s.id, s])) + const unique = [...new Set(targets)] + if (unique.length === 0) return { error: 'Choose at least one server.' } + const unknown = unique.filter((t) => !known.has(t)) + if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` } + const results = await Promise.all( + unique.map(async (id): Promise => { + const server = known.get(id)! + if (server.adminToken === undefined) return { server: id, ok: false, message: 'no admin token configured' } + try { + await this.#upstream.write(server, plan.path, plan.params) + return { server: id, ok: true, message: plan.done } + } catch (e) { + return { server: id, ok: false, message: reason(e) } + } + }), + ) + return { results } + } +} diff --git a/hub/session.ts b/hub/session.ts index f13e095..732ea85 100644 --- a/hub/session.ts +++ b/hub/session.ts @@ -35,9 +35,9 @@ and is always there. Administration (users, SSO, LDAP of each server) is not off What a role may change comes from its capabilities, per key. Settings never get `canDelete` (that is the backup and restore, which the hub does not forward), nor do the Logs and the Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs -`admin` to modify because its Import writes a zone. The Settings key also gates the -temporary blocking switch in the console, which the hub itself would let an operator use: -the console is the stricter of the two there. +`admin` to modify because its Import writes a zone. The blocking switch and the block list update are blocking, +so behind a hub the console asks for Blocked/Allowed Modify there (which an operator has), not for Settings Modify (which +only an admin has). Saving the list URLs of one server stays a settings change. */ const PERMISSION_KEYS = [ 'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs', diff --git a/src/api/hub-run.ts b/src/api/hub-run.ts new file mode 100644 index 0000000..ed3a50f --- /dev/null +++ b/src/api/hub-run.ts @@ -0,0 +1,26 @@ +import { apiRequest } from './client' +import type { RunOutcome } from './apps' + +/* +The same change on several servers, outside Apps (hub/servers-run.ts). The hub checks the +parameters and sends the one matching call to each target with that server's admin token; the +answer is a result per server. +*/ +export type ServerAction = + | 'blocked.add' + | 'blocked.delete' + | 'allowed.add' + | 'allowed.delete' + | 'blocking.disable' + | 'blocking.enable' + | 'blocking.updateLists' + | 'blocking.setListUrls' + | 'cache.flush' + | 'cache.delete' + +/** What an action needs besides the servers: a `domain`, `minutes`, or `urls` (one per line). */ +export type ServerParams = Partial> + +export function runOnServers(token: string | null, action: ServerAction, params: ServerParams, targets: string[]): Promise { + return apiRequest('hub/servers/run', { token, method: 'POST', body: { action, ...params, targets: targets.join(',') } }) +} diff --git a/src/app/Shell.tsx b/src/app/Shell.tsx index 4171e1d..712c600 100644 --- a/src/app/Shell.tsx +++ b/src/app/Shell.tsx @@ -33,6 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect' import { HubWritable } from '../lib/hub-write' +import { HubServerIds } from '../lib/hub-all' import { FooterLinks } from '../ui/FooterLinks' import { Versions } from './Versions' import { BlockingMark } from './BlockingMark' @@ -141,6 +142,8 @@ export function Shell({ const hub = session.info?.hub === true /* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */ const hubWritable = session.info?.hubWritable ?? NO_SERVERS + /* And every server it fronts, for the picker of a change made on several at once. */ + const hubServers = useMemo(() => (session.info?.clusterNodes ?? []).map((n) => n.name), [session.info?.clusterNodes]) const hubWarnings = useHubWarnings() /* Behind a hub: the count comes with the session, the list is read now and then. */ const hubDriftAtStart = session.info?.hubDrift @@ -276,7 +279,7 @@ export function Shell({ const current = sections.find((s) => s.id === active) ?? sections[0] /* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */ - const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking')) + const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub) /* The jumps other screens' row menus make (`app/handoff.ts`). The section switch @@ -429,6 +432,7 @@ export function Shell({ + {(slot) => (
@@ -664,7 +668,7 @@ export function Shell({ token={session.token} nodes={session.info?.clusterNodes ?? []} clusterInitialised={session.info?.clusterInitialized === true} - canBlock={hub ? permissions?.Settings?.canModify !== false : true} + canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true} /> ) : current?.id === 'dnsclient' ? ( )} + diff --git a/src/lib/hub-all.tsx b/src/lib/hub-all.tsx new file mode 100644 index 0000000..85343c5 --- /dev/null +++ b/src/lib/hub-all.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useState, type ReactNode } from 'react' +import { runOnServers, type ServerAction, type ServerParams } from '../api/hub-run' +import { RunFlow, pickerServers, type RunRequest } from '../screens/apps/RunFlow' +import { HubWritable } from './hub-write' + +/* +Behind a hub, a change that belongs on several servers at once (Blocked and Allowed, the blocking +switch, the block lists, the cache) goes through the same three steps as the apps: pick the +servers, wait, read what happened on each, with "Retry failed". This is the glue: + + · `HubServerIds`: every server the hub fronts, given by the shell. The picker lists all of them + and disables the ones the hub holds no admin token for, with the reason. + · `useAllServersFlow`: `start(spec)` opens the flow, `flow` is the element to render. + · `HubAll`: what a section provides to the controls inside it when "All servers" is chosen and + the role may make the change. A control that finds `null` there behaves as it always did. +*/ +export const HubServerIds = createContext([]) + +export interface AllSpec { + action: ServerAction + params?: ServerParams + title: string + intro?: ReactNode + confirmLabel?: string + /** The servers ticked at the start; every writable one when absent. */ + preselected?: string[] + /** After the flow closes, told whether any server changed. */ + onDone?: (changed: boolean) => void +} + +export type StartAll = (spec: AllSpec) => void + +export const HubAll = createContext(null) + +export function useAllServersFlow(token: string | null): { start: StartAll; flow: ReactNode } { + const ids = useContext(HubServerIds) + const writable = useContext(HubWritable) + const [open, setOpen] = useState<{ spec: AllSpec; request: RunRequest } | null>(null) + + const start: StartAll = (spec) => { + const request: RunRequest = { + title: spec.title, + intro: spec.intro, + confirmLabel: spec.confirmLabel ?? 'Run', + servers: pickerServers(ids.map((id) => ({ id, writable: writable.includes(id) }))), + preselected: spec.preselected ?? ids.filter((id) => writable.includes(id)), + run: (targets) => runOnServers(token, spec.action, spec.params ?? {}, targets), + } + setOpen({ spec, request }) + } + + const flow = + open === null ? null : ( + { + const done = open.spec.onDone + setOpen(null) + done?.(changed) + }} + /> + ) + return { start, flow } +} diff --git a/src/screens/blocking/AddDomainBar.tsx b/src/screens/blocking/AddDomainBar.tsx index d2fec9e..989f4a0 100644 --- a/src/screens/blocking/AddDomainBar.tsx +++ b/src/screens/blocking/AddDomainBar.tsx @@ -1,10 +1,13 @@ import { useContext, useId, useRef, useState } from 'react' import { HubNode, nodeArg } from '../../lib/hub-write' +import { HubAll } from '../../lib/hub-all' import { addDomain, type DomainList } from '../../api/zonelists' import { Panel, Body } from '../../ui/Panel' import { Field, Input } from '../../ui/Field' import { Loading } from '../../ui/Empty' import { PermissionButton } from '../../ui/PermissionButton' +import { Menu } from '../../ui/Menu' +import { LockedItem } from './Locked' import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' import { noticeFromFailure, type Notice } from '../../lib/notice' import { checkDomain, verdictOf, type Verdict } from './check-domain' @@ -54,6 +57,8 @@ export function AddDomainBar({ }) { /* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */ const node = useContext(HubNode) + /* With "All servers" chosen the verbs open the flow that makes the change on the servers picked next. */ + const all = useContext(HubAll) const [field, setField] = useState('') const [busy, setBusy] = useState(false) const entry = useRef(null) @@ -122,6 +127,24 @@ export function AddDomainBar({ return } + if (all !== null) { + const verb = list === 'allowed' ? 'Allowed' : 'Blocked' + all({ + action: `${list}.add`, + params: { domain: domain.trim() }, + title: `Add '${domain.trim()}' to ${verb} on several servers`, + confirmLabel: `Add to ${verb}`, + onDone: (changed) => { + if (changed) { + setField('') + forget() + onChanged(list, domain) + } + }, + }) + return + } + setBusy(true) const outcome = await addDomain(list, token, domain, ...nodeArg(node)) setBusy(false) @@ -140,6 +163,30 @@ export function AddDomainBar({ onChanged(list, domain) } + /* Removing a rule from Blocked or Allowed: only offered for several servers at once, since on one server the Rules tab does it. */ + function remove(list: DomainList) { + const domain = field.trim() + if (domain === '') { + onNotice({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to remove.' }) + entry.current?.focus() + return + } + const verb = list === 'allowed' ? 'Allowed' : 'Blocked' + all?.({ + action: `${list}.delete`, + params: { domain }, + title: `Remove '${domain}' from ${verb} on several servers`, + confirmLabel: `Remove from ${verb}`, + onDone: (changed) => { + if (changed) { + setField('') + forget() + onChanged(list, domain) + } + }, + }) + } + return ( @@ -192,6 +239,33 @@ export function AddDomainBar({ > Check + {/* One menu, not two more buttons: the row has no room for five at desktop width. */} + {all !== null && ( + + {(close) => + (['blocked', 'allowed'] as const).map((list) => { + const verb = list === 'allowed' ? 'Allowed' : 'Blocked' + const need = missing(permissions, list === 'allowed' ? 'Allowed.canModify' : 'Blocked.canModify') + return need != null ? ( + + From {verb} + + ) : ( + + ) + }) + } + + )} {/* One live region for the whole answer: the wait and the verdict that replaces it are announced once each. */} diff --git a/src/screens/blocking/AllServers.hub.test.tsx b/src/screens/blocking/AllServers.hub.test.tsx new file mode 100644 index 0000000..a86ff5e --- /dev/null +++ b/src/screens/blocking/AllServers.hub.test.tsx @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as dashboard from '../../api/dashboard' +import * as settings from '../../api/settings' +import * as apps from '../../api/apps' +import * as hubRun from '../../api/hub-run' +import { Overview } from './Overview' +import { BlockLists } from './BlockLists' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { HubNode, HubWritable } from '../../lib/hub-write' +import { HubServerIds } from '../../lib/hub-all' +import type { Permissions } from './permissions' +import { checkUrls } from './AllServersActions' + +vi.mock('../dashboard/Chart', () => ({ Chart: ({ aria }: { aria: string }) =>
})) + +afterEach(() => { + vi.restoreAllMocks() + localStorage.clear() +}) + +const NODES = [ + { name: 'mdns', type: 'Primary' }, + { name: 'ndns', type: 'Secondary' }, +] + +/* An operator: blocking and cache yes, settings no (what the hub session says). */ +const OPERATOR: Permissions = { + Dashboard: { canView: true, canModify: false, canDelete: false }, + Settings: { canView: true, canModify: false, canDelete: false }, + Blocked: { canView: true, canModify: true, canDelete: true }, + Allowed: { canView: true, canModify: true, canDelete: true }, + DnsClient: { canView: true, canModify: false, canDelete: false }, +} + +const STATS = { + totalQueries: 100, totalNoError: 0, totalServerFailure: 0, totalNxDomain: 0, totalRefused: 0, + totalAuthoritative: 0, totalRecursive: 0, totalCached: 0, totalBlocked: 10, totalDropped: 0, + totalClients: 1, zones: 0, cachedEntries: 0, allowedZones: 0, blockedZones: 0, allowListZones: 0, blockListZones: 0, +} +const MAIN = { labels: ['a'], datasets: [{ label: 'Total', data: [1] }, { label: 'Blocked', data: [1] }] } + +function serve() { + vi.spyOn(settings, 'getSettings').mockResolvedValue({ enableBlocking: true } as never) + vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [] } } } as never) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ + kind: 'ok', + data: { stats: STATS, mainChartData: MAIN, queryResponseChartData: MAIN, queryTypeChartData: MAIN, protocolTypeChartData: MAIN, topClients: [], topDomains: [], topBlockedDomains: [] }, + } as never) +} + +const results = (...r: { server: string; ok: boolean; message?: string }[]) => ({ kind: 'ok', data: { response: { results: r } } }) as never + +function hubOverview(writable = ['mdns', 'ndns'], permissions: Permissions = OPERATOR) { + serve() + return render( + + + + + + + , + ) +} + +describe('Blocking overview behind a hub, in All servers', () => { + it('says the change goes to the servers chosen next, and keeps Block and Allow enabled', async () => { + hubOverview() + expect(await screen.findByText(/goes to the servers you choose in the next step/)).toBeInTheDocument() + expect(screen.queryByText(/Choose a server to make changes/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Block' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Allow' })).toBeEnabled() + }) + + it('Block opens the picker, runs on the ticked servers and retries only the failed', async () => { + const run = vi + .spyOn(hubRun, 'runOnServers') + .mockResolvedValueOnce(results({ server: 'mdns', ok: true, message: 'Added to Blocked.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' })) + .mockResolvedValueOnce(results({ server: 'ndns', ok: true, message: 'Added to Blocked.' })) + hubOverview() + await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example') + await userEvent.click(screen.getByRole('button', { name: 'Block' })) + const picker = await screen.findByRole('dialog') + expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeChecked() + expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeChecked() + await userEvent.click(within(picker).getByRole('button', { name: 'Add to Blocked' })) + expect(run).toHaveBeenNthCalledWith(1, 't', 'blocked.add', { domain: 'ads.example' }, ['mdns', 'ndns']) + const list = await screen.findByRole('list', { name: 'Results' }) + expect(within(list).getByText('ndns did not answer')).toBeInTheDocument() + expect(within(list).getAllByText('Failed')).toHaveLength(1) + await userEvent.click(screen.getByRole('button', { name: 'Retry failed' })) + expect(run).toHaveBeenNthCalledWith(2, 't', 'blocked.add', { domain: 'ads.example' }, ['ndns']) + expect(await within(await screen.findByRole('list', { name: 'Results' })).findAllByText('Done')).toHaveLength(2) + }) + + it('disables a server the hub holds no admin token for, with the reason', async () => { + hubOverview(['mdns']) + await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example') + await userEvent.click(screen.getByRole('button', { name: 'Allow' })) + const picker = await screen.findByRole('dialog') + expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled() + expect(within(picker).getByText('No admin token configured on the hub')).toBeInTheDocument() + expect(within(picker).getByRole('checkbox', { name: 'mdns' })).toBeChecked() + }) + + it('removes a domain from Blocked on several servers', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true })) + hubOverview() + await userEvent.type(await screen.findByLabelText('Domain'), 'ads.example') + await userEvent.click(screen.getByRole('button', { name: 'Remove' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'From Blocked' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Remove from Blocked' })) + expect(run).toHaveBeenCalledWith('t', 'blocked.delete', { domain: 'ads.example' }, ['mdns', 'ndns']) + }) + + it('lets an operator use the blocking switch, and runs a temporary disable on all servers', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true })) + hubOverview() + const trigger = await screen.findByRole('button', { name: 'Blocking options' }) + expect(trigger).toBeEnabled() + await userEvent.click(trigger) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Disable Blocking For 15 Minutes' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Disable blocking' })) + expect(run).toHaveBeenCalledWith('t', 'blocking.disable', { minutes: '15' }, ['mdns', 'ndns']) + }) + + it('updates the lists on all servers', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true })) + hubOverview() + await userEvent.click(await screen.findByRole('button', { name: 'Update lists on all servers' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Update lists' })) + expect(run).toHaveBeenCalledWith('t', 'blocking.updateLists', {}, ['mdns', 'ndns']) + }) + + it('edits the list URLs on all servers, refusing an address that is not http or https', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(results({ server: 'mdns', ok: true }, { server: 'ndns', ok: true })) + hubOverview() + await userEvent.click(await screen.findByRole('button', { name: 'Edit list URLs on all servers' })) + const ask = await screen.findByRole('dialog') + const field = within(ask).getByLabelText('Block list URLs') + await userEvent.type(field, 'ftp://bad.example/x') + await userEvent.click(within(ask).getByRole('button', { name: 'Choose servers' })) + expect(await within(ask).findByRole('alert')).toHaveTextContent(/Not a valid http, https or file address/) + expect(run).not.toHaveBeenCalled() + await userEvent.clear(field) + await userEvent.type(field, 'https://a.example/hosts.txt') + await userEvent.click(within(ask).getByRole('button', { name: 'Choose servers' })) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Replace URLs' })) + expect(run).toHaveBeenCalledWith('t', 'blocking.setListUrls', { urls: 'https://a.example/hosts.txt' }, ['mdns', 'ndns']) + }) + + it('offers nothing of this to a role that may not change blocking', async () => { + hubOverview(['mdns', 'ndns'], { + ...OPERATOR, + Blocked: { canView: true, canModify: false, canDelete: false }, + Allowed: { canView: true, canModify: false, canDelete: false }, + }) + await screen.findByLabelText('Domain') + expect(screen.queryByRole('button', { name: 'Update lists on all servers' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Block' })).toBeDisabled() + }) +}) + +describe('the blocking switch outside a hub', () => { + it('still asks for Settings: Modify, and offers nothing for several servers', async () => { + serve() + render() + expect(await screen.findByRole('button', { name: 'Disable' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Update lists on all servers' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument() + }) +}) + +describe('block lists behind a hub', () => { + it('lets an operator update now and offers the actions for all servers, with the URLs filled in', async () => { + vi.spyOn(settings, 'readSettings').mockResolvedValue({ kind: 'ok', data: { blockListUrls: ['https://a.example/x', '# note'], blockListNextUpdatedOn: null } } as never) + vi.spyOn(dashboard, 'getDashboardStats').mockResolvedValue({ kind: 'ok', data: { stats: STATS } } as never) + render( + + + + + + + + + , + ) + expect(await screen.findByRole('button', { name: 'Update lists on all servers' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Update Now' })).toBeEnabled() + await userEvent.click(screen.getByRole('button', { name: 'Edit list URLs on all servers' })) + expect(await screen.findByLabelText('Block list URLs')).toHaveValue('https://a.example/x\n# note') + }) +}) + +describe('the list dialog check', () => { + it('takes allow lines, switched-off lines and headings, and refuses a bad scheme after the bang or a comma', () => { + expect(checkUrls('# Privacy\nhttps://a.example/x\n!https://b.example/allow\n#https://c.example/off\nfile:///x.txt')).toBeNull() + expect(checkUrls('!ftp://b.example/allow')).toMatch(/Not a valid/) + expect(checkUrls('# a, b')).toMatch(/comma/) + expect(checkUrls('https://a.example/x,y')).toMatch(/comma/) + expect(checkUrls(' \n ')).toMatch(/at least one/) + }) +}) diff --git a/src/screens/blocking/AllServersActions.tsx b/src/screens/blocking/AllServersActions.tsx new file mode 100644 index 0000000..2475651 --- /dev/null +++ b/src/screens/blocking/AllServersActions.tsx @@ -0,0 +1,93 @@ +import { useContext, useState } from 'react' +import { useAllServersFlow } from '../../lib/hub-all' +import { Button } from '../../ui/Button' +import { AskDialog } from '../../ui/AskDialog' +import { HubMode } from '../../ui/ClusterNodeSelect' + +/* +Behind a hub: the block list actions that go to several servers. "Update lists" asks every chosen +server to download its lists now; "Edit list URLs" REPLACES the list of URLs on every chosen +server with the one typed here (one per line), so the dialog says so. The +caller shows this only to a role that may change blocking. Drawn nowhere outside a hub. +*/ + +/* +What the dialog starts with: the lines of the chosen server's list as it holds them, `!` allow +lists, `#` switched-off lists and headings included, so saving it back changes nothing. +*/ +export function plainUrls(lines: readonly string[] | null | undefined): string { + return (lines ?? []).map((l) => l.trim()).filter((l) => l !== '').join('\n') +} + +/* The hub's rules (hub/servers-run.ts `cleanUrls`), so the dialog and the hub give the same answer. */ +export function checkUrls(text: string): string | null { + const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '') + if (lines.length === 0) return 'Give at least one block list URL.' + if (lines.length > 300) return 'At most 300 lines.' + for (const line of lines) { + if (line.includes(',')) return `A line cannot contain a comma: ${line.slice(0, 60)}` + if (line.startsWith('#')) { + if (line.length > 200) return 'A comment line is too long.' + continue + } + const body = line.startsWith('!') ? line.slice(1).trim() : line + let ok = line.length <= 2000 && !/\s/.test(body) + if (ok) { + try { + ok = ['http:', 'https:', 'file:'].includes(new URL(body).protocol) + } catch { + ok = false + } + } + if (!ok) return `Not a valid http, https or file address: ${line.slice(0, 60)}` + } + return null +} + +export function AllServersActions({ token, initialUrls }: { token: string | null; initialUrls?: readonly string[] | null }) { + const hub = useContext(HubMode) + const { start, flow } = useAllServersFlow(token) + const [editing, setEditing] = useState(false) + if (!hub) return null + return ( + <> + + + {editing && ( + setEditing(false)} + onSubmit={(text) => { + setEditing(false) + start({ + action: 'blocking.setListUrls', + params: { urls: text }, + title: 'Replace block list URLs on several servers', + intro: 'The list of URLs on each chosen server is replaced by the one you typed.', + confirmLabel: 'Replace URLs', + }) + }} + /> + )} + {flow} + + ) +} diff --git a/src/screens/blocking/BlockLists.tsx b/src/screens/blocking/BlockLists.tsx index d865db1..99b9533 100644 --- a/src/screens/blocking/BlockLists.tsx +++ b/src/screens/blocking/BlockLists.tsx @@ -1,5 +1,7 @@ import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react' import { HubNode, nodeArg } from '../../lib/hub-write' +import { HubMode } from '../../ui/ClusterNodeSelect' +import { AllServersActions } from './AllServersActions' import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings' import { getDashboardStats } from '../../api/dashboard' import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' @@ -25,7 +27,7 @@ import { } from './list-lines' import { Locked } from './Locked' import { hostsOf, moreLists } from './extra-lists' -import { missing, type Permissions } from './permissions' +import { missing, switchNeed, type Permissions } from './permissions' import shared from './Blocking.module.css' import styles from './BlockLists.module.css' import { UrlText } from '../../ui/UrlText' @@ -202,11 +204,14 @@ export function BlockLists({ permissions, clusterInitialised = false, onDirty, + allServers = false, }: { tabs?: ReactNode token: string | null permissions: Permissions clusterInitialised?: boolean + /** Behind a hub: the role may change blocking on several servers at once, so the actions for that are offered. */ + allServers?: boolean /** Told whether there are unsaved changes, for the tab's mark; false on leaving. */ onDirty?: (dirty: boolean) => void }) { @@ -215,6 +220,9 @@ export function BlockLists({ const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : '' const viewNeed = missing(permissions, 'Settings.canView') const modifyNeed = missing(permissions, 'Settings.canModify') + /* Behind a hub the list update is a blocking action (an operator's), unlike saving the URLs, which is a settings change. */ + const hub = useContext(HubMode) + const updateNeed = switchNeed(permissions, hub) const statsNeed = missing(permissions, 'Dashboard.canView') const invalidId = useId() @@ -624,6 +632,7 @@ export function BlockLists({ )} +
{loading ? '—' : nextUpdateText(next)} @@ -633,13 +642,19 @@ export function BlockLists({
setAskUpdate(true)} > Update Now
+ {hub && allServers && ( +
+ l.raw)} /> +
+ )} +
diff --git a/src/screens/blocking/Blocking.tsx b/src/screens/blocking/Blocking.tsx index 05e0570..cd17b30 100644 --- a/src/screens/blocking/Blocking.tsx +++ b/src/screens/blocking/Blocking.tsx @@ -6,7 +6,7 @@ import { getSettings } from '../../api/settings' import { getDashboardStats } from '../../api/dashboard' import { BlockingMenu } from '../dashboard/BlockingMenu' import { Tag } from '../../ui/Tag' -import { missing, withoutWrites } from './permissions' +import { missing, switchNeed, withoutWrites } from './permissions' import { untilText, useBlockingState } from './blocking-state' import { SubTabs } from '../../ui/SubTabs' import { ruleSearch } from './rules-model' @@ -69,8 +69,9 @@ function useCounts(token: string | null, permissions: Permissions, node = '') { itself, so the strip is for the other two. */ function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) { const { state } = useBlockingState(token, permissions, true, node) + const hub = useContext(HubMode) if (state == null || state === 'failed') return null - const canModify = missing(permissions, 'Settings.canModify') == null + const canModify = switchNeed(permissions, hub) == null return (
{state.kind === 'on' && Enabled} @@ -186,6 +187,7 @@ export function Blocking({ permissions={effective} clusterInitialised={clusterInitialised} onDirty={setListsDirty} + allServers={hub && missing(permissions, 'Blocked.canModify', 'Allowed.canModify') == null} /> ) diff --git a/src/screens/blocking/Overview.tsx b/src/screens/blocking/Overview.tsx index 692a902..ec27517 100644 --- a/src/screens/blocking/Overview.tsx +++ b/src/screens/blocking/Overview.tsx @@ -22,6 +22,7 @@ import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive' import { Locked } from './Locked' import { missing, withoutWrites, type Permissions } from './permissions' import { HubNode, useHubWrite } from '../../lib/hub-write' +import { HubAll, useAllServersFlow } from '../../lib/hub-all' import { HubWriteHint } from '../../ui/HubWriteHint' import shared from './Blocking.module.css' import styles from './Overview.module.css' @@ -203,6 +204,15 @@ export function Overview({ const hub = useContext(HubMode) const write = useHubWrite(node) const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions) + /* + With "All servers" chosen a role that may change blocking still can: the controls of the status + block and the add bar open a flow that picks the servers (`HubAll`). The tables and the recent + list below keep the switched-off permissions, since a row acts on one server. + */ + const all = useAllServersFlow(token) + const allServers = + hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null + const barPermissions = allServers ? rolePermissions : permissions const [data, setData] = useState(null) /** The first read of this period and node failed: there is nothing to show. */ const [failure, setFailure] = useState(false) @@ -326,6 +336,8 @@ export function Overview({ return ( + + {all.flow} - + - + void load()} /> @@ -473,6 +485,7 @@ export function Overview({ refreshOn={s?.totalBlocked} />
+ ) } diff --git a/src/screens/blocking/StatusPanel.tsx b/src/screens/blocking/StatusPanel.tsx index da0c88e..bf81c0e 100644 --- a/src/screens/blocking/StatusPanel.tsx +++ b/src/screens/blocking/StatusPanel.tsx @@ -1,5 +1,7 @@ import { useContext } from 'react' import { HubNode } from '../../lib/hub-write' +import { HubAll } from '../../lib/hub-all' +import { HubMode } from '../../ui/ClusterNodeSelect' import { BlockingMenu } from '../dashboard/BlockingMenu' import { Panel, Body } from '../../ui/Panel' import { PermissionButton } from '../../ui/PermissionButton' @@ -9,7 +11,8 @@ import { Icon } from '../../ui/Icon' import { Tag } from '../../ui/Tag' import type { Notice } from '../../lib/notice' import { Locked } from './Locked' -import { missing, type Permissions } from './permissions' +import { missing, switchNeed, type Permissions } from './permissions' +import { AllServersActions } from './AllServersActions' import { untilText, useBlockingState } from './blocking-state' import shared from './Blocking.module.css' import styles from './Overview.module.css' @@ -49,10 +52,14 @@ export function StatusPanel({ /* Behind a hub: the server the section has chosen. */ const node = useContext(HubNode) const { state, retry } = useBlockingState(token, permissions, true, node) + /* Behind a hub the switch is a blocking change (Blocked and Allowed), not a settings one. With "All + servers" chosen it opens the flow, and the list actions for several servers sit beside it. */ + const hub = useContext(HubMode) + const all = useContext(HubAll) if (viewNeed != null) return - const modifyNeed = missing(permissions, 'Settings.canModify') + const modifyNeed = switchNeed(permissions, hub) if (state === 'failed') { return ( @@ -77,6 +84,7 @@ export function StatusPanel({ return ( +
{state == null ? ( ) : ( @@ -117,6 +125,12 @@ export function StatusPanel({ )}
)} + {state != null && all !== null && modifyNeed == null && ( +
+ +
+ )} +
) diff --git a/src/screens/blocking/blocking-state.ts b/src/screens/blocking/blocking-state.ts index e4c0162..ba306a7 100644 --- a/src/screens/blocking/blocking-state.ts +++ b/src/screens/blocking/blocking-state.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getSettings } from '../../api/settings' import { minuteStamp } from '../../lib/dates' import { onSettingsChanged } from '../../lib/settings-signal' -import { missing, type Permissions } from './permissions' +import { missing, switchNeed, type Permissions } from './permissions' /* Whether blocking is on, read where the Overview's status block reads it @@ -128,10 +128,10 @@ export interface BlockingMark { tone: 'acc' | 'neutral' } -export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date()): BlockingMark | null { +export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null { if (state == null || state === 'failed' || state.kind === 'on') return null if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' } - const canLift = missing(permissions, 'Settings.canModify') == null + const canLift = switchNeed(permissions, hub) == null return { text: 'Paused', label: `Blocking, paused ${untilText(state.till, now).replace(/^Until/, 'until')}`, @@ -140,7 +140,7 @@ export function blockingMark(state: BlockingRead, permissions: Permissions, now } /** The mark for the sidebar's Blocking entry; `visible` false when there is no entry. */ -export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean): BlockingMark | null { +export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false): BlockingMark | null { const { state } = useBlockingState(token, permissions, visible) - return blockingMark(state, permissions) + return blockingMark(state, permissions, new Date(), hub) } diff --git a/src/screens/blocking/permissions.ts b/src/screens/blocking/permissions.ts index 8d31d6d..b652bb2 100644 --- a/src/screens/blocking/permissions.ts +++ b/src/screens/blocking/permissions.ts @@ -19,6 +19,16 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined return undefined } +/* +What the blocking switch (the temporary disable, enable, and the block list update) asks for. +Upstream asks `Settings: Modify` of the call. Behind a hub those calls are blocking, which an +operator may do (`Blocked` and `Allowed` Modify), while `Settings: Modify` is an admin's: the +switch follows the blocking permission there, and is unchanged anywhere else. +*/ +export function switchNeed(perms: Permissions, hub: boolean): Need | undefined { + return hub ? missing(perms, 'Blocked.canModify', 'Allowed.canModify') : missing(perms, 'Settings.canModify') +} + /* Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server the hub holds no admin token for): the same permissions with every write switched off. The diff --git a/src/screens/dashboard/BlockingMenu.tsx b/src/screens/dashboard/BlockingMenu.tsx index 195a8d8..917e625 100644 --- a/src/screens/dashboard/BlockingMenu.tsx +++ b/src/screens/dashboard/BlockingMenu.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { useContext, useState } from 'react' import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings' import { Confirm } from '../../ui/Confirm' import { Menu } from '../../ui/Menu' import { Button } from '../../ui/Button' import { Tooltip } from '../../ui/Tooltip' import { nodeArg, useHubWrite } from '../../lib/hub-write' +import { HubAll } from '../../lib/hub-all' import type { AlertType } from '../../ui/Alert' import { noticeFromFailure } from '../../lib/notice' @@ -73,6 +74,8 @@ export function BlockingMenu({ const [active, setActive] = useState(null) const [pending, setPending] = useState(null) const [busy, setBusy] = useState(false) + /* Behind a hub with "All servers" chosen: the choice opens the flow that makes it on the servers picked next. */ + const all = useContext(HubAll) async function loadState() { setActive(null) @@ -137,6 +140,42 @@ export function BlockingMenu({ /* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */ const write = useHubWrite(node, allowed) + if (all !== null) { + return ( + + {(close) => ( + <> + + {DURATIONS.map((p) => ( + + ))} + + )} + + ) + } if (!write.allowed) { return ( diff --git a/src/screens/lists/Cache.hub.test.tsx b/src/screens/lists/Cache.hub.test.tsx index 7e23b01..d70ced3 100644 --- a/src/screens/lists/Cache.hub.test.tsx +++ b/src/screens/lists/Cache.hub.test.tsx @@ -5,6 +5,8 @@ import { Cache } from './Lists' import * as api from '../../api/zonelists' import { HubMode } from '../../ui/ClusterNodeSelect' import { HubWritable } from '../../lib/hub-write' +import { HubServerIds } from '../../lib/hub-all' +import * as hubRun from '../../api/hub-run' afterEach(() => vi.restoreAllMocks()) @@ -19,7 +21,9 @@ function hubCache(writable: string[], canDelete = true) { return render( - + + + , ) @@ -47,3 +51,48 @@ describe('Cache behind a hub', () => { expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled() }) }) + +describe('Cache behind a hub, on several servers', () => { + const done = (...r: { server: string; ok: boolean; message?: string }[]) => ({ kind: 'ok', data: { response: { results: r } } }) as never + + it('flushes the cache on the servers picked, and retries only the one that failed', async () => { + const user = userEvent.setup() + const run = vi + .spyOn(hubRun, 'runOnServers') + .mockResolvedValueOnce(done({ server: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' })) + .mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' })) + hubCache(['mdns', 'ndns']) + await user.click(await screen.findByRole('button', { name: 'Flush on all servers' })) + const picker = await screen.findByRole('dialog') + await user.click(within(picker).getByRole('button', { name: 'Flush cache' })) + expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns']) + const list = await screen.findByRole('list', { name: 'Results' }) + expect(within(list).getByText('ndns did not answer')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry failed' })) + expect(run).toHaveBeenNthCalledWith(2, 't', 'cache.flush', {}, ['ndns']) + }) + + it('deletes one domain on the servers picked, after asking for it', async () => { + const user = userEvent.setup() + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true })) + hubCache(['mdns']) + await user.click(await screen.findByRole('button', { name: 'Delete a domain on all servers' })) + const ask = await screen.findByRole('dialog') + await user.type(within(ask).getByLabelText('Domain'), 'bad domain') + await user.click(within(ask).getByRole('button', { name: 'Choose servers' })) + expect(await within(ask).findByRole('alert')).toHaveTextContent('not a valid domain') + await user.clear(within(ask).getByLabelText('Domain')) + await user.type(within(ask).getByLabelText('Domain'), 'x.example') + await user.click(within(ask).getByRole('button', { name: 'Choose servers' })) + const picker = await screen.findByRole('dialog') + expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled() + await user.click(within(picker).getByRole('button', { name: 'Delete from cache' })) + expect(run).toHaveBeenCalledWith('t', 'cache.delete', { domain: 'x.example' }, ['mdns']) + }) + + it('is not offered to a role that may not delete from the cache', async () => { + hubCache(['mdns', 'ndns'], false) + await screen.findByRole('button', { name: 'Flush Cache' }) + expect(screen.queryByRole('button', { name: 'Flush on all servers' })).not.toBeInTheDocument() + }) +}) diff --git a/src/screens/lists/CacheAllServers.tsx b/src/screens/lists/CacheAllServers.tsx new file mode 100644 index 0000000..6230b9e --- /dev/null +++ b/src/screens/lists/CacheAllServers.tsx @@ -0,0 +1,65 @@ +import { useContext, useState } from 'react' +import { useAllServersFlow } from '../../lib/hub-all' +import { AskDialog } from '../../ui/AskDialog' +import { Button } from '../../ui/Button' +import { HubMode } from '../../ui/ClusterNodeSelect' + +/* +Behind a hub: the cache actions that go to several servers. "Flush" empties the whole cache of +every chosen server; "Delete a domain" removes one domain, with its records, from each. Drawn +nowhere outside a hub, and by the caller only to a role that may delete from the cache. +*/ +const NAME = /^(?:\*\.)?(?:[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.)*[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?\.?$/i + +export const checkDomain = (text: string): string | null => { + const domain = text.trim() + if (domain === '') return 'Please enter a domain name.' + return domain.length <= 254 && NAME.test(domain) ? null : 'That is not a valid domain name.' +} + +export function CacheAllServers({ token }: { token: string | null }) { + const hub = useContext(HubMode) + const { start, flow } = useAllServersFlow(token) + const [asking, setAsking] = useState(false) + if (!hub) return null + return ( + <> + + + {asking && ( + setAsking(false)} + onSubmit={(text) => { + setAsking(false) + const domain = text.trim() + start({ + action: 'cache.delete', + params: { domain }, + title: `Delete '${domain}' from the cache on several servers`, + confirmLabel: 'Delete from cache', + }) + }} + /> + )} + {flow} + + ) +} diff --git a/src/screens/lists/Lists.tsx b/src/screens/lists/Lists.tsx index 380aaf8..0560f6b 100644 --- a/src/screens/lists/Lists.tsx +++ b/src/screens/lists/Lists.tsx @@ -1,5 +1,5 @@ import { KpiStrip } from '../../ui/KpiStrip' -import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect' +import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect' import { useCallback, useContext, useEffect, useRef, useState } from 'react' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubWriteHint } from '../../ui/HubWriteHint' @@ -24,6 +24,7 @@ import { SectionHeader } from '../../ui/SectionHeader' import { Empty, Loading } from '../../ui/Empty' import { StaleData } from '../StaleData' import { Tree } from './Tree' +import { CacheAllServers } from './CacheAllServers' import { Path } from './Path' import { CacheTree } from './CacheTree' import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame' @@ -247,6 +248,7 @@ export function Lists({ const sectionRef = useRef(sectionNode) const listServer = embedded ? sectionNode : '' /* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */ + const hub = useContext(HubMode) const cacheWrite = useHubWrite(clusterNode, canDelete) const listWrite = useHubWrite(listServer, canDelete) @@ -490,9 +492,12 @@ export function Lists({ subtitle={isCache ? 'Records cached by the recursive resolver' : undefined} actions={ isCache ? ( - + <> + + {hub && canDelete && } + ) : undefined } /> diff --git a/src/screens/logs/Logs.tsx b/src/screens/logs/Logs.tsx index 41ad266..72dbae2 100644 --- a/src/screens/logs/Logs.tsx +++ b/src/screens/logs/Logs.tsx @@ -1,5 +1,6 @@ import { useContext, useState } from 'react' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' +import { useAllServersFlow } from '../../lib/hub-all' import { HubWriteHint } from '../../ui/HubWriteHint' import { SubTabs } from '../../ui/SubTabs' import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' @@ -72,6 +73,9 @@ export function Logs({ const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked /* The Allow and Block actions of a row: one server at a time. */ const write = useHubWrite(node, canBlock) + /* With "All servers" chosen a row's Allow and Block open the flow, with the row's own server ticked. */ + const all = useAllServersFlow(token) + const allServers = aggregateShown && node === AGGREGATE && canBlock /* The bar is built once here and handed to whichever sub-screen is drawn: the same @@ -100,10 +104,29 @@ export function Logs({ aggregate={aggregateShown} label="Cluster Node" /> - {active === 'Query Logs' && } + {active === 'Query Logs' && } {active === 'Query Logs' ? ( - + + all.start({ + action: verb === 'Allow Domain' ? 'allowed.add' : 'blocked.add', + params: { domain }, + title: `${verb === 'Allow Domain' ? 'Allow' : 'Block'} '${domain}' on several servers`, + confirmLabel: verb === 'Allow Domain' ? 'Allow' : 'Block', + preselected: server != null && server !== '' ? [server] : undefined, + }) + : undefined + } + /> + {all.flow} ) : ( vi.restoreAllMocks()) + +const NODES = [ + { name: 'mdns', type: 'Primary' }, + { name: 'ndns', type: 'Secondary' }, +] + +function entry(rowNumber: number, node: string, responseType = 'Recursive'): QueryLogEntry { + return { + rowNumber, + timestamp: '2026-08-26T05:32:14Z', + clientIpAddress: '10.0.0.7', + protocol: 'Udp', + responseType, + rcode: 'NoError', + qname: `r${rowNumber}.example`, + qtype: 'A', + qclass: 'IN', + answer: null, + node, + } +} + +async function show(entries: QueryLogEntry[], canBlock = true) { + vi.spyOn(apps, 'listApps').mockResolvedValue({ + kind: 'ok', + data: { + status: 'ok', + response: { + apps: [ + { + name: 'Query Logs (Sqlite)', + description: '', + version: '8.0', + dnsApps: [{ classPath: 'QueryLogsSqlite.App', description: '', recordDataTemplate: null, isAppRecordRequestHandler: false, isRequestController: false, isAuthoritativeRequestHandler: false, isRequestBlockingHandler: false, isQueryLogger: true, isQueryLogs: true, isPostProcessor: false }], + }, + ], + }, + }, + } as never) + vi.spyOn(logs, 'queryLogs').mockResolvedValue({ + kind: 'ok', + data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } }, + } as never) + render( + + + + + + + + + , + ) + await userEvent.click(await screen.findByRole('button', { name: 'Query' })) + await screen.findByText('r1.example') +} + +describe('Query Logs behind a hub, in All servers', () => { + it('opens the flow from a row with that row\'s server ticked, and runs on it only', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'ndns', ok: true, message: 'Added to Blocked.' }] } } } as never) + await show([entry(1, 'mdns'), entry(2, 'ndns')]) + await userEvent.click(screen.getByRole('button', { name: 'Actions for row 2' })) + await userEvent.click(await screen.findByText('Block Domain')) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeChecked() + expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).not.toBeChecked() + await userEvent.click(within(dialog).getByRole('button', { name: 'Block' })) + expect(run).toHaveBeenCalledWith('t', 'blocked.add', { domain: 'r2.example' }, ['ndns']) + expect(await screen.findByText('Added to Blocked.')).toBeInTheDocument() + }) + + it('uses Allow for a blocked row', async () => { + const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue({ kind: 'ok', data: { response: { results: [{ server: 'mdns', ok: true }] } } } as never) + await show([entry(1, 'mdns', 'Blocked')]) + await userEvent.click(screen.getByRole('button', { name: 'Actions for row 1' })) + await userEvent.click(await screen.findByText('Allow Domain')) + await userEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Allow' })) + expect(run).toHaveBeenCalledWith('t', 'allowed.add', { domain: 'r1.example' }, ['mdns']) + }) + + it('keeps the actions off for a role that may not change blocking', async () => { + await show([entry(1, 'mdns')], false) + await userEvent.click(screen.getByRole('button', { name: 'Actions for row 1' })) + expect(await screen.findByText('Block Domain')).toBeDisabled() + }) +}) diff --git a/src/screens/logs/QueryLogs.tsx b/src/screens/logs/QueryLogs.tsx index baacc7c..7ea3c14 100644 --- a/src/screens/logs/QueryLogs.tsx +++ b/src/screens/logs/QueryLogs.tsx @@ -31,7 +31,7 @@ import { Notifier } from '../../ui/Notifier' import { Menu } from '../../ui/Menu' import tbl from '../../ui/Table.module.css' import { useHandoff, type QueryLogsRequest } from '../../app/handoff' -import { useDomainAction, verbFor } from '../../lib/allow-block' +import { useDomainAction, verbFor, type DomainVerb } from '../../lib/allow-block' import { useMedia } from '../../lib/use-media' import { Icon } from '../../ui/Icon' @@ -321,11 +321,13 @@ export interface QueryLogsProps { node?: string /** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */ canBlock?: boolean + /** Behind a hub with "All servers" chosen: a row's Allow or Block goes through this, with the row's server. */ + onAll?: (verb: DomainVerb, domain: string, server: string | undefined) => void /** `showQueryLogs` from another screen: these filters, queried on arrival. */ request?: QueryLogsRequest } -export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) { +export function QueryLogs({ tabs, token, node = '', request, canBlock = true, onAll }: QueryLogsProps) { const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null) const [f, setF] = useState(() => defaultFilters('', '')) const [page, setPage] = useState(null) @@ -1043,10 +1045,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: + } + > + {intro != null &&

{intro}

} + + {(id) => + multiline ? ( +