/* 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 = '' /* 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(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(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 const isObject = (v: unknown): v is Record => 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 } 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 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 = {} 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() 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 = {} 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 } | { 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() 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)) } }