/* 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 /* 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): `` a block list, `!` 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): { 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, 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 => { 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 } } }