Files
technitium-console/hub/settings-config.ts
T
DenozordecandClaude Opus 5.5 392b644faa Hub stage 7: compare and align zones, settings and blocking
Zones gets "All servers" in hub mode: one table of every zone on every
server with its type and serial, marking zones that are on one server
only or whose serials or types differ. A zone can be copied to another
server (export, create, import; Overwrite must be ticked for a zone
that is already there) or made a Secondary there (the source's zone
transfer and notify lists gain the target, then the target zone is
created).

Settings and Blocking can be compared across servers against a baseline
kept in the hub. Host fields stay local, secrets are compared inside
the hub only and never shown or logged, and an apply sends only the
changed shared fields after saving each server's settings to history.
Blocked and Allowed lists are compared as sets; extras are removed only
when asked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:20:05 +07:00

544 lines
25 KiB
TypeScript

/*
Settings and blocking compare across the servers (stage 7): the baseline kept in the hub, the
fields that stay local, a preview, an apply with history and read-back, and the custom Blocked and
Allowed lists compared as sets.
The settings are read with each server's ADMIN token when the hub has one (a read-only token may
see blanked secrets, and a baseline imported from those would blank them elsewhere). Apply sends
ONLY the shared fields that differ, through the same checks as a per-server `settings/set`
(guards.ts), after saving the server's settings to the history, and reads them back.
Nothing here puts a secret value, or a hash of one, in an answer; the audit lines carry field
names only (see settings-sync.ts).
*/
import { createHash } from 'node:crypto'
import type { HubConfig, HubServer } from './config.ts'
import { WEB_SERVICE_MESSAGE, isForbiddenSettingsField, webServiceChanges } from './guards.ts'
import {
BLOCKING_FIELDS,
LOCAL_DEFAULTS,
MAX_DELETES,
MAX_LIST,
changedFields,
compareSettings,
diffSets,
hasSecret,
isBuiltInLocal,
isFieldName,
maskSettings,
maskValue,
normalize,
parseDomains,
prepare,
restoreSecrets,
sameValue,
toParams,
type Comparison,
} from './settings-sync.ts'
import { cleanDomain } from './servers-run.ts'
import { UserError } from './apps-config.ts'
import type { FieldChange } from './apps-sync.ts'
import { MAX_CONFIG } from './apps-sync.ts'
import {
listHistoryIn,
readHistoryIn,
readListsBaseline,
readSettingsBaseline,
readSettingsOverrides,
settingsHistoryDir,
writeHistoryIn,
writeListsBaseline,
writeSettingsBaseline,
writeSettingsOverrides,
type HistoryEntry,
type ListsBaseline,
type SettingsBaseline,
} from './store.ts'
import { Upstream } from './upstream.ts'
import { readCapped } from './zones-sync.ts'
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
const hashOf = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16)
export const MAX_ADDS = 1000
const LIST_TIMEOUT_MS = 30_000
const LIST_BYTES = 8 * 1024 * 1024
export interface TargetResult {
server: string
ok: boolean
message?: string
}
export interface SettingsTargetPreview {
server: string
ok: boolean
error?: string
writable: boolean
/* Tells the apply whether the server changed since this preview. Built from masked values. */
currentHash: string
/* `{kind:'json'}` so the console's field table draws it as it does a config. */
changes: { kind: 'json'; changes: FieldChange[] }
/* Fields whose change is a secret: shown as "secret changed", never as values. */
secretFields: string[]
unchanged: boolean
warnings: string[]
}
export interface ListsServerView {
id: string
ok: boolean
error?: string
writable: boolean
blocked: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] }
allowed: { count: number; missing: number; extra: number; missingSample: string[]; extraSample: string[] }
status: 'same' | 'differs' | 'unknown'
}
const SAMPLE = 100
export class SettingsConfig {
#config: HubConfig
#upstream: Upstream
#now: () => number
constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#now = now
}
#server(id: string): HubServer {
const found = this.#config.servers.find((s) => s.id === id)
if (found === undefined) throw new UserError(`Unknown server: ${id}.`)
return found
}
#targets(ids: string[]): HubServer[] {
const unique = [...new Set(ids)]
if (unique.length === 0) throw new UserError('Choose at least one server.')
return unique.map((id) => this.#server(id))
}
/* Fresh `settings/get`, with the admin token when there is one. */
async #read(server: HubServer): Promise<unknown> {
this.#upstream.invalidate(server.id)
return this.#upstream.call(server, 'settings/get', {}, server.adminToken ?? server.token)
}
async #readAll(): Promise<Record<string, { settings: unknown } | { error: string }>> {
const entries = await Promise.all(
this.#config.servers.map(async (s): Promise<[string, { settings: unknown } | { error: string }]> => {
try {
return [s.id, { settings: await this.#read(s) }]
} catch (e) {
return [s.id, { error: reason(e) }]
}
}),
)
return Object.fromEntries(entries)
}
getBaseline(): SettingsBaseline | null {
return readSettingsBaseline(this.#config.dataDir)
}
#extraLocal(): string[] {
return readSettingsOverrides(this.#config.dataDir).paths
}
/* The baseline as the console may see it: secrets masked. */
baselineView(): { settings: Record<string, unknown>; importedFrom?: string; updatedAt: string; updatedBy: string } | null {
const b = this.getBaseline()
return b === null ? null : { ...b, settings: maskSettings(b.settings) }
}
async compare(): Promise<unknown> {
const baseline = this.getBaseline()
const extra = this.#extraLocal()
const live = await this.#readAll()
const comparison: Comparison = compareSettings({ baseline: baseline?.settings ?? null, live, extraLocal: extra })
return {
baseline: this.baselineView(),
localFields: { builtIn: [...LOCAL_DEFAULTS], builtInPatterns: ['webService*', 'user*, sso*, ldap*, auth*'], extra },
blockingFields: [...BLOCKING_FIELDS],
servers: comparison.servers.map((s) => ({ ...s, writable: this.#config.servers.find((x) => x.id === s.id)?.adminToken !== undefined })),
fields: comparison.fields,
withBaseline: comparison.withBaseline,
}
}
/* Writes the baseline; the one it replaces goes to the history first. */
#setBaseline(settings: Record<string, unknown>, importedFrom: string | undefined, user: string): void {
const old = this.getBaseline()
if (old !== null && JSON.stringify(old.settings) !== JSON.stringify(settings)) {
try {
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), 'baseline', JSON.stringify(old.settings), 'before-save', this.#now())
} catch (e) {
throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`)
}
}
writeSettingsBaseline(this.#config.dataDir, {
settings,
...(importedFrom === undefined ? {} : { importedFrom }),
updatedAt: new Date(this.#now()).toISOString(),
updatedBy: user,
})
}
/* Takes a server's settings as the baseline; with `group=blocking` only its blocking fields replace the baseline's. */
async importBaseline(serverId: string, group: string | undefined, user: string): Promise<{ detail: string }> {
const server = this.#server(serverId)
let raw: unknown
try {
raw = await this.#read(server)
} catch (e) {
throw new UserError(`${serverId}: ${reason(e)}`)
}
const read = normalize(raw)
if (Object.keys(read).length === 0) throw new UserError(`${serverId} sent no settings.`)
let next = read
if (group === 'blocking') {
const old = this.getBaseline()?.settings ?? {}
const kept: Record<string, unknown> = { ...old }
for (const name of BLOCKING_FIELDS) if (Object.hasOwn(read, name)) kept[name] = read[name]
next = kept
} else if (group !== undefined && group !== '') throw new UserError('group can only be "blocking".')
this.#setBaseline(next, serverId, user)
return { detail: `from ${serverId}${group === 'blocking' ? ' (blocking fields)' : ''}, ${Object.keys(next).length} fields` }
}
/* The baseline from the editor's JSON. A secret shown as the placeholder keeps the stored one. */
saveBaseline(textBody: string, user: string): { detail: string } {
if (textBody.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.')
let parsed: unknown
try {
parsed = JSON.parse(textBody)
} catch (e) {
throw new UserError(`The baseline is not valid JSON: ${reason(e)}`)
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new UserError('The baseline must be a JSON object of settings.')
const named = Object.keys(parsed as object)
const bad = named.find((k) => !isFieldName(k))
if (bad !== undefined) throw new UserError(`"${bad.slice(0, 40)}" is not a setting name.`)
let settings: Record<string, unknown>
try {
settings = restoreSecrets(normalize(parsed), this.getBaseline()?.settings ?? null)
} catch (e) {
throw new UserError(reason(e))
}
this.#setBaseline(settings, this.getBaseline()?.importedFrom, user)
return { detail: `${Object.keys(settings).length} fields` }
}
saveOverrides(pathsRaw: string[]): { detail: string } {
const seen = new Set<string>()
if (pathsRaw.length > 50) throw new UserError('At most 50 extra local fields.')
for (const p of pathsRaw) {
if (!isFieldName(p)) throw new UserError(`"${p.slice(0, 40)}" is not a setting name.`)
if (seen.has(p)) throw new UserError(`"${p}" is listed twice.`)
seen.add(p)
}
writeSettingsOverrides(this.#config.dataDir, { paths: pathsRaw })
return { detail: `${pathsRaw.length} extra local field(s)` }
}
/* ---- Preview and apply ---- */
#fingerprint(current: unknown, baseline: Record<string, unknown>, extra: string[]): string {
const { settings } = prepare(current)
const shared = Object.keys(settings)
.filter((k) => !isBuiltInLocal(k) && !extra.includes(k) && Object.hasOwn(baseline, k))
.sort()
.map((k) => [k, maskValue(k, settings[k])])
return hashOf(shared)
}
async preview(ids: string[], only?: readonly string[]): Promise<{ targets: SettingsTargetPreview[] }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline()
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#extraLocal()
const base = normalize(baseline.settings)
const out = await Promise.all(
targets.map(async (s): Promise<SettingsTargetPreview> => {
const empty = { kind: 'json' as const, changes: [] }
const writable = s.adminToken !== undefined
let current: unknown
try {
current = await this.#read(s)
} catch (e) {
return { server: s.id, ok: false, error: reason(e), writable, currentHash: '', changes: empty, secretFields: [], unchanged: false, warnings: [] }
}
const names = changedFields(baseline.settings, current, extra, only)
const live = prepare(current)
const warnings: string[] = []
if (!writable) warnings.push('The hub has no admin token for this server, so it cannot write to it.')
if (live.paused) warnings.push('Blocking is paused for a while on this server; its blocking switch is left alone.')
const changes: FieldChange[] = names.map((name) => {
const [a, b] = [maskValue(name, live.settings[name]), maskValue(name, base[name])]
return { path: name, type: a === undefined ? 'added' : b === undefined ? 'removed' : 'changed', a, b }
})
return {
server: s.id,
ok: true,
writable,
currentHash: this.#fingerprint(current, base, extra),
changes: { kind: 'json', changes },
secretFields: names.filter(hasSecret),
unchanged: names.length === 0,
warnings,
}
}),
)
return { targets: out }
}
async #applyOne(s: HubServer, baseline: SettingsBaseline, extra: string[], expect: string | undefined, only?: readonly string[]): Promise<{ result: TargetResult; detail?: string }> {
const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } })
if (s.adminToken === undefined) return fail('no admin token configured')
try {
let current: unknown
try {
current = await this.#read(s)
} catch (e) {
return fail(reason(e))
}
const base = normalize(baseline.settings)
if (expect !== undefined && expect !== this.#fingerprint(current, base, extra)) return fail('the settings changed on the server since the preview; run the preview again')
const names = changedFields(baseline.settings, current, extra, only)
if (names.length === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
const params: Record<string, string> = {}
for (const name of names) {
const encoded = toParams(name, base[name])
if ('error' in encoded) return fail(`${name}: ${encoded.error}; nothing was changed`)
Object.assign(params, encoded.params)
}
// The same checks as a per-server settings/set (guards.ts).
if (Object.keys(params).some((k) => isForbiddenSettingsField(k) || k.startsWith('webService'))) return fail('a setting the hub does not change was in the list; nothing was changed')
const web = webServiceChanges(params, current)
if (web.length > 0) return fail(`${WEB_SERVICE_MESSAGE} nothing was changed`)
try {
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(normalize(current)), 'before-apply', this.#now())
} catch (e) {
return fail(`the old settings could not be saved first (${reason(e)}); nothing was changed`)
}
await this.#upstream.write(s, 'settings/set', params)
const detail = names.join(', ')
let after: unknown
try {
after = prepare(await this.#read(s)).settings
} catch (e) {
return { result: { server: s.id, ok: false, message: `the server accepted the change but could not be read back (${reason(e)})` }, detail }
}
const wrong = names.filter((n) => !sameValue(n, base[n], (after as Record<string, unknown>)[n]))
if (wrong.length > 0) return { result: { server: s.id, ok: false, message: `the server accepted the change but reads back different values for: ${wrong.join(', ')}` }, detail }
return { result: { server: s.id, ok: true, message: `Applied and verified (${names.length} field${names.length === 1 ? '' : 's'}).` }, detail }
} catch (e) {
return fail(reason(e))
}
}
async apply(ids: string[], expect: Record<string, string>, only?: readonly string[]): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const baseline = this.getBaseline()
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#extraLocal()
const done = await Promise.all(targets.map((s) => this.#applyOne(s, baseline, extra, expect[s.id], only)))
const details: Record<string, string> = {}
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
return { results: done.map((d) => d.result), details }
}
/* ---- History: list and view (no rollback; apply the baseline again, or accept an old one by hand) ---- */
historyList(): HistoryEntry[] {
return listHistoryIn(settingsHistoryDir(this.#config.dataDir))
}
historyGet(id: string): { entry: HistoryEntry; settings?: Record<string, unknown>; lists?: { blocked: number; allowed: number } } {
const entry = this.historyList().find((e) => e.id === id)
const text = entry === undefined ? null : readHistoryIn(settingsHistoryDir(this.#config.dataDir), id)
if (entry === undefined || text === null) throw new UserError('There is no such history entry.')
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new UserError('That history entry cannot be read.')
}
if (entry.kind === 'before-lists') {
const p = parsed as { blocked?: unknown[]; allowed?: unknown[] }
return { entry, lists: { blocked: p.blocked?.length ?? 0, allowed: p.allowed?.length ?? 0 } }
}
return { entry, settings: maskSettings(normalize(parsed)) }
}
/* ---- Blocked and Allowed lists ---- */
async #readList(server: HubServer, list: 'blocked' | 'allowed'): Promise<string[]> {
const file = await this.#upstream.raw(server, `${list}/export`, {})
return parseDomains(await readCapped(file, LIST_BYTES, LIST_TIMEOUT_MS, `The ${list} list`))
}
async #readLists(server: HubServer): Promise<{ blocked: string[]; allowed: string[] }> {
const [blocked, allowed] = await Promise.all([this.#readList(server, 'blocked'), this.#readList(server, 'allowed')])
return { blocked, allowed }
}
getListsBaseline(): ListsBaseline | null {
return readListsBaseline(this.#config.dataDir)
}
async listsCompare(): Promise<unknown> {
const baseline = this.getListsBaseline()
const reads = await Promise.all(
this.#config.servers.map(async (s) => {
try {
return { s, lists: await this.#readLists(s), error: undefined }
} catch (e) {
return { s, lists: null, error: reason(e) }
}
}),
)
const reference = baseline ?? reads.find((r) => r.lists !== null)?.lists ?? null
const refId = baseline === null ? reads.find((r) => r.lists !== null)?.s.id : undefined
const view = (live: string[], ref: string[], own: boolean) => {
const d = own ? { missing: [], extra: [] } : diffSets(ref, live)
return { count: live.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) }
}
const servers: ListsServerView[] = reads.map((r) => {
const writable = r.s.adminToken !== undefined
if (r.lists === null || reference === null) {
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
return { id: r.s.id, ok: false, error: r.error, writable, blocked: none, allowed: none, status: 'unknown' }
}
const own = r.s.id === refId
const blocked = view(r.lists.blocked, reference.blocked, own)
const allowed = view(r.lists.allowed, reference.allowed, own)
const differs = blocked.missing + blocked.extra + allowed.missing + allowed.extra > 0
return { id: r.s.id, ok: true, writable, blocked, allowed, status: differs ? 'differs' : 'same' }
})
// Without a baseline the first server is the reference; it differs when any other does.
if (baseline === null) {
const first = servers.find((s) => s.id === refId)
if (first !== undefined && servers.some((s) => s.id !== first.id && s.status === 'differs')) first.status = 'differs'
}
return {
baseline: baseline === null ? null : { blocked: baseline.blocked.length, allowed: baseline.allowed.length, importedFrom: baseline.importedFrom, updatedAt: baseline.updatedAt, updatedBy: baseline.updatedBy },
withBaseline: baseline !== null,
servers,
}
}
async listsImport(serverId: string, user: string): Promise<{ detail: string }> {
const server = this.#server(serverId)
let lists: { blocked: string[]; allowed: string[] }
try {
lists = await this.#readLists(server)
} catch (e) {
throw new UserError(`${serverId}: ${reason(e)}`)
}
if (lists.blocked.length > MAX_LIST || lists.allowed.length > MAX_LIST) {
throw new UserError(`A list is longer than ${MAX_LIST} domains. The hub compares custom lists, not whole block lists.`)
}
writeListsBaseline(this.#config.dataDir, { ...lists, importedFrom: serverId, updatedAt: new Date(this.#now()).toISOString(), updatedBy: user })
return { detail: `from ${serverId}: ${lists.blocked.length} blocked, ${lists.allowed.length} allowed` }
}
async listsPreview(ids: string[], removeExtras: boolean): Promise<{ targets: (ListsServerView & { tooMany?: string })[] }> {
const targets = this.#targets(ids)
const baseline = this.getListsBaseline()
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
const out = await Promise.all(
targets.map(async (s) => {
const writable = s.adminToken !== undefined
const none = { count: 0, missing: 0, extra: 0, missingSample: [], extraSample: [] }
try {
const live = await this.#readLists(s)
const mk = (l: string[], b: string[]) => {
const d = diffSets(b, l)
return { count: l.length, missing: d.missing.length, extra: d.extra.length, missingSample: d.missing.slice(0, SAMPLE), extraSample: d.extra.slice(0, SAMPLE) }
}
const blocked = mk(live.blocked, baseline.blocked)
const allowed = mk(live.allowed, baseline.allowed)
const adds = blocked.missing + allowed.missing
const deletes = removeExtras ? blocked.extra + allowed.extra : 0
const tooMany = adds > MAX_ADDS ? `${adds} domains to add; at most ${MAX_ADDS} at a time.` : deletes > MAX_DELETES ? `${deletes} domains to remove; at most ${MAX_DELETES} at a time.` : undefined
return { id: s.id, ok: true, writable, blocked, allowed, status: adds + (removeExtras ? blocked.extra + allowed.extra : 0) > 0 ? ('differs' as const) : ('same' as const), ...(tooMany === undefined ? {} : { tooMany }) }
} catch (e) {
return { id: s.id, ok: false, error: reason(e), writable, blocked: none, allowed: none, status: 'unknown' as const }
}
}),
)
return { targets: out }
}
/* Adds the missing domains and, only when asked, deletes the extras; one call per domain, a few at a time. */
async #applyLists(s: HubServer, baseline: ListsBaseline, removeExtras: boolean): Promise<{ result: TargetResult; detail?: string }> {
const fail = (message: string): { result: TargetResult } => ({ result: { server: s.id, ok: false, message } })
if (s.adminToken === undefined) return fail('no admin token configured')
try {
const live = await this.#readLists(s)
const plan = (['blocked', 'allowed'] as const).map((list) => {
const d = diffSets(baseline[list], live[list])
return { list, add: d.missing, remove: removeExtras ? d.extra : [] }
})
const adds = plan.reduce((n, p) => n + p.add.length, 0)
const removals = plan.reduce((n, p) => n + p.remove.length, 0)
if (adds + removals === 0) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } }
if (adds > MAX_ADDS) return fail(`${adds} domains to add; at most ${MAX_ADDS} at a time, so nothing was changed`)
if (removals > MAX_DELETES) return fail(`${removals} domains to remove; at most ${MAX_DELETES} at a time, so nothing was changed`)
const calls: { path: string; domain: string }[] = []
let invalid = 0
for (const p of plan) {
for (const d of p.add) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/add`, domain: d }))
for (const d of p.remove) (cleanDomain(d) === null ? invalid++ : calls.push({ path: `${p.list}/delete`, domain: d }))
}
if (removals > 0) {
try {
writeHistoryIn(settingsHistoryDir(this.#config.dataDir), s.id, JSON.stringify(live), 'before-lists', this.#now())
} catch (e) {
return fail(`the old lists could not be saved first (${reason(e)}); nothing was changed`)
}
}
let failed = 0
let firstError = ''
for (let i = 0; i < calls.length; i += 10) {
await Promise.all(
calls.slice(i, i + 10).map(async (c) => {
try {
await this.#upstream.write(s, c.path, { domain: c.domain })
} catch (e) {
failed++
if (firstError === '') firstError = reason(e)
}
}),
)
}
const detail = `${adds} to add, ${removals} to remove`
if (failed > 0 || invalid > 0) {
return { result: { server: s.id, ok: false, message: `${failed} change(s) failed${firstError === '' ? '' : ` (${firstError})`}${invalid > 0 ? `, ${invalid} domain(s) skipped as invalid` : ''}` }, detail }
}
const after = await this.#readLists(s)
const left = (['blocked', 'allowed'] as const).reduce((n, l) => n + diffSets(baseline[l], after[l]).missing.length + (removeExtras ? diffSets(baseline[l], after[l]).extra.length : 0), 0)
if (left > 0) return { result: { server: s.id, ok: false, message: `the changes were accepted but ${left} difference(s) remain when read back` }, detail }
return { result: { server: s.id, ok: true, message: `Added ${adds}, removed ${removals}.` }, detail }
} catch (e) {
return fail(reason(e))
}
}
async listsApply(ids: string[], removeExtras: boolean): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
const targets = this.#targets(ids)
const baseline = this.getListsBaseline()
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
const done = await Promise.all(targets.map((s) => this.#applyLists(s, baseline, removeExtras)))
const details: Record<string, string> = {}
for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail
return { results: done.map((d) => d.result), details }
}
}