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:
1 parent
8bb1e01936
commit
34de910e04
31 files changed
+1560
-40
No files matched your search
+9
-1
@@ -300,11 +300,19 @@ function route(path, q) {
|
||||
for (const [k, v] of q) {
|
||||
if (!(k in settings) || k === 'node' || k === 'token') continue
|
||||
const old = settings[k]
|
||||
if (k === 'enableBlocking' && v === 'true') settings.temporaryDisableBlockingTill = null
|
||||
settings[k] = typeof old === 'boolean' ? v === 'true' : typeof old === 'number' ? Number(v) : Array.isArray(old) ? v.split(/[,\n]/).map((x) => x.trim()).filter((x) => x && x !== 'false') : v
|
||||
}
|
||||
return ok(settings)
|
||||
}
|
||||
case 'settings/forceUpdateBlockLists': case 'settings/temporaryDisableBlocking': return ok(path === 'settings/set' ? settings : { temporaryDisableBlockingTill: iso(now + 10 * 60_000) })
|
||||
case 'settings/forceUpdateBlockLists': return ok()
|
||||
case 'settings/temporaryDisableBlocking': {
|
||||
// Blocking is off until the time given, as on a server; `settings/set enableBlocking=true` turns it back on.
|
||||
const minutes = Number(q.get('minutes') ?? 10)
|
||||
settings.enableBlocking = false
|
||||
settings.temporaryDisableBlockingTill = iso(Date.now() + minutes * 60_000)
|
||||
return ok({ temporaryDisableBlockingTill: settings.temporaryDisableBlockingTill })
|
||||
}
|
||||
case 'user/createSingleUseToken': return ok({ token: 'mock-once' })
|
||||
case 'settings/getTsigKeyNames': return ok({ tsigKeyNames: ['transfer.example.net'] })
|
||||
case 'dhcp/leases/list': return ok({ leases })
|
||||
|
||||
+4
-2
@@ -93,7 +93,7 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
|
||||
| Zones | View, Modify, Delete | зоны, записи, DNSSEC, импорт |
|
||||
| Cache | View, Delete | сброс кеша и удаление домена из кеша |
|
||||
| Allowed, Blocked | View, Modify, Delete | добавить, удалить, очистить, импортировать |
|
||||
| Settings | View, Modify | сохранение настроек, временное отключение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты |
|
||||
| Settings | View, Modify | сохранение настроек и URL списков блокировки, временное отключение блокировки, включение блокировки, обновление списков. Delete не нужен: резервная копия и восстановление через хаб закрыты |
|
||||
| DnsClient | View, Modify | Import из DNS Client пишет ответ в зону |
|
||||
| DhcpServer | View, Modify, Delete | области и аренды |
|
||||
| Dashboard, Logs | View | чтобы этот токен мог читать то же, что `hub-reader`. Delete не нужен: удаление логов и статистики закрыто |
|
||||
@@ -107,7 +107,9 @@ git tag hub-v1.0.0 && git push origin hub-v1.0.0
|
||||
|
||||
Запись идёт на один выбранный сервер. В режиме «All servers» (Dashboard, Logs, Blocking › Overview) кнопки записи неактивны с подсказкой «Choose a server».
|
||||
|
||||
Какие роли хаба что могут: `operator` меняет кеш и списки Blocked и Allowed, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают.
|
||||
Действия на несколько серверов сразу (этап 6): в «All servers» кнопки Block, Allow, Disable в Blocking › Overview, «Update lists on all servers», «Edit list URLs on all servers», «Remove from Blocked» и «Remove from Allowed», а также «Flush on all servers» и «Delete a domain on all servers» в Cache и Block/Allow в строках Query Logs открывают выбор серверов. По умолчанию отмечены все серверы с токеном записи; сервер без токена виден, но отключён, с причиной. Итог показывается по каждому серверу, «Retry failed» повторяет только неудавшиеся. Для этого нужны те же права на серверах: Blocked и Allowed (Modify, Delete), Cache (Delete), Settings (Modify: URL списков и переключатель блокировки). Роли `operator` и `admin`; в аудит попадает по строке на сервер, список URL в аудит не пишется. Заменённый список URL полностью перезаписывает прежний на каждом выбранном сервере.
|
||||
|
||||
Какие роли хаба что могут: `operator` меняет кеш, списки Blocked и Allowed, переключатель блокировки (включить, временно отключить) и обновляет списки, `admin` меняет и зоны, настройки и DHCP; `viewer` и `apps-admin` в этих разделах только читают.
|
||||
|
||||
Хаб отклоняет всё, чего нет в таблице путей: `admin/*` и `user/*` (кроме собственного входа хаба), `logs/delete`, `logs/deleteAll`, `dashboard/stats/deleteAll`, `settings/backup`, `settings/restore`, права на зоны (`zones/permissions/*`). Сохранение настроек с изменённым полем веб-службы сервера (`webService*`) или с полем пользователей, SSO и LDAP хаб тоже отклоняет с сообщением «Change the web service settings in that server's own console.»
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- **общее управление**: каждый сервер из одной консоли, действия «на все серверы», сравнение и выравнивание;
|
||||
- **упор на Apps**: приложения на всех серверах и синхронизация их конфигов.
|
||||
|
||||
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения) и 5 (управление каждым сервером) сделаны. Этапы 6 и далее пока планы (`docs/hub-deploy.md`).
|
||||
Статус: этапы 0 и 1 (безопасность, Apps по серверам), 2 (установка и обновление), 3 (сравнение и синхронизация конфигов), 4 (история, откат, расхождения), 5 (управление каждым сервером) и 6 (действия на все серверы вне Apps) сделаны. Этапы 7 и далее пока планы (`docs/hub-deploy.md`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+26
-1
@@ -58,7 +58,7 @@ atomically with mode 0600. The running hub notices a change on the next request
|
||||
| `apps-admin` | read, apps.write |
|
||||
| `admin` | everything, including audit.read |
|
||||
|
||||
`apps.write` is what the app writes below need; `operate` and the rest are still unused. Every response also carries
|
||||
`apps.write` is what the app writes below need; `operate` and `admin` are what the per-server and several-server changes need. Every response also carries
|
||||
`hubRole` (and `info.hubRole`).
|
||||
|
||||
## Login protection
|
||||
@@ -86,6 +86,31 @@ Public: `status`, `user/login`. After signing in: `user/session/get`, `user/logo
|
||||
|
||||
Everything else answers `403 The hub is read-only.` and is audited.
|
||||
|
||||
## Changing several servers at once
|
||||
|
||||
`POST hub/servers/run` (`write-all`, role with `operate`) runs one fixed action on the chosen servers, in parallel, each with
|
||||
that server's admin token. Parameters: `action`, `targets` (comma separated server ids) and the action's own `domain`,
|
||||
`minutes` or `urls` (lines in the grammar of the block lists, up to 300: `<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
|
||||
|
||||
The sections Zones, Cache, Blocking (Blocked and Allowed), DNS Client, Settings and DHCP are managed on ONE server at a
|
||||
|
||||
+14
-2
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
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).
|
||||
A path that is not listed is refused. The app writes need `apps.write`.
|
||||
A path that is not listed is refused. The app writes need `apps.write`. `hub/servers/run` (blocking, Blocked/Allowed and cache on several
|
||||
servers at once) needs `operate`.
|
||||
|
||||
The sections a server is managed from (stage 5) are listed by what the console calls. A
|
||||
write there goes to ONE server (`node=<id>`) with that server's admin token. Capabilities:
|
||||
@@ -116,7 +117,7 @@ export const ROUTES: Record<string, Route> = {
|
||||
'dnsClient/resolve': readOne(),
|
||||
// Settings. `settings/get` (above) is not `perServer`: the header's blocking mark reads it without a node.
|
||||
'settings/getTsigKeyNames': readOne(),
|
||||
'settings/set': writeOne('admin'),
|
||||
'settings/set': writeOne('admin'), // see `effectiveRoute` for the blocking switch
|
||||
'settings/temporaryDisableBlocking': writeOne('operate'),
|
||||
'settings/forceUpdateBlockLists': writeOne('operate'),
|
||||
// Logs (no delete).
|
||||
@@ -156,6 +157,8 @@ export const ROUTES: Record<string, Route> = {
|
||||
'hub/apps/import': writeAll('apps.write'),
|
||||
'hub/apps/run': writeAll('apps.write'),
|
||||
'hub/apps/upload': writeAll('apps.write'),
|
||||
// The same change on several servers, outside Apps (servers-run.ts). One endpoint with a fixed list of actions.
|
||||
'hub/servers/run': writeAll('operate'),
|
||||
}
|
||||
|
||||
export function routeFor(apiPath: string): Route | undefined {
|
||||
@@ -173,6 +176,15 @@ export function effectiveRoute(apiPath: string, params: Record<string, string>):
|
||||
const flag = (params.import ?? '').trim().toLowerCase()
|
||||
if (flag !== '' && flag !== 'false') return writeOne('admin')
|
||||
}
|
||||
/*
|
||||
The blocking switch is blocking, not server settings: a `settings/set` that carries nothing but
|
||||
`enableBlocking` needs `operate`, as the temporary switch does. Any other field in the call
|
||||
makes it the usual `admin` write. (`node` and `token` are the hub's own.)
|
||||
*/
|
||||
if (apiPath === 'settings/set') {
|
||||
const fields = Object.keys(params).filter((k) => k !== 'node' && k !== 'token')
|
||||
if (fields.length === 1 && fields[0] === 'enableBlocking') return writeOne('operate')
|
||||
}
|
||||
return routeFor(apiPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -196,13 +196,17 @@ const OPERATE = [
|
||||
['allowed/delete', { domain: 'ok.example' }],
|
||||
['settings/temporaryDisableBlocking', { minutes: '5' }],
|
||||
['settings/forceUpdateBlockLists', {}],
|
||||
// The blocking switch alone is blocking, not server settings (routes.ts `effectiveRoute`).
|
||||
['settings/set', { enableBlocking: 'false' }],
|
||||
] as const
|
||||
const ADMIN = [
|
||||
['zones/records/add', { zone: 'example.com', domain: 'www.example.com', type: 'A', ipAddress: '192.0.2.1' }],
|
||||
['zones/delete', { zone: 'example.com' }],
|
||||
['zones/dnssec/sign', { zone: 'example.com', algorithm: 'ECDSA' }],
|
||||
['dhcp/scopes/enable', { name: 'Default' }],
|
||||
['settings/set', { enableBlocking: 'true' }],
|
||||
['settings/set', { dnsServerDomain: 'x.example' }],
|
||||
// The switch plus any other field is a settings change.
|
||||
['settings/set', { enableBlocking: 'true', dnsServerDomain: 'x.example' }],
|
||||
] as const
|
||||
|
||||
describe('who may write what', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Audit } from './audit.ts'
|
||||
import { effectiveRoute, largeBodyLimit, routeFor, type Route } from './routes.ts'
|
||||
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, redactSettings, safeTarget, webServiceChanges } from './guards.ts'
|
||||
import { buildMatrix } from './apps-matrix.ts'
|
||||
import { ServersRunner, planAction } from './servers-run.ts'
|
||||
import { AppsWriter, actionForPath, type FileAction, type TargetResult } from './apps-write.ts'
|
||||
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
|
||||
import { AppsConfig, UserError } from './apps-config.ts'
|
||||
@@ -144,6 +145,7 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
const limiter = new LoginLimiter()
|
||||
const audit = new Audit(config.dataDir)
|
||||
const writer = new AppsWriter(config, upstream)
|
||||
const runner = new ServersRunner(config, upstream)
|
||||
const appsConfig = new AppsConfig(config, upstream)
|
||||
const byId = new Map(config.servers.map((s) => [s.id, s]))
|
||||
|
||||
@@ -384,6 +386,27 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
|
||||
await configWrite(res, apiPath, params, who, ip)
|
||||
return
|
||||
}
|
||||
if (apiPath === 'hub/servers/run') {
|
||||
const planned = planAction(params.action, params)
|
||||
const line = { user: who.user, role: who.role, ip, action: 'error' in planned ? 'servers.run' : `servers.${planned.plan.action}` }
|
||||
if ('error' in planned) {
|
||||
audit.log({ ...line, outcome: 'refused', detail: planned.error })
|
||||
send(res, 400, fail(planned.error))
|
||||
return
|
||||
}
|
||||
const outcome = await runner.run(planned.plan, csv(params.targets))
|
||||
if ('error' in outcome) {
|
||||
audit.log({ ...line, target: planned.plan.target, outcome: 'refused', detail: outcome.error })
|
||||
send(res, 400, fail(outcome.error))
|
||||
return
|
||||
}
|
||||
// One line per server. The target is a domain or a count, never the list of URLs.
|
||||
for (const r of outcome.results) {
|
||||
audit.log({ ...line, server: r.server, target: planned.plan.target, outcome: r.ok ? 'ok' : 'failed', detail: r.message })
|
||||
}
|
||||
send(res, 200, ok({ results: outcome.results }))
|
||||
return
|
||||
}
|
||||
if (apiPath === 'hub/apps/run') {
|
||||
const action = params.action
|
||||
if (action !== 'install-store' && action !== 'update-store' && action !== 'uninstall') {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -35,9 +35,9 @@ and is always there. Administration (users, SSO, LDAP of each server) is not off
|
||||
What a role may change comes from its capabilities, per key. Settings never get `canDelete`
|
||||
(that is the backup and restore, which the hub does not forward), nor do the Logs and the
|
||||
Dashboard (deleting logs and stats stays in each server's own console). DNS Client needs
|
||||
`admin` to modify because its Import writes a zone. The Settings key also gates the
|
||||
temporary blocking switch in the console, which the hub itself would let an operator use:
|
||||
the console is the stricter of the two there.
|
||||
`admin` to modify because its Import writes a zone. The blocking switch and the block list update are blocking,
|
||||
so behind a hub the console asks for Blocked/Allowed Modify there (which an operator has), not for Settings Modify (which
|
||||
only an admin has). Saving the list URLs of one server stays a settings change.
|
||||
*/
|
||||
const PERMISSION_KEYS = [
|
||||
'Dashboard', 'Zones', 'Cache', 'Allowed', 'Blocked', 'Apps', 'DnsClient', 'Settings', 'DhcpServer', 'Administration', 'Logs',
|
||||
|
||||
@@ -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
@@ -33,6 +33,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { HubMode, ThisServerName } from '../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../lib/hub-write'
|
||||
import { HubServerIds } from '../lib/hub-all'
|
||||
import { FooterLinks } from '../ui/FooterLinks'
|
||||
import { Versions } from './Versions'
|
||||
import { BlockingMark } from './BlockingMark'
|
||||
@@ -141,6 +142,8 @@ export function Shell({
|
||||
const hub = session.info?.hub === true
|
||||
/* Behind a hub: the servers a change can be made on (the screens read it through `HubWritable`). */
|
||||
const hubWritable = session.info?.hubWritable ?? NO_SERVERS
|
||||
/* And every server it fronts, for the picker of a change made on several at once. */
|
||||
const hubServers = useMemo(() => (session.info?.clusterNodes ?? []).map((n) => n.name), [session.info?.clusterNodes])
|
||||
const hubWarnings = useHubWarnings()
|
||||
/* Behind a hub: the count comes with the session, the list is read now and then. */
|
||||
const hubDriftAtStart = session.info?.hubDrift
|
||||
@@ -276,7 +279,7 @@ export function Shell({
|
||||
|
||||
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||
/* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'))
|
||||
const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking'), hub)
|
||||
|
||||
/*
|
||||
The jumps other screens' row menus make (`app/handoff.ts`). The section switch
|
||||
@@ -429,6 +432,7 @@ export function Shell({
|
||||
<ThisServerName.Provider value={info?.dnsServerDomain ?? session.info?.dnsServerDomain ?? ''}>
|
||||
<HubMode.Provider value={hub}>
|
||||
<HubWritable.Provider value={hubWritable}>
|
||||
<HubServerIds.Provider value={hubServers}>
|
||||
<SlotProvider>
|
||||
{(slot) => (
|
||||
<div className={styles.shell} data-rail={rail}>
|
||||
@@ -664,7 +668,7 @@ export function Shell({
|
||||
token={session.token}
|
||||
nodes={session.info?.clusterNodes ?? []}
|
||||
clusterInitialised={session.info?.clusterInitialized === true}
|
||||
canBlock={hub ? permissions?.Settings?.canModify !== false : true}
|
||||
canBlock={hub ? permissions?.Blocked?.canModify !== false && permissions?.Allowed?.canModify !== false : true}
|
||||
/>
|
||||
) : current?.id === 'dnsclient' ? (
|
||||
<DnsClient
|
||||
@@ -847,6 +851,7 @@ export function Shell({
|
||||
</div>
|
||||
)}
|
||||
</SlotProvider>
|
||||
</HubServerIds.Provider>
|
||||
</HubWritable.Provider>
|
||||
</HubMode.Provider>
|
||||
</ThisServerName.Provider>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useContext, useId, useRef, useState } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { HubAll } from '../../lib/hub-all'
|
||||
import { addDomain, type DomainList } from '../../api/zonelists'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { Field, Input } from '../../ui/Field'
|
||||
import { Loading } from '../../ui/Empty'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { LockedItem } from './Locked'
|
||||
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
|
||||
import { noticeFromFailure, type Notice } from '../../lib/notice'
|
||||
import { checkDomain, verdictOf, type Verdict } from './check-domain'
|
||||
@@ -54,6 +57,8 @@ export function AddDomainBar({
|
||||
}) {
|
||||
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
|
||||
const node = useContext(HubNode)
|
||||
/* With "All servers" chosen the verbs open the flow that makes the change on the servers picked next. */
|
||||
const all = useContext(HubAll)
|
||||
const [field, setField] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const entry = useRef<HTMLInputElement>(null)
|
||||
@@ -122,6 +127,24 @@ export function AddDomainBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (all !== null) {
|
||||
const verb = list === 'allowed' ? 'Allowed' : 'Blocked'
|
||||
all({
|
||||
action: `${list}.add`,
|
||||
params: { domain: domain.trim() },
|
||||
title: `Add '${domain.trim()}' to ${verb} on several servers`,
|
||||
confirmLabel: `Add to ${verb}`,
|
||||
onDone: (changed) => {
|
||||
if (changed) {
|
||||
setField('')
|
||||
forget()
|
||||
onChanged(list, domain)
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
|
||||
setBusy(false)
|
||||
@@ -140,6 +163,30 @@ export function AddDomainBar({
|
||||
onChanged(list, domain)
|
||||
}
|
||||
|
||||
/* Removing a rule from Blocked or Allowed: only offered for several servers at once, since on one server the Rules tab does it. */
|
||||
function remove(list: DomainList) {
|
||||
const domain = field.trim()
|
||||
if (domain === '') {
|
||||
onNotice({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to remove.' })
|
||||
entry.current?.focus()
|
||||
return
|
||||
}
|
||||
const verb = list === 'allowed' ? 'Allowed' : 'Blocked'
|
||||
all?.({
|
||||
action: `${list}.delete`,
|
||||
params: { domain },
|
||||
title: `Remove '${domain}' from ${verb} on several servers`,
|
||||
confirmLabel: `Remove from ${verb}`,
|
||||
onDone: (changed) => {
|
||||
if (changed) {
|
||||
setField('')
|
||||
forget()
|
||||
onChanged(list, domain)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Check, block or allow a domain">
|
||||
<Body>
|
||||
@@ -192,6 +239,33 @@ export function AddDomainBar({
|
||||
>
|
||||
Check
|
||||
</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>
|
||||
{/* One live region for the whole answer: the wait and the verdict that
|
||||
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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { AllServersActions } from './AllServersActions'
|
||||
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
|
||||
import { getDashboardStats } from '../../api/dashboard'
|
||||
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
} from './list-lines'
|
||||
import { Locked } from './Locked'
|
||||
import { hostsOf, moreLists } from './extra-lists'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './BlockLists.module.css'
|
||||
import { UrlText } from '../../ui/UrlText'
|
||||
@@ -202,11 +204,14 @@ export function BlockLists({
|
||||
permissions,
|
||||
clusterInitialised = false,
|
||||
onDirty,
|
||||
allServers = false,
|
||||
}: {
|
||||
tabs?: ReactNode
|
||||
token: string | null
|
||||
permissions: Permissions
|
||||
clusterInitialised?: boolean
|
||||
/** Behind a hub: the role may change blocking on several servers at once, so the actions for that are offered. */
|
||||
allServers?: boolean
|
||||
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
|
||||
onDirty?: (dirty: boolean) => void
|
||||
}) {
|
||||
@@ -215,6 +220,9 @@ export function BlockLists({
|
||||
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
|
||||
const viewNeed = missing(permissions, 'Settings.canView')
|
||||
const modifyNeed = missing(permissions, 'Settings.canModify')
|
||||
/* Behind a hub the list update is a blocking action (an operator's), unlike saving the URLs, which is a settings change. */
|
||||
const hub = useContext(HubMode)
|
||||
const updateNeed = switchNeed(permissions, hub)
|
||||
const statsNeed = missing(permissions, 'Dashboard.canView')
|
||||
const invalidId = useId()
|
||||
|
||||
@@ -624,6 +632,7 @@ export function BlockLists({
|
||||
</>
|
||||
)}
|
||||
<Panel className={shared.centred}><Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={shared.kpi}>
|
||||
<div className={shared.kpiText}>
|
||||
<span className={shared.kpiValue}>{loading ? '—' : nextUpdateText(next)}</span>
|
||||
@@ -633,13 +642,19 @@ export function BlockLists({
|
||||
</span>
|
||||
</div>
|
||||
<PermissionButton
|
||||
permission={modifyNeed}
|
||||
permission={updateNeed}
|
||||
disabled={busy || !hasSaved}
|
||||
onClick={() => setAskUpdate(true)}
|
||||
>
|
||||
Update Now
|
||||
</PermissionButton>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getSettings } from '../../api/settings'
|
||||
import { getDashboardStats } from '../../api/dashboard'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { missing, withoutWrites } from './permissions'
|
||||
import { missing, switchNeed, withoutWrites } from './permissions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ruleSearch } from './rules-model'
|
||||
@@ -69,8 +69,9 @@ function useCounts(token: string | null, permissions: Permissions, node = '') {
|
||||
itself, so the strip is for the other two. */
|
||||
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
|
||||
const { state } = useBlockingState(token, permissions, true, node)
|
||||
const hub = useContext(HubMode)
|
||||
if (state == null || state === 'failed') return null
|
||||
const canModify = missing(permissions, 'Settings.canModify') == null
|
||||
const canModify = switchNeed(permissions, hub) == null
|
||||
return (
|
||||
<div className={shared.banner} role="status">
|
||||
{state.kind === 'on' && <Tag tone="ok">Enabled</Tag>}
|
||||
@@ -186,6 +187,7 @@ export function Blocking({
|
||||
permissions={effective}
|
||||
clusterInitialised={clusterInitialised}
|
||||
onDirty={setListsDirty}
|
||||
allServers={hub && missing(permissions, 'Blocked.canModify', 'Allowed.canModify') == null}
|
||||
/>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, withoutWrites, type Permissions } from './permissions'
|
||||
import { HubNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubAll, useAllServersFlow } from '../../lib/hub-all'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
@@ -203,6 +204,15 @@ export function Overview({
|
||||
const hub = useContext(HubMode)
|
||||
const write = useHubWrite(node)
|
||||
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
|
||||
/*
|
||||
With "All servers" chosen a role that may change blocking still can: the controls of the status
|
||||
block and the add bar open a flow that picks the servers (`HubAll`). The tables and the recent
|
||||
list below keep the switched-off permissions, since a row acts on one server.
|
||||
*/
|
||||
const all = useAllServersFlow(token)
|
||||
const allServers =
|
||||
hub && node === AGGREGATE && missing(rolePermissions, 'Blocked.canModify', 'Allowed.canModify') == null
|
||||
const barPermissions = allServers ? rolePermissions : permissions
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
/** The first read of this period and node failed: there is nothing to show. */
|
||||
const [failure, setFailure] = useState(false)
|
||||
@@ -326,6 +336,8 @@ export function Overview({
|
||||
|
||||
return (
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<HubAll.Provider value={allServers ? all.start : null}>
|
||||
{all.flow}
|
||||
<InChrome>
|
||||
<ClusterNodeSelect
|
||||
nodes={nodes}
|
||||
@@ -335,7 +347,7 @@ export function Overview({
|
||||
onChange={setNode}
|
||||
/>
|
||||
</InChrome>
|
||||
<HubWriteHint write={write} />
|
||||
<HubWriteHint write={write} allServers={allServers} />
|
||||
|
||||
<SectionHeader
|
||||
title="Blocking"
|
||||
@@ -379,10 +391,10 @@ export function Overview({
|
||||
)}
|
||||
|
||||
<div className={`${shared.row2} ${styles.checkRow}`}>
|
||||
<StatusPanel token={token} permissions={permissions} onNotice={setNotice} />
|
||||
<StatusPanel token={token} permissions={barPermissions} onNotice={setNotice} />
|
||||
<AddDomainBar
|
||||
token={token}
|
||||
permissions={permissions}
|
||||
permissions={barPermissions}
|
||||
onNotice={setNotice}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
@@ -473,6 +485,7 @@ export function Overview({
|
||||
refreshOn={s?.totalBlocked}
|
||||
/>
|
||||
</div>
|
||||
</HubAll.Provider>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useContext } from 'react'
|
||||
import { HubNode } from '../../lib/hub-write'
|
||||
import { HubAll } from '../../lib/hub-all'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
@@ -9,7 +11,8 @@ import { Icon } from '../../ui/Icon'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import type { Notice } from '../../lib/notice'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
import { AllServersActions } from './AllServersActions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
@@ -49,10 +52,14 @@ export function StatusPanel({
|
||||
/* Behind a hub: the server the section has chosen. */
|
||||
const node = useContext(HubNode)
|
||||
const { state, retry } = useBlockingState(token, permissions, true, node)
|
||||
/* Behind a hub the switch is a blocking change (Blocked and Allowed), not a settings one. With "All
|
||||
servers" chosen it opens the flow, and the list actions for several servers sit beside it. */
|
||||
const hub = useContext(HubMode)
|
||||
const all = useContext(HubAll)
|
||||
|
||||
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
|
||||
|
||||
const modifyNeed = missing(permissions, 'Settings.canModify')
|
||||
const modifyNeed = switchNeed(permissions, hub)
|
||||
|
||||
if (state === 'failed') {
|
||||
return (
|
||||
@@ -77,6 +84,7 @@ export function StatusPanel({
|
||||
return (
|
||||
<Panel className={shared.centred}>
|
||||
<Body>
|
||||
<div className="flex flex-col gap-4">
|
||||
{state == null ? (
|
||||
<Loading compact />
|
||||
) : (
|
||||
@@ -117,6 +125,12 @@ export function StatusPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state != null && all !== null && modifyNeed == null && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AllServersActions token={token} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Body>
|
||||
</Panel>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { getSettings } from '../../api/settings'
|
||||
import { minuteStamp } from '../../lib/dates'
|
||||
import { onSettingsChanged } from '../../lib/settings-signal'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { missing, switchNeed, type Permissions } from './permissions'
|
||||
|
||||
/*
|
||||
Whether blocking is on, read where the Overview's status block reads it
|
||||
@@ -128,10 +128,10 @@ export interface BlockingMark {
|
||||
tone: 'acc' | 'neutral'
|
||||
}
|
||||
|
||||
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date()): BlockingMark | null {
|
||||
export function blockingMark(state: BlockingRead, permissions: Permissions, now = new Date(), hub = false): BlockingMark | null {
|
||||
if (state == null || state === 'failed' || state.kind === 'on') return null
|
||||
if (state.kind === 'off') return { text: 'Off', label: 'Blocking, disabled', tone: 'neutral' }
|
||||
const canLift = missing(permissions, 'Settings.canModify') == null
|
||||
const canLift = switchNeed(permissions, hub) == null
|
||||
return {
|
||||
text: 'Paused',
|
||||
label: `Blocking, paused ${untilText(state.till, now).replace(/^Until/, 'until')}`,
|
||||
@@ -140,7 +140,7 @@ export function blockingMark(state: BlockingRead, permissions: Permissions, now
|
||||
}
|
||||
|
||||
/** The mark for the sidebar's Blocking entry; `visible` false when there is no entry. */
|
||||
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean): BlockingMark | null {
|
||||
export function useBlockingMark(token: string | null, permissions: Permissions, visible: boolean, hub = false): BlockingMark | null {
|
||||
const { state } = useBlockingState(token, permissions, visible)
|
||||
return blockingMark(state, permissions)
|
||||
return blockingMark(state, permissions, new Date(), hub)
|
||||
}
|
||||
@@ -19,6 +19,16 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
/*
|
||||
What the blocking switch (the temporary disable, enable, and the block list update) asks for.
|
||||
Upstream asks `Settings: Modify` of the call. Behind a hub those calls are blocking, which an
|
||||
operator may do (`Blocked` and `Allowed` Modify), while `Settings: Modify` is an admin's: the
|
||||
switch follows the blocking permission there, and is unchanged anywhere else.
|
||||
*/
|
||||
export function switchNeed(perms: Permissions, hub: boolean): Need | undefined {
|
||||
return hub ? missing(perms, 'Blocked.canModify', 'Allowed.canModify') : missing(perms, 'Settings.canModify')
|
||||
}
|
||||
|
||||
/*
|
||||
Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server
|
||||
the hub holds no admin token for): the same permissions with every write switched off. The
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { getSettings, setSettings, temporaryDisableBlocking } from '../../api/settings'
|
||||
import { Confirm } from '../../ui/Confirm'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import { Button } from '../../ui/Button'
|
||||
import { Tooltip } from '../../ui/Tooltip'
|
||||
import { nodeArg, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubAll } from '../../lib/hub-all'
|
||||
import type { AlertType } from '../../ui/Alert'
|
||||
import { noticeFromFailure } from '../../lib/notice'
|
||||
|
||||
@@ -73,6 +74,8 @@ export function BlockingMenu({
|
||||
const [active, setActive] = useState<boolean | null>(null)
|
||||
const [pending, setPending] = useState<Pending | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
/* Behind a hub with "All servers" chosen: the choice opens the flow that makes it on the servers picked next. */
|
||||
const all = useContext(HubAll)
|
||||
|
||||
async function loadState() {
|
||||
setActive(null)
|
||||
@@ -137,6 +140,42 @@ export function BlockingMenu({
|
||||
|
||||
/* Behind a hub, only on a chosen server the hub can write to; the tooltip says why not. */
|
||||
const write = useHubWrite(node, allowed)
|
||||
if (all !== null) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<Tooltip text={write.reason ?? 'Requires Settings: Modify'}>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Cache } from './Lists'
|
||||
import * as api from '../../api/zonelists'
|
||||
import { HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubWritable } from '../../lib/hub-write'
|
||||
import { HubServerIds } from '../../lib/hub-all'
|
||||
import * as hubRun from '../../api/hub-run'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
@@ -19,7 +21,9 @@ function hubCache(writable: string[], canDelete = true) {
|
||||
return render(
|
||||
<HubMode.Provider value>
|
||||
<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>
|
||||
</HubMode.Provider>,
|
||||
)
|
||||
@@ -47,3 +51,48 @@ describe('Cache behind a hub', () => {
|
||||
expect(screen.getByRole('button', { name: 'Flush Cache' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cache behind a hub, on several servers', () => {
|
||||
const done = (...r: { server: string; ok: boolean; message?: string }[]) => ({ kind: 'ok', data: { response: { results: r } } }) as never
|
||||
|
||||
it('flushes the cache on the servers picked, and retries only the one that failed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const run = vi
|
||||
.spyOn(hubRun, 'runOnServers')
|
||||
.mockResolvedValueOnce(done({ server: 'mdns', ok: true, message: 'Cache flushed.' }, { server: 'ndns', ok: false, message: 'ndns did not answer' }))
|
||||
.mockResolvedValueOnce(done({ server: 'ndns', ok: true, message: 'Cache flushed.' }))
|
||||
hubCache(['mdns', 'ndns'])
|
||||
await user.click(await screen.findByRole('button', { name: 'Flush on all servers' }))
|
||||
const picker = await screen.findByRole('dialog')
|
||||
await user.click(within(picker).getByRole('button', { name: 'Flush cache' }))
|
||||
expect(run).toHaveBeenNthCalledWith(1, 't', 'cache.flush', {}, ['mdns', 'ndns'])
|
||||
const list = await screen.findByRole('list', { name: 'Results' })
|
||||
expect(within(list).getByText('ndns did not answer')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Retry failed' }))
|
||||
expect(run).toHaveBeenNthCalledWith(2, 't', 'cache.flush', {}, ['ndns'])
|
||||
})
|
||||
|
||||
it('deletes one domain on the servers picked, after asking for it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const run = vi.spyOn(hubRun, 'runOnServers').mockResolvedValue(done({ server: 'mdns', ok: true }))
|
||||
hubCache(['mdns'])
|
||||
await user.click(await screen.findByRole('button', { name: 'Delete a domain on all servers' }))
|
||||
const ask = await screen.findByRole('dialog')
|
||||
await user.type(within(ask).getByLabelText('Domain'), 'bad domain')
|
||||
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
|
||||
expect(await within(ask).findByRole('alert')).toHaveTextContent('not a valid domain')
|
||||
await user.clear(within(ask).getByLabelText('Domain'))
|
||||
await user.type(within(ask).getByLabelText('Domain'), 'x.example')
|
||||
await user.click(within(ask).getByRole('button', { name: 'Choose servers' }))
|
||||
const picker = await screen.findByRole('dialog')
|
||||
expect(within(picker).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
|
||||
await user.click(within(picker).getByRole('button', { name: 'Delete from cache' }))
|
||||
expect(run).toHaveBeenCalledWith('t', 'cache.delete', { domain: 'x.example' }, ['mdns'])
|
||||
})
|
||||
|
||||
it('is not offered to a role that may not delete from the cache', async () => {
|
||||
hubCache(['mdns', 'ndns'], false)
|
||||
await screen.findByRole('button', { name: 'Flush Cache' })
|
||||
expect(screen.queryByRole('button', { name: 'Flush on all servers' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { KpiStrip } from '../../ui/KpiStrip'
|
||||
import { ClusterNodeSelect, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||
import { ClusterNodeSelect, HubMode, primaryNodeName } from '../../ui/ClusterNodeSelect'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
@@ -24,6 +24,7 @@ import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Empty, Loading } from '../../ui/Empty'
|
||||
import { StaleData } from '../StaleData'
|
||||
import { Tree } from './Tree'
|
||||
import { CacheAllServers } from './CacheAllServers'
|
||||
import { Path } from './Path'
|
||||
import { CacheTree } from './CacheTree'
|
||||
import { Frame, FrameHeader, FramePanel } from '@/components/reui/frame'
|
||||
@@ -247,6 +248,7 @@ export function Lists({
|
||||
const sectionRef = useRef(sectionNode)
|
||||
const listServer = embedded ? sectionNode : ''
|
||||
/* A change is allowed only on a chosen server the hub can write to (outside a hub: the permission). */
|
||||
const hub = useContext(HubMode)
|
||||
const cacheWrite = useHubWrite(clusterNode, canDelete)
|
||||
const listWrite = useHubWrite(listServer, canDelete)
|
||||
|
||||
@@ -490,9 +492,12 @@ export function Lists({
|
||||
subtitle={isCache ? 'Records cached by the recursive resolver' : undefined}
|
||||
actions={
|
||||
isCache ? (
|
||||
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
|
||||
Flush Cache
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="danger" disabled={busy || !cacheWrite.allowed} onClick={askFlushCache}>
|
||||
Flush Cache
|
||||
</Button>
|
||||
{hub && canDelete && <CacheAllServers token={token} />}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useState } from 'react'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { useAllServersFlow } from '../../lib/hub-all'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { AGGREGATE, ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
@@ -72,6 +73,9 @@ export function Logs({
|
||||
const node = hub && !aggregateShown && (picked === '' || picked === AGGREGATE) ? defaultNode : picked
|
||||
/* The Allow and Block actions of a row: one server at a time. */
|
||||
const write = useHubWrite(node, canBlock)
|
||||
/* With "All servers" chosen a row's Allow and Block open the flow, with the row's own server ticked. */
|
||||
const all = useAllServersFlow(token)
|
||||
const allServers = aggregateShown && node === AGGREGATE && canBlock
|
||||
|
||||
/*
|
||||
The bar is built once here and handed to whichever sub-screen is drawn: the same
|
||||
@@ -100,10 +104,29 @@ export function Logs({
|
||||
aggregate={aggregateShown}
|
||||
label="Cluster Node"
|
||||
/>
|
||||
{active === 'Query Logs' && <HubWriteHint write={write} />}
|
||||
{active === 'Query Logs' && <HubWriteHint write={write} allServers={allServers} />}
|
||||
{active === 'Query Logs' ? (
|
||||
<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>
|
||||
) : (
|
||||
<ViewLogs tabs={tabs}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -31,7 +31,7 @@ import { Notifier } from '../../ui/Notifier'
|
||||
import { Menu } from '../../ui/Menu'
|
||||
import tbl from '../../ui/Table.module.css'
|
||||
import { useHandoff, type QueryLogsRequest } from '../../app/handoff'
|
||||
import { useDomainAction, verbFor } from '../../lib/allow-block'
|
||||
import { useDomainAction, verbFor, type DomainVerb } from '../../lib/allow-block'
|
||||
import { useMedia } from '../../lib/use-media'
|
||||
import { Icon } from '../../ui/Icon'
|
||||
|
||||
@@ -321,11 +321,13 @@ export interface QueryLogsProps {
|
||||
node?: string
|
||||
/** Whether a row may be allowed or blocked (behind a hub: a server is chosen and writable). */
|
||||
canBlock?: boolean
|
||||
/** Behind a hub with "All servers" chosen: a row's Allow or Block goes through this, with the row's server. */
|
||||
onAll?: (verb: DomainVerb, domain: string, server: string | undefined) => void
|
||||
/** `showQueryLogs` from another screen: these filters, queried on arrival. */
|
||||
request?: QueryLogsRequest
|
||||
}
|
||||
|
||||
export function QueryLogs({ tabs, token, node = '', request, canBlock = true }: QueryLogsProps) {
|
||||
export function QueryLogs({ tabs, token, node = '', request, canBlock = true, onAll }: QueryLogsProps) {
|
||||
const [apps, setApps] = useState<{ name: string; classPaths: string[] }[] | null>(null)
|
||||
const [f, setF] = useState<Filters>(() => defaultFilters('', ''))
|
||||
const [page, setPage] = useState<QueryLogPage | null>(null)
|
||||
@@ -1043,10 +1045,11 @@ export function QueryLogs({ tabs, token, node = '', request, canBlock = true }:
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={action.isBusy(key) || !canBlock}
|
||||
disabled={(onAll === undefined && action.isBusy(key)) || !canBlock}
|
||||
onClick={() => {
|
||||
close()
|
||||
void action.run(key, verb, domain)
|
||||
if (onAll !== undefined) onAll(verb, domain, e.node)
|
||||
else void action.run(key, verb, domain)
|
||||
}}
|
||||
>
|
||||
{verb}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
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 (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 (
|
||||
<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.`}
|
||||
|
||||
Reference in new issue
Block a user