Files
technitium-console/hub/settings-sync.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 <[email protected]>
2026-11-01 01:20:05 +07:00

386 lines
17 KiB
TypeScript

/*
Pure functions behind the settings and blocking compare (stage 7): which fields are never
compared, which stay local to a server, how two settings are compared, how a field is encoded for
`settings/set`, and how secrets are kept out of every view. No I/O here.
Three kinds of field:
* IGNORED: runtime or read-only (`version`, `uptimestamp`, cluster info, the temporary blocking
switch, the next list update). Never compared, never sent.
* LOCAL: the identity and the bind addresses of the host, certificates and paths, and every
`webService*` field. Compared across servers for information, never sent by an apply. The
built-in list cannot be shortened; more fields can be added (`overrides.json`).
* everything else is SHARED: compared with the baseline and sent by an apply when it differs.
Secrets (TSIG shared secrets and the forward proxy password, plus the two certificate passwords,
which are local anyway) are compared inside the hub by their real value, but no view ever carries
a value or a hash of one: a view shows `SECRET_PLACEHOLDER`, and the row says only that the secret
differs.
*/
import { isForbiddenSettingsField } from './guards.ts'
export const SECRET_PLACEHOLDER = '<secret>'
/* Read-only or runtime: never compared. */
const IGNORED = new Set([
'version',
'uptimestamp',
'clusterInitialized',
'clusterNodes',
'temporaryDisableBlockingTill',
'blockListNextUpdatedOn',
])
export const isIgnoredField = (name: string): boolean => IGNORED.has(name) || /^cluster/i.test(name)
/*
Local by default, and why:
dnsServerDomain, dnsServerLocalEndPoints, dnsServerIPv4SourceAddresses,
dnsServerIPv6SourceAddresses the identity of the host and the addresses it binds
webService* how the hub itself reaches the server (guards.ts refuses them anyway)
dnsTlsCertificatePath, dnsTlsCertificatePassword the certificate file of this host
dnsOverHttpUnixSocket, dnsOverHttpsUnixSocket, enableDnsOverHttpUnixSocket,
enableDnsOverHttpsUnixSocket sockets of this host
dnsReverseProxyNetworkACL the address of the reverse proxy in front of this host
logFolder a path on this host
*/
export const LOCAL_DEFAULTS = [
'dnsServerDomain',
'dnsServerLocalEndPoints',
'dnsServerIPv4SourceAddresses',
'dnsServerIPv6SourceAddresses',
'dnsTlsCertificatePath',
'dnsTlsCertificatePassword',
'dnsOverHttpUnixSocket',
'dnsOverHttpsUnixSocket',
'enableDnsOverHttpUnixSocket',
'enableDnsOverHttpsUnixSocket',
'dnsReverseProxyNetworkACL',
'logFolder',
] as const
const LOCAL_SET = new Set<string>(LOCAL_DEFAULTS)
/* True for a field that is local whatever the overrides say: the built-in list, `webService*`, and anything user, SSO or LDAP. */
export const isBuiltInLocal = (name: string): boolean => LOCAL_SET.has(name) || name.startsWith('webService') || isForbiddenSettingsField(name)
/* The fields of the Blocking group (the rest are General). */
export const BLOCKING_FIELDS = [
'enableBlocking',
'allowTxtBlockingReport',
'blockingBypassList',
'blockingType',
'customBlockingAddresses',
'blockingAnswerTtl',
'blockListUrls',
'blockListUpdateIntervalHours',
] as const
const BLOCKING_SET = new Set<string>(BLOCKING_FIELDS)
export const groupOf = (name: string): 'blocking' | 'general' => (BLOCKING_SET.has(name) ? 'blocking' : 'general')
const PASSWORD_FIELDS = new Set(['webServiceTlsCertificatePassword', 'dnsTlsCertificatePassword'])
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9]{0,59}$/
export const isFieldName = (name: string): boolean => FIELD_NAME.test(name)
type Settings = Record<string, unknown>
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
/* `settings/get` without the ignored fields. */
export function normalize(raw: unknown): Settings {
const out: Settings = {}
if (!isObject(raw)) return out
for (const [k, v] of Object.entries(raw)) if (isFieldName(k) && !isIgnoredField(k)) out[k] = v
return out
}
/* Whether a field carries a secret anywhere inside it. */
export const hasSecret = (name: string): boolean => name === 'tsigKeys' || name === 'proxy' || PASSWORD_FIELDS.has(name)
/* A copy of a field's value that is safe to send out: every secret replaced by the placeholder. */
export function maskValue(name: string, value: unknown): unknown {
if (PASSWORD_FIELDS.has(name)) return typeof value === 'string' && value !== '' ? SECRET_PLACEHOLDER : value
if (name === 'tsigKeys' && Array.isArray(value)) {
return value.map((k) => (isObject(k) ? { ...k, sharedSecret: typeof k.sharedSecret === 'string' && k.sharedSecret !== '' ? SECRET_PLACEHOLDER : k.sharedSecret } : k))
}
if (name === 'proxy' && isObject(value)) return { ...value, password: typeof value.password === 'string' && value.password !== '' ? SECRET_PLACEHOLDER : value.password }
return value
}
export function maskSettings(settings: Settings): Settings {
return Object.fromEntries(Object.entries(settings).map(([k, v]) => [k, maskValue(k, v)]))
}
/* ---- Comparing ---- */
/* A value reduced to what it means: no key order, no difference between absent, null, empty text and an empty list. */
function canon(name: string, value: unknown): unknown {
if (value === undefined || value === null || value === '') return null
if (typeof value === 'string') return value.trim()
if (Array.isArray(value)) return value.length === 0 ? null : value.map((v) => canon('', v))
if (isObject(value)) {
// A proxy of type None carries no other setting.
if (name === 'proxy' && String(value.type ?? '').toLowerCase() === 'none') return null
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canon('', value[k])]))
}
return value
}
export const sameValue = (name: string, a: unknown, b: unknown): boolean => JSON.stringify(canon(name, a)) === JSON.stringify(canon(name, b))
/*
Settings ready for comparing: normalised, and without `enableBlocking` while blocking is paused
for a while (it is off for now, not by configuration).
*/
export function prepare(raw: unknown): { settings: Settings; paused: boolean } {
const settings = normalize(raw)
const paused = isObject(raw) && typeof raw.temporaryDisableBlockingTill === 'string' && raw.temporaryDisableBlockingTill !== ''
if (paused) delete settings.enableBlocking
return { settings, paused }
}
export interface Cell {
value: unknown
differs: boolean
/* The values look alike once masked: only a secret differs. */
secretChanged?: boolean
}
export interface FieldRow {
name: string
group: 'blocking' | 'general'
local: boolean
secret: boolean
/* The baseline's value (masked), when comparing with a baseline. */
expected?: unknown
cells: Record<string, Cell>
}
export interface ServerSummary {
id: string
ok: boolean
error?: string
/* same, differs, local-only (only local fields differ) or unknown (the server did not answer). */
status: 'same' | 'differs' | 'local-only' | 'unknown'
differing: number
localDiffering: number
paused: boolean
}
export interface Comparison {
/* True when compared with a baseline, false when the servers are compared with the first that answered. */
withBaseline: boolean
servers: ServerSummary[]
fields: FieldRow[]
}
export interface CompareInput {
baseline: Settings | null
/** Raw `settings/get` per server; null when it did not answer. */
live: Record<string, { settings: unknown } | { error: string }>
extraLocal: string[]
}
export function compareSettings({ baseline, live, extraLocal }: CompareInput): Comparison {
const isLocal = (name: string): boolean => isBuiltInLocal(name) || extraLocal.includes(name)
const base = baseline === null ? null : normalize(baseline)
const prepared: Record<string, { settings: Settings; paused: boolean }> = {}
const summaries: ServerSummary[] = []
for (const [id, answer] of Object.entries(live)) {
if ('error' in answer) summaries.push({ id, ok: false, error: answer.error, status: 'unknown', differing: 0, localDiffering: 0, paused: false })
else {
prepared[id] = prepare(answer.settings)
summaries.push({ id, ok: true, status: 'same', differing: 0, localDiffering: 0, paused: prepared[id].paused })
}
}
const ids = Object.keys(prepared)
const reference = base ?? (ids.length > 0 ? prepared[ids[0]].settings : null)
const names = new Set<string>()
if (base !== null) for (const k of Object.keys(base)) names.add(k)
for (const id of ids) for (const k of Object.keys(prepared[id].settings)) names.add(k)
const rows: FieldRow[] = []
for (const name of [...names].sort()) {
const local = isLocal(name)
const holders = ids.filter((id) => Object.hasOwn(prepared[id].settings, name))
const cells: Record<string, Cell> = {}
let any = false
if (local) {
// Compared with the baseline's value when it has one, else with the first server that has the field.
const useBase = base !== null && Object.hasOwn(base, name)
const ref = useBase ? base[name] : holders.length > 0 ? prepared[holders[0]].settings[name] : undefined
for (const id of holders) cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs: !sameValue(name, ref, prepared[id].settings[name]) }
any = holders.some((id) => cells[id].differs)
if (any && !useBase) for (const id of holders) cells[id].differs = true
} else {
if (reference === null || !Object.hasOwn(reference, name)) continue
for (const id of holders) {
const differs = !sameValue(name, reference[name], prepared[id].settings[name])
const secretChanged = differs && hasSecret(name) && JSON.stringify(maskValue(name, canon(name, reference[name]))) === JSON.stringify(maskValue(name, canon(name, prepared[id].settings[name])))
cells[id] = { value: maskValue(name, prepared[id].settings[name]), differs, ...(secretChanged ? { secretChanged: true } : {}) }
if (differs) any = true
}
// Without a baseline the reference itself is one side of the difference.
if (any && base === null) cells[ids[0]].differs = true
}
if (!any) continue
rows.push({
name,
group: groupOf(name),
local,
secret: hasSecret(name),
...(base !== null && !local ? { expected: maskValue(name, base[name]) } : {}),
cells,
})
}
for (const s of summaries) {
if (!s.ok) continue
s.differing = rows.filter((r) => !r.local && r.cells[s.id]?.differs === true && (base !== null || s.id !== ids[0])).length
s.localDiffering = rows.filter((r) => r.local && r.cells[s.id]?.differs === true).length
s.status = s.differing > 0 ? 'differs' : s.localDiffering > 0 ? 'local-only' : 'same'
}
// Without a baseline the first server is the reference: it differs only if another does.
if (base === null && ids.length > 0) {
const first = summaries.find((s) => s.id === ids[0])!
const others = summaries.some((s) => s.ok && s.id !== first.id && s.differing > 0)
first.differing = rows.filter((r) => !r.local).length
first.status = others ? 'differs' : first.localDiffering > 0 ? 'local-only' : 'same'
}
return { withBaseline: base !== null, servers: summaries, fields: rows }
}
/*
The shared fields an apply would send to a server: in the baseline and reported by the server,
different, not local, not forbidden. Sorted. A field only one side has is left alone.
*/
export function changedFields(baseline: unknown, current: unknown, extraLocal: string[], only?: readonly string[]): string[] {
const base = normalize(baseline)
const { settings } = prepare(current)
const out: string[] = []
for (const name of Object.keys(base)) {
if (isBuiltInLocal(name) || extraLocal.includes(name)) continue
if (only !== undefined && !only.includes(name)) continue
if (!Object.hasOwn(settings, name)) continue
if (!sameValue(name, base[name], settings[name])) out.push(name)
}
return out.sort()
}
/* ---- Encoding for `settings/set` (the same as the console's own save, model.ts `buildBody`) ---- */
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v))
function table(rows: unknown, keys: string[]): string | null {
if (!Array.isArray(rows)) return rows === null || rows === undefined ? 'false' : null
const cells: string[] = []
for (const row of rows) {
if (!isObject(row)) return null
for (const k of keys) {
const cell = text(row[k])
if (cell.includes('|')) return null
cells.push(cell)
}
}
return cells.length === 0 ? 'false' : cells.join('|')
}
/* The form parameters for one field, or an error that says why it cannot be sent. */
export function toParams(name: string, value: unknown): { params: Record<string, string> } | { error: string } {
if (!isFieldName(name)) return { error: `"${name}" is not a setting name.` }
if (name === 'proxy') {
if (!isObject(value) || String(value.type ?? '').toLowerCase() === 'none' || String(value.type ?? '') === '') return { params: { proxyType: 'none' } }
const bypass = Array.isArray(value.bypass) ? value.bypass.map(String) : []
if (bypass.some((b) => b.includes(','))) return { error: 'A proxy bypass entry contains a comma.' }
return {
params: {
proxyType: String(value.type).toLowerCase(),
proxyAddress: text(value.address),
proxyPort: text(value.port),
proxyUsername: text(value.username),
proxyPassword: text(value.password),
proxyBypass: bypass.join(','),
},
}
}
if (name === 'tsigKeys') {
const t = table(value, ['keyName', 'sharedSecret', 'algorithmName'])
return t === null ? { error: 'A TSIG key cannot be sent (a "|" in it, or a malformed list).' } : { params: { tsigKeys: t } }
}
if (name === 'qpmPrefixLimitsIPv4' || name === 'qpmPrefixLimitsIPv6') {
const t = table(value, ['prefix', 'udpLimit', 'tcpLimit'])
return t === null ? { error: `${name} cannot be sent (a malformed list).` } : { params: { [name]: t } }
}
if (Array.isArray(value)) {
const items = value.map((v) => text(v).trim()).filter((v) => v !== '')
if (items.some((i) => i.includes(',') || i.includes('\n'))) return { error: `An entry of ${name} contains a comma or a line break, which the server takes as a separator.` }
return { params: { [name]: items.length === 0 ? 'false' : items.join(',') } }
}
if (isObject(value)) return { error: `${name} has a shape the hub does not send.` }
if (typeof value === 'boolean' || typeof value === 'number') return { params: { [name]: String(value) } }
return { params: { [name]: text(value) } }
}
/* ---- Baseline editing ---- */
/*
A baseline coming back from the editor shows secrets as the placeholder. Each placeholder stands for
the secret the stored baseline already has (a TSIG key is found by its name); anything else is a new
secret and replaces it. A placeholder with nothing to stand for is refused, so a save can never
write the placeholder over a real key.
*/
export function restoreSecrets(next: Settings, old: Settings | null): Settings {
const out: Settings = structuredClone(next)
const fail = (what: string): never => {
throw new Error(`The secret of ${what} is shown as ${SECRET_PLACEHOLDER} but the stored baseline has none to keep. Type the value or remove the entry.`)
}
for (const name of PASSWORD_FIELDS) {
if (out[name] === SECRET_PLACEHOLDER) {
const was = old?.[name]
out[name] = typeof was === 'string' ? was : fail(name)
}
}
if (Array.isArray(out.tsigKeys)) {
const oldKeys = Array.isArray(old?.tsigKeys) ? (old.tsigKeys as unknown[]).filter(isObject) : []
out.tsigKeys = out.tsigKeys.map((k: unknown) => {
if (!isObject(k) || k.sharedSecret !== SECRET_PLACEHOLDER) return k
const was = oldKeys.find((o) => o.keyName === k.keyName)
return { ...k, sharedSecret: typeof was?.sharedSecret === 'string' ? was.sharedSecret : fail(`the TSIG key ${text(k.keyName)}`) }
})
}
if (isObject(out.proxy) && out.proxy.password === SECRET_PLACEHOLDER) {
const was = isObject(old?.proxy) ? old.proxy.password : undefined
out.proxy = { ...out.proxy, password: typeof was === 'string' ? was : fail('the proxy') }
}
return out
}
/* ---- Blocked and Allowed lists ---- */
export const MAX_LIST = 5000
export const MAX_DELETES = 500
export const IMPORT_CHUNK = 500
/* The domains of an export: lower case, no trailing dot, no blank or `#` line, once each, sorted. */
export function parseDomains(textBody: string): string[] {
const set = new Set<string>()
for (const line of textBody.split(/\r?\n/)) {
const t = line.trim().toLowerCase().replace(/\.$/, '')
if (t !== '' && !t.startsWith('#')) set.add(t)
}
return [...set].sort()
}
export interface SetDiff {
/* In the baseline, not on the server. */
missing: string[]
/* On the server, not in the baseline. */
extra: string[]
}
export function diffSets(baseline: readonly string[], live: readonly string[]): SetDiff {
const [b, l] = [new Set(baseline), new Set(live)]
return { missing: baseline.filter((d) => !l.has(d)), extra: live.filter((d) => !b.has(d)) }
}