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>
184 lines
8.0 KiB
TypeScript
184 lines
8.0 KiB
TypeScript
/*
|
|
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 }
|
|
}
|
|
}
|