Hub stage 6: blocking and cache actions on several servers
One endpoint, hub/servers/run (operate), runs a fixed set of actions on the chosen servers in parallel with each server's admin token: add or remove a domain in Blocked or Allowed, disable blocking for a time or enable it, update block lists, replace the block list URLs, flush the cache or delete a domain from it. Each server gets its own result and the console offers Retry failed. In "All servers", Blocking, Cache and Query Logs rows open this flow instead of being disabled. The hub validates domains, minutes and the server's list grammar (allow lists with !, switched-off lines with #) and logs no URL list. Operators can now use the blocking switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bb1e01936
commit
34de910e04
31 files changed
+1560
-40
No files matched your search
+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',
|
||||
|
||||
Reference in new issue
Block a user