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 <[email protected]>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-31 22:50:09 +07:00
1 parent 8bb1e01936
commit 34de910e04
31 files changed
+1560 -40

No files matched your search

+9 -1
View File
@@ -300,11 +300,19 @@ function route(path, q) {
for (const [k, v] of q) { for (const [k, v] of q) {
if (!(k in settings) || k === 'node' || k === 'token') continue if (!(k in settings) || k === 'node' || k === 'token') continue
const old = settings[k] 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 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) 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 'user/createSingleUseToken': return ok({ token: 'mock-once' })
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] }) case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
case 'dhcp/leases/list': return ok({ leases }) case 'dhcp/leases/list': return ok({ leases })
+4 -2
View File
@@ -93,7 +93,7 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
| Zones | View, Modify, Delete | зоны, записи, DNSSEC, импорт | | Zones | View, Modify, Delete | зоны, записи, DNSSEC, импорт |
| Cache | View, Delete | сброс кеша и удаление домена из кеша | | Cache | View, Delete | сброс кеша и удаление домена из кеша |
| Allowed, Blocked | View, Modify, Delete | добавить, удалить, очистить, импортировать | | Allowed, Blocked | View, Modify, Delete | добавить, удалить, очистить, импортировать |
| Settings | View, Modify | сохранение настроек, временное отключение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты | | Settings | View, Modify | сохранение настроек и URL списков блокировки, временное отключение блокировки, включение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты |
| DnsClient | View, Modify | Import из DNS Client пишет ответ в зону | | DnsClient | View, Modify | Import из DNS Client пишет ответ в зону |
| DhcpServer | View, Modify, Delete | области и аренды | | DhcpServer | View, Modify, Delete | области и аренды |
| Dashboard, Logs | View | чтобы этот токен мог читать то же, что `hub-reader`. 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». Запись идёт на один выбранный сервер. В режиме «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.» Хаб отклоняет всё, чего нет в таблице путей: `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.»
+1 -1
View File
@@ -4,7 +4,7 @@
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание; - **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов. - **упор на 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`).
--- ---
+26 -1
View File
@@ -58,7 +58,7 @@ atomically with mode 0600. The running hub notices a change on the next request
| `apps-admin` | read, apps.write | | `apps-admin` | read, apps.write |
| `admin` | everything, including audit.read | | `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`). `hubRole` (and `info.hubRole`).
## Login protection ## 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. 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: `<url>`, `!<url>` 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 ## Managing one server
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
+14 -2
View File
@@ -1,7 +1,8 @@
/* /*
What each `/api/<path>` does and who may call it. `kind` says what it touches: What each `/api/<path>` 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). `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 The sections a server is managed from (stage 5) are listed by what the console calls. A
write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities: write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities:
@@ -116,7 +117,7 @@ export const ROUTES: Record<string, Route> = {
'dnsClient/resolve': readOne(), 'dnsClient/resolve': readOne(),
// Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node. // Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node.
'settings/getTsigKeyNames': readOne(), 'settings/getTsigKeyNames': readOne(),
'settings/set': writeOne('admin'), 'settings/set': writeOne('admin'), // see `effectiveRoute` for the blocking switch
'settings/temporaryDisableBlocking': writeOne('operate'), 'settings/temporaryDisableBlocking': writeOne('operate'),
'settings/forceUpdateBlockLists': writeOne('operate'), 'settings/forceUpdateBlockLists': writeOne('operate'),
// Logs (no delete). // Logs (no delete).
@@ -156,6 +157,8 @@ export const ROUTES: Record<string, Route> = {
'hub/apps/import': writeAll('apps.write'), 'hub/apps/import': writeAll('apps.write'),
'hub/apps/run': writeAll('apps.write'), 'hub/apps/run': writeAll('apps.write'),
'hub/apps/upload': 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 { export function routeFor(apiPath: string): Route | undefined {
@@ -173,6 +176,15 @@ export function effectiveRoute(apiPath: string, params: Record<string, string>):
const flag = (params.import ?? '').trim().toLowerCase() const flag = (params.import ?? '').trim().toLowerCase()
if (flag !== '' && flag !== 'false') return writeOne('admin') 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) return routeFor(apiPath)
} }
+5 -1
View File
@@ -196,13 +196,17 @@ const OPERATE = [
['allowed/delete', { domain: 'ok.example' }], ['allowed/delete', { domain: 'ok.example' }],
['settings/temporaryDisableBlocking', { minutes: '5' }], ['settings/temporaryDisableBlocking', { minutes: '5' }],
['settings/forceUpdateBlockLists', {}], ['settings/forceUpdateBlockLists', {}],
// The blocking switch alone is blocking, not server settings (routes.ts `effectiveRoute`).
['settings/set', { enableBlocking: 'false' }],
] as const ] as const
const ADMIN = [ const ADMIN = [
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }], ['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
['zones/delete', { zone: 'example.com' }], ['zones/delete', { zone: 'example.com' }],
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }], ['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
['dhcp/scopes/enable', { name: 'Default' }], ['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 ] as const
describe('who may write what', () => { describe('who may write what', () => {
+23
View File
@@ -27,6 +27,7 @@ import { Audit } from './audit.ts'
import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts' import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts' import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
import { buildMatrix } from './apps-matrix.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 { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts' import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
import { AppsConfig, UserError } from './apps-config.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 limiter = new LoginLimiter()
const audit = new Audit(config.dataDir) const audit = new Audit(config.dataDir)
const writer = new AppsWriter(config, upstream) const writer = new AppsWriter(config, upstream)
const runner = new ServersRunner(config, upstream)
const appsConfig = new AppsConfig(config, upstream) const appsConfig = new AppsConfig(config, upstream)
const byId = new Map(config.servers.map((s) => [s.id, s])) 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) await configWrite(res, apiPath, params, who, ip)
return 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') { if (apiPath === 'hub/apps/run') {
const action = params.action const action = params.action
if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') { if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') {
+354
View File
@@ -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<Fake> {
return new Promise((resolve) => {
const f: Fake = { server: http.createServer(), url: '', calls: [], failWith: null, down: false }
f.server.on('request', (req, res) => {
const chunks: Buffer[] = []
req.on('data', (c: Buffer) => chunks.push(c))
req.on('end', () => {
if (f.down) return req.socket.destroy()
const url = new URL(req.url ?? '/', 'http://fake')
const contentType = String(req.headers['content-type'] ?? '')
const form = new URLSearchParams(contentType.includes('x-www-form-urlencoded') ? Buffer.concat(chunks).toString('utf8') : '')
f.calls.push({ path: url.pathname.replace(/^\/api\//, ''), method: req.method ?? '', token: url.searchParams.get('token') ?? '', query: url.searchParams, form })
res.setHeader('content-type', 'application/json')
if (f.failWith !== null && req.method === 'POST') return res.end(JSON.stringify({ status: 'error', errorMessage: f.failWith }))
res.end(JSON.stringify({ status: 'ok', response: {} }))
})
})
f.server.listen(0, '127.0.0.1', () => {
f.url = `http://127.0.0.1:${(f.server.address() as AddressInfo).port}`
resolve(f)
})
})
}
let a: Fake
let b: Fake
let dataDir: string
let staticDir: string
let hub: http.Server | undefined
let base: string
const tokens: Record<string, string> = {}
const ROLES = ['viewer', 'operator', 'apps-admin', 'admin']
async function start(extra: Record<string, string> = {}): Promise<void> {
hub?.close()
hub = createHub(
parseConfig({
HUB_SERVERS: `a=${a.url},b=${b.url}`,
A_TOKEN: 'read-a',
B_TOKEN: 'read-b',
A_ADMIN_TOKEN: 'admin-a',
B_ADMIN_TOKEN: 'admin-b',
HUB_DATA: dataDir,
HUB_STATIC: staticDir,
HUB_CACHE_SECONDS: '0',
HUB_TIMEOUT_MS: '2000',
...extra,
}),
)
await new Promise<void>((resolve) => hub!.listen(0, '127.0.0.1', resolve))
base = `http://127.0.0.1:${(hub.address() as AddressInfo).port}`
for (const role of ROLES) {
const res = await fetch(`${base}/api/user/login`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: `user=${role}&pass=password-${role}`,
})
tokens[role] = ((await res.json()) as { token: string }).token
}
}
async function run(role: string, params: Record<string, string>) {
const res = await fetch(`${base}/api/hub/servers/run`, {
method: 'POST',
headers: { authorization: `Bearer ${tokens[role]}`, 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params).toString(),
})
return { code: res.status, body: (await res.json()) as Record<string, any> }
}
const writes = (f: Fake) => f.calls.filter((c) => c.method === 'POST')
async function auditEntries(): Promise<Record<string, string>[]> {
const res = await fetch(`${base}/api/hub/audit/list?limit=200`, { headers: { authorization: `Bearer ${tokens.admin}` } })
return ((await res.json()) as { response: { entries: Record<string, string>[] } }).response.entries
}
beforeAll(async () => {
a = await fake()
b = await fake()
dataDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-data-'))
staticDir = mkdtempSync(path.join(tmpdir(), 'hub-s6-static-'))
let users = addUser([], 'viewer', 'viewer', 'password-viewer')
users = addUser(users, 'operator', 'operator', 'password-operator')
users = addUser(users, 'apps-admin', 'apps-admin', 'password-apps-admin')
users = addUser(users, 'admin', 'admin', 'password-admin')
writeUsersFile(dataDir, users)
await start()
})
beforeEach(() => {
for (const f of [a, b]) {
f.calls = []
f.failWith = null
f.down = false
}
})
afterAll(() => {
hub?.close()
a.server.close()
b.server.close()
rmSync(dataDir, { recursive: true, force: true })
rmSync(staticDir, { recursive: true, force: true })
})
describe('the plan of an action', () => {
const plan = (action: string, raw: Record<string, string> = {}) => {
const out = planAction(action, raw)
if ('error' in out) throw new Error(out.error)
return out.plan
}
it('maps each action to its Technitium call and sends only what it needs', () => {
expect(plan('blocked.add', { domain: 'Ads.Example.' })).toMatchObject({ path: 'blocked/add', params: { domain: 'Ads.Example' } })
expect(plan('blocked.delete', { domain: 'ads.example' })).toMatchObject({ path: 'blocked/delete', params: { domain: 'ads.example' } })
expect(plan('allowed.add', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/add' })
expect(plan('allowed.delete', { domain: 'ok.example' })).toMatchObject({ path: 'allowed/delete' })
expect(plan('blocking.disable', { minutes: '15' })).toMatchObject({ path: 'settings/temporaryDisableBlocking', params: { minutes: '15' }, target: 'minutes=15' })
expect(plan('blocking.enable')).toMatchObject({ path: 'settings/set', params: { enableBlocking: 'true' } })
expect(plan('blocking.updateLists')).toMatchObject({ path: 'settings/forceUpdateBlockLists', params: {} })
expect(plan('blocking.setListUrls', { urls: 'https://a.example/x\nhttp://b.example/y' })).toMatchObject({
path: 'settings/set',
params: { blockListUrls: 'https://a.example/x,http://b.example/y' },
target: 'urls=2',
})
expect(plan('cache.flush')).toMatchObject({ path: 'cache/flush', params: {} })
expect(plan('cache.delete', { domain: 'x.example' })).toMatchObject({ path: 'cache/delete', params: { domain: 'x.example' } })
expect(SERVER_ACTIONS).toHaveLength(10)
})
it('refuses an action that is not on the list', () => {
for (const bad of [undefined, '', 'settings.set', 'zones/delete', 'cache.flush ', '__proto__']) expect(planAction(bad, {})).toHaveProperty('error')
})
it('checks the domain', () => {
expect(cleanDomain('example.com')).toBe('example.com')
expect(cleanDomain('*.example.com')).toBe('*.example.com')
for (const bad of ['', ' ', 'a b.example', 'a..example', '-a.example', 'a.example/x', 'a.example?x=1', 'a,b.example', 'a'.repeat(64) + '.example', ('a.'.repeat(130)) + 'com']) {
expect(cleanDomain(bad)).toBeNull()
expect(planAction('blocked.add', { domain: bad })).toHaveProperty('error')
}
})
it('checks the minutes', () => {
for (const bad of [undefined, '', '0', '-5', '1.5', 'abc', '5m', String(MAX_MINUTES + 1), '99999999']) {
expect(planAction('blocking.disable', { minutes: bad })).toHaveProperty('error')
}
expect(planAction('blocking.disable', { minutes: String(MAX_MINUTES) })).toHaveProperty('plan')
})
it('checks the lines', () => {
for (const bad of ['', ' \n ', 'ftp://a.example/x', '!ftp://a.example/x', '! javascript:alert(1)', 'not a url', 'https://a.example/x,y', '# a, b', 'https://a.example/a b', '#' + 'x'.repeat(300)]) {
expect(cleanUrls(bad), bad).toHaveProperty('error')
}
expect(cleanUrls(Array.from({ length: MAX_LINES + 1 }, (_, i) => `https://a.example/${i}`).join('\n'))).toHaveProperty('error')
})
it('takes the grammar of the lists: allow lines, switched-off lines, headings, file addresses', () => {
const text = '# Privacy\nhttps://a.example/hosts\n!https://b.example/allow.txt\n#https://c.example/off\n\nfile:///etc/extra.txt\r\n http://d.example/x \n'
expect(cleanUrls(text)).toEqual({
urls: ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', 'file:///etc/extra.txt', 'http://d.example/x'],
})
})
it('sends the same blockListUrls the console Settings save sends (the lines joined with commas, in order)', () => {
const lines = ['# Privacy', 'https://a.example/hosts', '!https://b.example/allow.txt', '#https://c.example/off', '# Ads', 'https://d.example/x']
const out = planAction('blocking.setListUrls', { urls: lines.join('\n') })
if ('error' in out) throw new Error(out.error)
// cleanList in src/screens/settings/model.ts: newlines to commas, repeated commas collapsed, none at the ends.
expect(out.plan.params).toEqual({ blockListUrls: lines.join(',') })
expect(out.plan.target).toBe('urls=6')
})
})
describe('who may run it', () => {
it('is a write-all route for operate', () => {
expect(routeFor('hub/servers/run')).toMatchObject({ kind: 'write-all', capability: 'operate' })
})
it('refuses a viewer and an apps admin, calling no server', async () => {
for (const role of ['viewer', 'apps-admin']) {
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(403)
}
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(0)
})
it('lets an operator and an admin run it', async () => {
for (const role of ['operator', 'admin']) {
const r = await run(role, { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(200)
expect(r.body.response.results).toHaveLength(2)
}
})
it('needs a POST', async () => {
const res = await fetch(`${base}/api/hub/servers/run?action=cache.flush&targets=a`, { headers: { authorization: `Bearer ${tokens.operator}` } })
expect(res.status).toBe(405)
expect(writes(a)).toHaveLength(0)
})
})
describe('running it', () => {
it('sends each action to each server with its admin token, parameters in the body', async () => {
const cases: [Record<string, string>, string, Record<string, string>][] = [
[{ action: 'blocked.add', domain: 'ads.example' }, 'blocked/add', { domain: 'ads.example' }],
[{ action: 'blocked.delete', domain: 'ads.example' }, 'blocked/delete', { domain: 'ads.example' }],
[{ action: 'allowed.add', domain: 'ok.example' }, 'allowed/add', { domain: 'ok.example' }],
[{ action: 'allowed.delete', domain: 'ok.example' }, 'allowed/delete', { domain: 'ok.example' }],
[{ action: 'blocking.disable', minutes: '15' }, 'settings/temporaryDisableBlocking', { minutes: '15' }],
[{ action: 'blocking.enable' }, 'settings/set', { enableBlocking: 'true' }],
[{ action: 'blocking.updateLists' }, 'settings/forceUpdateBlockLists', {}],
[{ action: 'blocking.setListUrls', urls: 'https://a.example/x\nhttps://b.example/y' }, 'settings/set', { blockListUrls: 'https://a.example/x,https://b.example/y' }],
[{ action: 'cache.flush' }, 'cache/flush', {}],
[{ action: 'cache.delete', domain: 'x.example' }, 'cache/delete', { domain: 'x.example' }],
]
for (const [params, upstreamPath, sent] of cases) {
a.calls = []
b.calls = []
const r = await run('operator', { ...params, targets: 'a,b' })
expect(r.code, params.action).toBe(200)
expect(r.body.response.results.map((x: { server: string; ok: boolean }) => [x.server, x.ok])).toEqual([['a', true], ['b', true]])
for (const [f, token] of [[a, 'admin-a'], [b, 'admin-b']] as const) {
const call = writes(f).filter((c) => c.path !== 'settings/get')
expect(call, params.action).toHaveLength(1)
expect(call[0].path).toBe(upstreamPath)
expect(call[0].token).toBe(token)
expect(Object.fromEntries(call[0].form)).toEqual(sent)
// Nothing but the token in the URL.
expect([...call[0].query.keys()]).toEqual(['token'])
}
}
})
it('sends only the one setting, and never reads or writes anything else', async () => {
await run('operator', { action: 'blocking.setListUrls', urls: 'https://a.example/x', targets: 'a' })
expect(a.calls.map((c) => c.path)).toEqual(['settings/set'])
expect([...(a.calls[0].form.keys())]).toEqual(['blockListUrls'])
})
it('shows a partial failure per server, never a bare ok', async () => {
b.failWith = 'Access was denied to user x for Blocked.'
const r = await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
expect(r.code).toBe(200)
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: true })
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: false })
expect(results.find((x) => x.server === 'b')?.message).toContain('Access was denied')
expect(results.every((x) => typeof x.message === 'string' && x.message !== '')).toBe(true)
})
it('reports a server that does not answer, and still runs the others', async () => {
b.down = true
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
const results = r.body.response.results as { server: string; ok: boolean }[]
expect(results.map((x) => [x.server, x.ok])).toEqual([['a', true], ['b', false]])
expect(writes(a)).toHaveLength(1)
})
it('fails only the target that has no admin token, and one that is unknown refuses the request', async () => {
await start({ A_ADMIN_TOKEN: '' })
for (const f of [a, b]) f.calls = []
const r = await run('operator', { action: 'cache.flush', targets: 'a,b' })
expect(r.code).toBe(200)
const results = r.body.response.results as { server: string; ok: boolean; message: string }[]
expect(results.find((x) => x.server === 'a')).toMatchObject({ ok: false, message: 'no admin token configured' })
expect(results.find((x) => x.server === 'b')).toMatchObject({ ok: true })
expect(writes(a)).toHaveLength(0)
expect(writes(b)).toHaveLength(1)
const unknown = await run('operator', { action: 'cache.flush', targets: 'a,nope' })
expect(unknown.code).toBe(400)
expect(writes(a)).toHaveLength(0)
await start()
})
it('refuses empty targets and a bad parameter, calling no server', async () => {
const bad: Record<string, string>[] = [
{ action: 'cache.flush', targets: '' },
{ action: 'cache.flush' },
{ action: 'cache.flush', targets: ' , ' },
{ action: 'blocked.add', domain: 'a b', targets: 'a' },
{ action: 'blocking.disable', minutes: '0', targets: 'a' },
{ action: 'blocking.setListUrls', urls: 'ftp://x.example', targets: 'a' },
{ action: 'settings.set', targets: 'a' },
]
for (const params of bad) {
const r = await run('operator', params)
expect(r.code, JSON.stringify(params)).toBe(400)
expect(r.body.status).toBe('error')
}
expect(a.calls).toHaveLength(0)
expect(b.calls).toHaveLength(0)
})
it('runs on a server once even when it is named twice', async () => {
await run('operator', { action: 'cache.flush', targets: 'a,a' })
expect(writes(a)).toHaveLength(1)
})
})
describe('the audit log', () => {
it('has one line per server with the target, and never the URL list', async () => {
b.failWith = 'Disk full.'
await run('operator', { action: 'blocking.setListUrls', urls: 'https://secret.example/token123/list.txt\nhttps://x.example/y', targets: 'a,b' })
await run('operator', { action: 'blocked.add', domain: 'ads.example', targets: 'a,b' })
await run('operator', { action: 'blocking.disable', minutes: '15', targets: 'a' })
await run('operator', { action: 'cache.flush', targets: '' })
const entries = (await auditEntries()).filter((e) => e.action?.startsWith('servers.'))
const lists = entries.filter((e) => e.action === 'servers.blocking.setListUrls')
expect(lists.map((e) => [e.server, e.outcome, e.target])).toEqual(expect.arrayContaining([['a', 'ok', 'urls=2'], ['b', 'failed', 'urls=2']]))
expect(entries.filter((e) => e.action === 'servers.blocked.add').map((e) => [e.server, e.target])).toEqual(expect.arrayContaining([['a', 'ads.example'], ['b', 'ads.example']]))
expect(entries.find((e) => e.action === 'servers.blocking.disable')).toMatchObject({ server: 'a', target: 'minutes=15', outcome: 'ok', user: 'operator' })
expect(entries.find((e) => e.action === 'servers.cache.flush')).toMatchObject({ outcome: 'refused' })
const text = JSON.stringify(entries)
expect(text).not.toContain('secret.example')
expect(text).not.toContain('token123')
expect(text).not.toContain('admin-a')
})
})
+183
View File
@@ -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<string, string>
/* 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): `<url>` a block list, `!<url>` 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<string, string | undefined>): { 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<string, string>, 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<TargetResult> => {
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 }
}
}
+3 -3
View File
@@ -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` 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 (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 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 `admin` to modify because its Import writes a zone. The blocking switch and the block list update are blocking,
temporary blocking switch in the console, which the hub itself would let an operator use: so behind a hub the console asks for Blocked/Allowed Modify there (which an operator has), not for Settings Modify (which
the console is the stricter of the two there. only an admin has). Saving the list URLs of one server stays a settings change.
*/ */
const PERMISSION_KEYS = [ const PERMISSION_KEYS = [
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs', 'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
+26
View File
@@ -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<Record<'domain' | 'minutes' | 'urls', string>>
export function runOnServers(token: string | null, action: ServerAction, params: ServerParams, targets: string[]): Promise<RunOutcome> {
return apiRequest('hub/servers/run', { token, method: 'POST', body: { action, ...params, targets: targets.join(',') } })
}
+7 -2
View File
@@ -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 { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect' import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
import { HubWritable } from '../lib/hub-write' import { HubWritable } from '../lib/hub-write'
import { HubServerIds } from '../lib/hub-all'
import { FooterLinks } from '../ui/FooterLinks' import { FooterLinks } from '../ui/FooterLinks'
import { Versions } from './Versions' import { Versions } from './Versions'
import { BlockingMark } from './BlockingMark' import { BlockingMark } from './BlockingMark'
@@ -141,6 +142,8 @@ export function Shell({
const hub = session.info?.hub === true const hub = session.info?.hub === true
/* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */ /* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */
const hubWritable = session.info?.hubWritable ?? NO_SERVERS 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() const hubWarnings = useHubWarnings()
/* Behind a hub: the count comes with the session, the list is read now and then. */ /* Behind a hub: the count comes with the session, the list is read now and then. */
const hubDriftAtStart = session.info?.hubDrift const hubDriftAtStart = session.info?.hubDrift
@@ -276,7 +279,7 @@ export function Shell({
const current = sections.find((s) => s.id === active) ?? sections[0] const current = sections.find((s) => s.id === active) ?? sections[0]
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */ /* 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 The jumps other screens' row menus make (`app/handoff.ts`). The section switch
@@ -429,6 +432,7 @@ export function Shell({
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}> <ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
<HubMode.Provider value={hub}> <HubMode.Provider value={hub}>
<HubWritable.Provider value={hubWritable}> <HubWritable.Provider value={hubWritable}>
<HubServerIds.Provider value={hubServers}>
<SlotProvider> <SlotProvider>
{(slot) => ( {(slot) => (
<div className={styles.shell} data-rail={rail}> <div className={styles.shell} data-rail={rail}>
@@ -664,7 +668,7 @@ export function Shell({
token={session.token} token={session.token}
nodes={session.info?.clusterNodes ?? []} nodes={session.info?.clusterNodes ?? []}
clusterInitialised={session.info?.clusterInitialized === true} 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' ? ( ) : current?.id === 'dnsclient' ? (
<DnsClient <DnsClient
@@ -847,6 +851,7 @@ export function Shell({
</div> </div>
)} )}
</SlotProvider> </SlotProvider>
</HubServerIds.Provider>
</HubWritable.Provider> </HubWritable.Provider>
</HubMode.Provider> </HubMode.Provider>
</ThisServerName.Provider> </ThisServerName.Provider>
+64
View File
@@ -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<readonly string[]>([])
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<StartAll | null>(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 : (
<RunFlow
request={open.request}
onClose={(changed) => {
const done = open.spec.onDone
setOpen(null)
done?.(changed)
}}
/>
)
return { start, flow }
}
+74
View File
@@ -1,10 +1,13 @@
import { useContext, useId, useRef, useState } from 'react' import { useContext, useId, useRef, useState } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write' import { HubNode, nodeArg } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import { addDomain, type DomainList } from '../../api/zonelists' import { addDomain, type DomainList } from '../../api/zonelists'
import { Panel, Body } from '../../ui/Panel' import { Panel, Body } from '../../ui/Panel'
import { Field, Input } from '../../ui/Field' import { Field, Input } from '../../ui/Field'
import { Loading } from '../../ui/Empty' import { Loading } from '../../ui/Empty'
import { PermissionButton } from '../../ui/PermissionButton' import { PermissionButton } from '../../ui/PermissionButton'
import { Menu } from '../../ui/Menu'
import { LockedItem } from './Locked'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
import { noticeFromFailure, type Notice } from '../../lib/notice' import { noticeFromFailure, type Notice } from '../../lib/notice'
import { checkDomain, verdictOf, type Verdict } from './check-domain' 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`). */ /* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
const node = useContext(HubNode) 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 [field, setField] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const entry = useRef<HTMLInputElement>(null) const entry = useRef<HTMLInputElement>(null)
@@ -122,6 +127,24 @@ export function AddDomainBar({
return 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) setBusy(true)
const outcome = await addDomain(list, token, domain, ...nodeArg(node)) const outcome = await addDomain(list, token, domain, ...nodeArg(node))
setBusy(false) setBusy(false)
@@ -140,6 +163,30 @@ export function AddDomainBar({
onChanged(list, domain) 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 ( return (
<Panel title="Check, block or allow a domain"> <Panel title="Check, block or allow a domain">
<Body> <Body>
@@ -192,6 +239,33 @@ export function AddDomainBar({
> >
Check Check
</PermissionButton> </PermissionButton>
{/* One menu, not two more buttons: the row has no room for five at desktop width. */}
{all !== null && (
<Menu label="Remove" text="Remove">
{(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 ? (
<LockedItem key={list} need={need} placement="left">
From {verb}
</LockedItem>
) : (
<button
key={list}
type="button"
onClick={() => {
close()
remove(list)
}}
>
From {verb}
</button>
)
})
}
</Menu>
)}
</form> </form>
{/* One live region for the whole answer: the wait and the verdict that {/* One live region for the whole answer: the wait and the verdict that
replaces it are announced once each. */} replaces it are announced once each. */}
@@ -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 }) => <div role="img" aria-label={aria} /> }))
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(
<HubMode.Provider value>
<HubWritable.Provider value={writable}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<Overview token="t" permissions={permissions} nodes={NODES} clusterInitialised />
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
}
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(<Overview token="t" permissions={OPERATOR} />)
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(
<HubMode.Provider value>
<HubWritable.Provider value={['mdns']}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<HubNode.Provider value="mdns">
<BlockLists token="t" permissions={OPERATOR} allServers />
</HubNode.Provider>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
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/)
})
})
@@ -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 (
<>
<Button
onClick={() =>
start({
action: 'blocking.updateLists',
title: 'Update block lists on several servers',
intro: 'Each chosen server downloads its block lists now.',
confirmLabel: 'Update lists',
})
}
>
Update lists on all servers
</Button>
<Button onClick={() => setEditing(true)}>Edit list URLs on all servers</Button>
{editing && (
<AskDialog
title="Edit list URLs on all servers"
label="Block list URLs"
intro="One address per line: a list, `!` before an allow list, `#` before a switched-off list or a heading. This replaces the whole list of block list URLs on every server you choose next."
initial={plainUrls(initialUrls)}
multiline
placeholder="https://example.com/hosts.txt"
confirmLabel="Choose servers"
check={checkUrls}
onClose={() => 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}
</>
)
}
+17 -2
View File
@@ -1,5 +1,7 @@
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { HubNode, nodeArg } from '../../lib/hub-write' import { HubNode, nodeArg } from '../../lib/hub-write'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { AllServersActions } from './AllServersActions'
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings' import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard' import { getDashboardStats } from '../../api/dashboard'
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists' import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
@@ -25,7 +27,7 @@ import {
} from './list-lines' } from './list-lines'
import { Locked } from './Locked' import { Locked } from './Locked'
import { hostsOf, moreLists } from './extra-lists' 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 shared from './Blocking.module.css'
import styles from './BlockLists.module.css' import styles from './BlockLists.module.css'
import { UrlText } from '../../ui/UrlText' import { UrlText } from '../../ui/UrlText'
@@ -202,11 +204,14 @@ export function BlockLists({
permissions, permissions,
clusterInitialised = false, clusterInitialised = false,
onDirty, onDirty,
allServers = false,
}: { }: {
tabs?: ReactNode tabs?: ReactNode
token: string | null token: string | null
permissions: Permissions permissions: Permissions
clusterInitialised?: boolean 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. */ /** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
onDirty?: (dirty: boolean) => void onDirty?: (dirty: boolean) => void
}) { }) {
@@ -215,6 +220,9 @@ export function BlockLists({
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : '' const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
const viewNeed = missing(permissions, 'Settings.canView') const viewNeed = missing(permissions, 'Settings.canView')
const modifyNeed = missing(permissions, 'Settings.canModify') 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 statsNeed = missing(permissions, 'Dashboard.canView')
const invalidId = useId() const invalidId = useId()
@@ -624,6 +632,7 @@ export function BlockLists({
</> </>
)} )}
<Panel className={shared.centred}><Body> <Panel className={shared.centred}><Body>
<div className="flex flex-col gap-4">
<div className={shared.kpi}> <div className={shared.kpi}>
<div className={shared.kpiText}> <div className={shared.kpiText}>
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span> <span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
@@ -633,13 +642,19 @@ export function BlockLists({
</span> </span>
</div> </div>
<PermissionButton <PermissionButton
permission={modifyNeed} permission={updateNeed}
disabled={busy || !hasSaved} disabled={busy || !hasSaved}
onClick={() => setAskUpdate(true)} onClick={() => setAskUpdate(true)}
> >
Update Now Update Now
</PermissionButton> </PermissionButton>
</div> </div>
{hub && allServers && (
<div className="flex flex-wrap items-center gap-2">
<AllServersActions token={token} initialUrls={saved?.map((l) => l.raw)} />
</div>
)}
</div>
</Body></Panel> </Body></Panel>
</div> </div>
+4 -2
View File
@@ -6,7 +6,7 @@ import { getSettings } from '../../api/settings'
import { getDashboardStats } from '../../api/dashboard' import { getDashboardStats } from '../../api/dashboard'
import { BlockingMenu } from '../dashboard/BlockingMenu' import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Tag } from '../../ui/Tag' import { Tag } from '../../ui/Tag'
import { missing, withoutWrites } from './permissions' import { missing, switchNeed, withoutWrites } from './permissions'
import { untilText, useBlockingState } from './blocking-state' import { untilText, useBlockingState } from './blocking-state'
import { SubTabs } from '../../ui/SubTabs' import { SubTabs } from '../../ui/SubTabs'
import { ruleSearch } from './rules-model' 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. */ itself, so the strip is for the other two. */
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) { function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
const { state } = useBlockingState(token, permissions, true, node) const { state } = useBlockingState(token, permissions, true, node)
const hub = useContext(HubMode)
if (state == null || state === 'failed') return null if (state == null || state === 'failed') return null
const canModify = missing(permissions, 'Settings.canModify') == null const canModify = switchNeed(permissions, hub) == null
return ( return (
<div className={shared.banner} role="status"> <div className={shared.banner} role="status">
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>} {state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
@@ -186,6 +187,7 @@ export function Blocking({
permissions={effective} permissions={effective}
clusterInitialised={clusterInitialised} clusterInitialised={clusterInitialised}
onDirty={setListsDirty} onDirty={setListsDirty}
allServers={hub && missing(permissions, 'Blocked.canModify', 'Allowed.canModify') == null}
/> />
</HubNode.Provider> </HubNode.Provider>
) )
+16 -3
View File
@@ -22,6 +22,7 @@ import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
import { Locked } from './Locked' import { Locked } from './Locked'
import { missing, withoutWrites, type Permissions } from './permissions' import { missing, withoutWrites, type Permissions } from './permissions'
import { HubNode, useHubWrite } from '../../lib/hub-write' import { HubNode, useHubWrite } from '../../lib/hub-write'
import { HubAll, useAllServersFlow } from '../../lib/hub-all'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
import shared from './Blocking.module.css' import shared from './Blocking.module.css'
import styles from './Overview.module.css' import styles from './Overview.module.css'
@@ -203,6 +204,15 @@ export function Overview({
const hub = useContext(HubMode) const hub = useContext(HubMode)
const write = useHubWrite(node) const write = useHubWrite(node)
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions) 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<DashboardStats | null>(null) const [data, setData] = useState<DashboardStats | null>(null)
/** The first read of this period and node failed: there is nothing to show. */ /** The first read of this period and node failed: there is nothing to show. */
const [failure, setFailure] = useState(false) const [failure, setFailure] = useState(false)
@@ -326,6 +336,8 @@ export function Overview({
return ( return (
<HubNode.Provider value={hub ? node : ''}> <HubNode.Provider value={hub ? node : ''}>
<HubAll.Provider value={allServers ? all.start : null}>
{all.flow}
<InChrome> <InChrome>
<ClusterNodeSelect <ClusterNodeSelect
nodes={nodes} nodes={nodes}
@@ -335,7 +347,7 @@ export function Overview({
onChange={setNode} onChange={setNode}
/> />
</InChrome> </InChrome>
<HubWriteHint write={write} /> <HubWriteHint write={write} allServers={allServers} />
<SectionHeader <SectionHeader
title="Blocking" title="Blocking"
@@ -379,10 +391,10 @@ export function Overview({
)} )}
<div className={`${shared.row2} ${styles.checkRow}`}> <div className={`${shared.row2} ${styles.checkRow}`}>
<StatusPanel token={token} permissions={permissions} onNotice={setNotice} /> <StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} />
<AddDomainBar <AddDomainBar
token={token} token={token}
permissions={permissions} permissions={barPermissions}
onNotice={setNotice} onNotice={setNotice}
onChanged={() => void load()} onChanged={() => void load()}
/> />
@@ -473,6 +485,7 @@ export function Overview({
refreshOn={s?.totalBlocked} refreshOn={s?.totalBlocked}
/> />
</div> </div>
</HubAll.Provider>
</HubNode.Provider> </HubNode.Provider>
) )
} }
+16 -2
View File
@@ -1,5 +1,7 @@
import { useContext } from 'react' import { useContext } from 'react'
import { HubNode } from '../../lib/hub-write' import { HubNode } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { BlockingMenu } from '../dashboard/BlockingMenu' import { BlockingMenu } from '../dashboard/BlockingMenu'
import { Panel, Body } from '../../ui/Panel' import { Panel, Body } from '../../ui/Panel'
import { PermissionButton } from '../../ui/PermissionButton' import { PermissionButton } from '../../ui/PermissionButton'
@@ -9,7 +11,8 @@ import { Icon } from '../../ui/Icon'
import { Tag } from '../../ui/Tag' import { Tag } from '../../ui/Tag'
import type { Notice } from '../../lib/notice' import type { Notice } from '../../lib/notice'
import { Locked } from './Locked' 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 { untilText, useBlockingState } from './blocking-state'
import shared from './Blocking.module.css' import shared from './Blocking.module.css'
import styles from './Overview.module.css' import styles from './Overview.module.css'
@@ -49,10 +52,14 @@ export function StatusPanel({
/* Behind a hub: the server the section has chosen. */ /* Behind a hub: the server the section has chosen. */
const node = useContext(HubNode) const node = useContext(HubNode)
const { state, retry } = useBlockingState(token, permissions, true, node) 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 <Locked title="Blocking" need={viewNeed} /> if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
const modifyNeed = missing(permissions, 'Settings.canModify') const modifyNeed = switchNeed(permissions, hub)
if (state === 'failed') { if (state === 'failed') {
return ( return (
@@ -77,6 +84,7 @@ export function StatusPanel({
return ( return (
<Panel className={shared.centred}> <Panel className={shared.centred}>
<Body> <Body>
<div className="flex flex-col gap-4">
{state == null ? ( {state == null ? (
<Loading compact /> <Loading compact />
) : ( ) : (
@@ -117,6 +125,12 @@ export function StatusPanel({
)} )}
</div> </div>
)} )}
{state != null && all !== null && modifyNeed == null && (
<div className="flex flex-wrap items-center gap-2">
<AllServersActions token={token} />
</div>
)}
</div>
</Body> </Body>
</Panel> </Panel>
) )
+5 -5
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { getSettings } from '../../api/settings' import { getSettings } from '../../api/settings'
import { minuteStamp } from '../../lib/dates' import { minuteStamp } from '../../lib/dates'
import { onSettingsChanged } from '../../lib/settings-signal' 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 Whether blocking is on, read where the Overview's status block reads it
@@ -128,10 +128,10 @@ export interface BlockingMark {
tone: 'acc' | 'neutral' 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 == null || state === 'failed' || state.kind === 'on') return null
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' } 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 { return {
text: 'Paused', text: 'Paused',
label: `Blocking, paused ${untilText(state.till, now).replace(/^Until/, 'until')}`, 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. */ /** 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) const { state } = useBlockingState(token, permissions, visible)
return blockingMark(state, permissions) return blockingMark(state, permissions, new Date(), hub)
} }
+10
View File
@@ -19,6 +19,16 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
return 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 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 the hub holds no admin token for): the same permissions with every write switched off. The
+40 -1
View File
@@ -1,10 +1,11 @@
import { useState } from 'react' import { useContext, useState } from 'react'
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings' import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
import { Confirm } from '../../ui/Confirm' import { Confirm } from '../../ui/Confirm'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
import { Button } from '../../ui/Button' import { Button } from '../../ui/Button'
import { Tooltip } from '../../ui/Tooltip' import { Tooltip } from '../../ui/Tooltip'
import { nodeArg, useHubWrite } from '../../lib/hub-write' import { nodeArg, useHubWrite } from '../../lib/hub-write'
import { HubAll } from '../../lib/hub-all'
import type { AlertType } from '../../ui/Alert' import type { AlertType } from '../../ui/Alert'
import { noticeFromFailure } from '../../lib/notice' import { noticeFromFailure } from '../../lib/notice'
@@ -73,6 +74,8 @@ export function BlockingMenu({
const [active, setActive] = useState<boolean | null>(null) const [active, setActive] = useState<boolean | null>(null)
const [pending, setPending] = useState<Pending | null>(null) const [pending, setPending] = useState<Pending | null>(null)
const [busy, setBusy] = useState(false) 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() { async function loadState() {
setActive(null) 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. */ /* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
const write = useHubWrite(node, allowed) const write = useHubWrite(node, allowed)
if (all !== null) {
return (
<Menu label="Blocking options" text={text}>
{(close) => (
<>
<button
role="menuitem"
onClick={() => {
close()
all({ action: 'blocking.enable', title: 'Enable blocking on several servers', confirmLabel: 'Enable blocking' })
}}
>
Enable Blocking
</button>
{DURATIONS.map((p) => (
<button
key={p.minutes}
role="menuitem"
onClick={() => {
close()
all({
action: 'blocking.disable',
params: { minutes: String(p.minutes) },
title: `Disable blocking for ${p.minutes} minute(s) on several servers`,
confirmLabel: 'Disable blocking',
})
}}
>
{p.text}
</button>
))}
</>
)}
</Menu>
)
}
if (!write.allowed) { if (!write.allowed) {
return ( return (
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}> <Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
+50 -1
View File
@@ -5,6 +5,8 @@ import { Cache } from './Lists'
import * as api from '../../api/zonelists' import * as api from '../../api/zonelists'
import { HubMode } from '../../ui/ClusterNodeSelect' import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write' import { HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
import * as hubRun from '../../api/hub-run'
afterEach(() => vi.restoreAllMocks()) afterEach(() => vi.restoreAllMocks())
@@ -19,7 +21,9 @@ function hubCache(writable: string[], canDelete = true) {
return render( return render(
<HubMode.Provider value> <HubMode.Provider value>
<HubWritable.Provider value={writable}> <HubWritable.Provider value={writable}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} /> <HubServerIds.Provider value={['mdns', 'ndns']}>
<Cache token="t" nodes={NODES} clusterInitialised canDelete={canDelete} />
</HubServerIds.Provider>
</HubWritable.Provider> </HubWritable.Provider>
</HubMode.Provider>, </HubMode.Provider>,
) )
@@ -47,3 +51,48 @@ describe('Cache behind a hub', () => {
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled() 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()
})
})
+65
View File
@@ -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 (
<>
<Button
variant="danger"
onClick={() =>
start({
action: 'cache.flush',
title: 'Flush the cache on several servers',
intro: 'The whole cache of each chosen server is emptied.',
confirmLabel: 'Flush cache',
})
}
>
Flush on all servers
</Button>
<Button onClick={() => setAsking(true)}>Delete a domain on all servers</Button>
{asking && (
<AskDialog
title="Delete a domain from the cache on all servers"
label="Domain"
intro="The domain and its cached records are removed from the cache of every server you choose next."
placeholder="example.com"
confirmLabel="Choose servers"
check={checkDomain}
onClose={() => 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}
</>
)
}
+9 -4
View File
@@ -1,5 +1,5 @@
import { KpiStrip } from '../../ui/KpiStrip' 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 { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
@@ -24,6 +24,7 @@ import { SectionHeader } from '../../ui/SectionHeader'
import { Empty, Loading } from '../../ui/Empty' import { Empty, Loading } from '../../ui/Empty'
import { StaleData } from '../StaleData' import { StaleData } from '../StaleData'
import { Tree } from './Tree' import { Tree } from './Tree'
import { CacheAllServers } from './CacheAllServers'
import { Path } from './Path' import { Path } from './Path'
import { CacheTree } from './CacheTree' import { CacheTree } from './CacheTree'
import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame' import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
@@ -247,6 +248,7 @@ export function Lists({
const sectionRef = useRef(sectionNode) const sectionRef = useRef(sectionNode)
const listServer = embedded ? sectionNode : '' const listServer = embedded ? sectionNode : ''
/* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */ /* 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 cacheWrite = useHubWrite(clusterNode, canDelete)
const listWrite = useHubWrite(listServer, canDelete) const listWrite = useHubWrite(listServer, canDelete)
@@ -490,9 +492,12 @@ export function Lists({
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined} subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
actions={ actions={
isCache ? ( isCache ? (
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}> <>
Flush Cache <Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
</Button> Flush Cache
</Button>
{hub && canDelete && <CacheAllServers token={token} />}
</>
) : undefined ) : undefined
} }
/> />
+25 -2
View File
@@ -1,5 +1,6 @@
import { useContext, useState } from 'react' import { useContext, useState } from 'react'
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write' import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { useAllServersFlow } from '../../lib/hub-all'
import { HubWriteHint } from '../../ui/HubWriteHint' import { HubWriteHint } from '../../ui/HubWriteHint'
import { SubTabs } from '../../ui/SubTabs' import { SubTabs } from '../../ui/SubTabs'
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect' import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
@@ -72,6 +73,9 @@ export function Logs({
const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked
/* The Allow and Block actions of a row: one server at a time. */ /* The Allow and Block actions of a row: one server at a time. */
const write = useHubWrite(node, canBlock) 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 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} aggregate={aggregateShown}
label="Cluster Node" label="Cluster Node"
/> />
{active === 'Query Logs' && <HubWriteHint write={write} />} {active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
{active === 'Query Logs' ? ( {active === 'Query Logs' ? (
<HubNode.Provider value={hub ? node : ''}> <HubNode.Provider value={hub ? node : ''}>
<QueryLogs tabs={tabs} token={token} node={node} request={request} canBlock={write.allowed} /> <QueryLogs
tabs={tabs}
token={token}
node={node}
request={request}
canBlock={write.allowed || allServers}
onAll={
allServers
? (verb, domain, server) =>
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}
</HubNode.Provider> </HubNode.Provider>
) : ( ) : (
<ViewLogs tabs={tabs} <ViewLogs tabs={tabs}
+103
View File
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import * as hubRun from '../../api/hub-run'
import type { QueryLogEntry } from '../../api/logs'
import { HandoffContext } from '../../app/handoff'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { HubWritable } from '../../lib/hub-write'
import { HubServerIds } from '../../lib/hub-all'
/* Behind a hub, in "All servers", a row's Allow and Block open the flow with the row's own server ticked. */
afterEach(() => 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(
<HubMode.Provider value>
<HubWritable.Provider value={['mdns', 'ndns']}>
<HubServerIds.Provider value={['mdns', 'ndns']}>
<HandoffContext.Provider value={{ queryDnsServer: vi.fn(), showQueryLogs: vi.fn() }}>
<Logs token="t" sub="Query Logs" nodes={NODES} clusterInitialised canBlock={canBlock} />
</HandoffContext.Provider>
</HubServerIds.Provider>
</HubWritable.Provider>
</HubMode.Provider>,
)
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()
})
})
+7 -4
View File
@@ -31,7 +31,7 @@ import { Notifier } from '../../ui/Notifier'
import { Menu } from '../../ui/Menu' import { Menu } from '../../ui/Menu'
import tbl from '../../ui/Table.module.css' import tbl from '../../ui/Table.module.css'
import { useHandoff, type QueryLogsRequest } from '../../app/handoff' 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 { useMedia } from '../../lib/use-media'
import { Icon } from '../../ui/Icon' import { Icon } from '../../ui/Icon'
@@ -321,11 +321,13 @@ export interface QueryLogsProps {
node?: string node?: string
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */ /** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
canBlock?: boolean 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. */ /** `showQueryLogs` from another screen: these filters, queried on arrival. */
request?: QueryLogsRequest 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 [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
const [f, setF] = useState<Filters>(() => defaultFilters('', '')) const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
const [page, setPage] = useState<QueryLogPage | null>(null) const [page, setPage] = useState<QueryLogPage | null>(null)
@@ -1043,10 +1045,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true }:
</button> </button>
<button <button
type="button" type="button"
disabled={action.isBusy(key) || !canBlock} disabled={(onAll === undefined && action.isBusy(key)) || !canBlock}
onClick={() => { onClick={() => {
close() close()
void action.run(key, verb, domain) if (onAll !== undefined) onAll(verb, domain, e.node)
else void action.run(key, verb, domain)
}} }}
> >
{verb} {verb}
+92
View File
@@ -0,0 +1,92 @@
import { useState, type ReactNode } from 'react'
import { Button } from './Button'
import { Dialog } from './Dialog'
import { Field, Input, Textarea } from './Field'
/*
One question in a dialog: a field (a line, or several with `multiline`) and a button. For the
step before a change on several servers that needs a value, a domain or a list of URLs, which
the next step (the server picker) does not ask for. `check` returns the sentence that says what
is wrong with the text, or `null` when it is fine; it is shown under the field and the answer
is not passed on.
*/
export function AskDialog({
title,
label,
intro,
initial = '',
placeholder,
multiline = false,
confirmLabel,
check,
onSubmit,
onClose,
}: {
title: string
label: string
intro?: ReactNode
initial?: string
placeholder?: string
multiline?: boolean
confirmLabel: string
check?: (text: string) => string | null
onSubmit: (text: string) => void
onClose: () => void
}) {
const [text, setText] = useState(initial)
const [problem, setProblem] = useState<string | null>(null)
function submit() {
const wrong = check?.(text) ?? null
setProblem(wrong)
if (wrong === null) onSubmit(text)
}
return (
<Dialog
open
onOpenChange={onClose}
title={title}
close="Cancel"
actions={
<Button variant="primary" onClick={submit}>
{confirmLabel}
</Button>
}
>
{intro != null && <p className="m-0 text-sm text-muted-foreground">{intro}</p>}
<Field label={label}>
{(id) =>
multiline ? (
<Textarea
id={id}
mono
rows={8}
placeholder={placeholder}
value={text}
aria-invalid={problem !== null || undefined}
onChange={(e) => setText(e.target.value)}
/>
) : (
<Input
id={id}
mono
placeholder={placeholder}
value={text}
aria-invalid={problem !== null || undefined}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') submit()
}}
/>
)
}
</Field>
{problem !== null && (
<p className="m-0 text-xs text-destructive" role="alert">
{problem}
</p>
)}
</Dialog>
)
}
+8 -1
View File
@@ -6,8 +6,15 @@ no server is chosen ("All servers"), or the hub holds no admin token for the one
Nothing is drawn when the change is allowed, and nothing when the role is what says no (the Nothing is drawn when the change is allowed, and nothing when the role is what says no (the
controls then carry their own "Requires ..." tooltip). controls then carry their own "Requires ..." tooltip).
*/ */
export function HubWriteHint({ write }: { write: HubWrite }) { export function HubWriteHint({ write, allServers = false }: { write: HubWrite; allServers?: boolean }) {
if (write.reason == null) return null if (write.reason == null) return null
if (allServers && write.reason === CHOOSE_A_SERVER) {
return (
<p className="m-0 text-xs text-muted-foreground" role="status">
A change made here goes to the servers you choose in the next step.
</p>
)
}
return ( return (
<p className="m-0 text-xs text-muted-foreground" role="status"> <p className="m-0 text-xs text-muted-foreground" role="status">
{write.reason === CHOOSE_A_SERVER ? 'Choose a server to make changes. Changes are made on one server at a time.' : `${write.reason}, so this server is read only.`} {write.reason === CHOOSE_A_SERVER ? 'Choose a server to make changes. Changes are made on one server at a time.' : `${write.reason}, so this server is read only.`}