Hub stage 6: blocking and cache actions on several servers

One endpoint, hub/servers/run (operate), runs a fixed set of actions on
the chosen servers in parallel with each server's admin token: add or
remove a domain in Blocked or Allowed, disable blocking for a time or
enable it, update block lists, replace the block list URLs, flush the
cache or delete a domain from it. Each server gets its own result and
the console offers Retry failed.

In "All servers", Blocking, Cache and Query Logs rows open this flow
instead of being disabled. The hub validates domains, minutes and the
server's list grammar (allow lists with !, switched-off lines with #)
and logs no URL list. Operators can now use the blocking switch.

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

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
/*
Actions on several servers at once, outside Apps (stage 6): Blocked and Allowed, the temporary
blocking switch, the block lists, and the cache. One endpoint, `hub/servers/run`, takes a fixed
`action` and the action's own parameters, checks them HERE, and runs the one matching Technitium
call on every chosen server, in parallel, with that server's admin token.
* `planAction` is pure: the allowlist, the validation, and the path and parameters to send.
* `ServersRunner` runs a plan on targets. A server that is unknown or has no admin token fails
on its own; the others still run. The answer is one result per server, never a bare "ok".
The block list URLs are part of blocking, so `blocking.setListUrls` needs `operate` like the rest,
not `admin` like the per-server `settings/set`. Only `blockListUrls` is sent, so nothing else of
the server's settings is touched, and the plan still goes through the checks of guards.ts.
*/
import type { HubConfig } from './config.ts'
import { isForbiddenSettingsField } from './guards.ts'
import { Upstream } from './upstream.ts'
export const SERVER_ACTIONS = [
'blocked.add',
'blocked.delete',
'allowed.add',
'allowed.delete',
'blocking.disable',
'blocking.enable',
'blocking.updateLists',
'blocking.setListUrls',
'cache.flush',
'cache.delete',
] as const
export type ServerAction = (typeof SERVER_ACTIONS)[number]
export interface TargetResult {
server: string
ok: boolean
message?: string
}
export interface Plan {
action: ServerAction
/* The Technitium endpoint. */
path: string
/* Exactly what is sent (a form body). */
params: Record<string, string>
/* What the audit line may say about it: a domain, `minutes=15`, `urls=3`. Never the URLs. */
target?: string
/* The sentence for a server where it worked. */
done: string
}
export const MAX_MINUTES = 1440
const MAX_URL_LENGTH = 2000
const LABEL = '[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?'
const DOMAIN = new RegExp(`^(?:\\*\\.)?(?:${LABEL}\\.)*${LABEL}$`, 'i')
/* A domain as the hub accepts it (a trailing dot is dropped), or null. */
export function cleanDomain(value: string | undefined): string | null {
const domain = (value ?? '').trim().replace(/\.$/, '')
return domain.length > 0 && domain.length <= 253 && DOMAIN.test(domain) ? domain : null
}
/*
The lines of a block list request, one per line, in the grammar of the servers and of the console's
Lists tab (src/screens/blocking/list-lines.ts): `<url>` a block list, `!<url>` an allow list,
`#...` a switched-off list or a comment (a group heading), free text. Each URL is http, https or
file. Blank lines go. They travel as the console's own Settings save sends them: joined with a
comma. A comma cannot be told from the separator, so a line that has one is refused, not split.
*/
export const MAX_LINES = 300
export const MAX_COMMENT = 200
const SCHEMES = new Set(['http:', 'https:', 'file:'])
export function cleanUrls(value: string | undefined): { urls: string[] } | { error: string } {
const lines = (value ?? '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l !== '')
if (lines.length === 0) return { error: 'Give at least one block list URL.' }
if (lines.length > MAX_LINES) return { error: `At most ${MAX_LINES} lines.` }
for (const line of lines) {
if (line.includes(',')) return { error: `A line cannot contain a comma: ${line.slice(0, 60)}` }
if (line.startsWith('#')) {
if (line.length > MAX_COMMENT) return { error: 'A comment line is too long.' }
continue
}
if (line.length > MAX_URL_LENGTH) return { error: 'A block list URL is too long.' }
const text = line.startsWith('!') ? line.slice(1).trim() : line
let url: URL
try {
url = new URL(text)
} catch {
return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` }
}
if (!SCHEMES.has(url.protocol) || /\s/.test(text)) return { error: `Not a valid http, https or file address: ${line.slice(0, 60)}` }
}
return { urls: lines }
}
export function planAction(action: string | undefined, raw: Record<string, string | undefined>): { error: string } | { plan: Plan } {
if (action === undefined || !(SERVER_ACTIONS as readonly string[]).includes(action)) {
return { error: `action must be one of: ${SERVER_ACTIONS.join(', ')}.` }
}
const kind = action as ServerAction
switch (kind) {
case 'blocked.add':
case 'blocked.delete':
case 'allowed.add':
case 'allowed.delete':
case 'cache.delete': {
const domain = cleanDomain(raw.domain)
if (domain === null) return { error: 'A valid domain name is required.' }
const [list, verb] = kind.split('.')
const path = list === 'cache' ? 'cache/delete' : `${list}/${verb}`
const done =
kind === 'cache.delete' ? 'Deleted from the cache.' : `${verb === 'add' ? 'Added to' : 'Removed from'} ${list === 'blocked' ? 'Blocked' : 'Allowed'}.`
return { plan: { action: kind, path, params: { domain }, target: domain, done } }
}
case 'blocking.disable': {
const text = (raw.minutes ?? '').trim()
const minutes = /^\d{1,5}$/.test(text) ? Number(text) : 0
if (minutes < 1 || minutes > MAX_MINUTES) return { error: `minutes must be a whole number from 1 to ${MAX_MINUTES}.` }
return {
plan: { action: kind, path: 'settings/temporaryDisableBlocking', params: { minutes: String(minutes) }, target: `minutes=${minutes}`, done: `Blocking disabled for ${minutes} minute(s).` },
}
}
case 'blocking.enable':
return settingsPlan(kind, { enableBlocking: 'true' }, undefined, 'Blocking enabled.')
case 'blocking.setListUrls': {
const cleaned = cleanUrls(raw.urls)
if ('error' in cleaned) return cleaned
return settingsPlan(kind, { blockListUrls: cleaned.urls.join(',') }, `urls=${cleaned.urls.length}`, `Block list URLs replaced (${cleaned.urls.length} line(s)).`)
}
case 'blocking.updateLists':
return { plan: { action: kind, path: 'settings/forceUpdateBlockLists', params: {}, done: 'Block list update started.' } }
case 'cache.flush':
return { plan: { action: kind, path: 'cache/flush', params: {}, done: 'Cache flushed.' } }
}
}
/* A `settings/set` of one field. The same checks as the per-server route, applied to what the hub itself builds. */
function settingsPlan(action: ServerAction, params: Record<string, string>, target: string | undefined, done: string): { error: string } | { plan: Plan } {
if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return { error: 'That setting is not changed from the hub.' }
return { plan: { action, path: 'settings/set', params, target, done } }
}
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
export class ServersRunner {
#config: HubConfig
#upstream: Upstream
constructor(config: HubConfig, upstream: Upstream) {
this.#config = config
this.#upstream = upstream
}
/*
The plan on every target, in parallel. Returns `{ error }` when the request itself is refused
(no targets, an unknown server), else one result per target.
*/
async run(plan: Plan, targets: string[]): Promise<{ error: string } | { results: TargetResult[] }> {
const known = new Map(this.#config.servers.map((s) => [s.id, s]))
const unique = [...new Set(targets)]
if (unique.length === 0) return { error: 'Choose at least one server.' }
const unknown = unique.filter((t) => !known.has(t))
if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` }
const results = await Promise.all(
unique.map(async (id): Promise<TargetResult> => {
const server = known.get(id)!
if (server.adminToken === undefined) return { server: id, ok: false, message: 'no admin token configured' }
try {
await this.#upstream.write(server, plan.path, plan.params)
return { server: id, ok: true, message: plan.done }
} catch (e) {
return { server: id, ok: false, message: reason(e) }
}
}),
)
return { results }
}
}