/* Config sync for one app across the servers: compare, baseline, local fields, preview, apply. The baseline (kept in the hub) plus a server's local values is that server's effective config. Apply brings the selected servers to it: it re-reads the server, refuses if the config changed since the preview, saves the old config to the history, writes with the server's ADMIN token and reads the config back to check it. Config texts are returned to the caller (it is what they edit) but never written to the audit log. */ import type { HubConfig, HubServer } from './config.ts' import { MAX_CONFIG, acceptServerConfig, cleanValues, diffConfigs, effectiveConfig, importLocalValues, looksLikeJson, parseJson, sameConfig, textHash, validatePaths, type Diff, } from './apps-sync.ts' import { Upstream, UpstreamError } from './upstream.ts' import { listBaselineApps, listHistory, readBaseline, readHistory, readOverrides, writeBaseline, writeHistory, writeOverrides, type Baseline, type HistoryEntry, type Overrides, } from './store.ts' export class UserError extends Error { constructor(message: string) { super(message) this.name = 'UserError' } } const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') export interface Live { installed: boolean version?: string ok: boolean error?: string /** The server's config text; null when the app has none. */ text: string | null } export interface ServerView { id: string ok: boolean error?: string installed: boolean writable: boolean version?: string text: string | null hash: string effective?: string warnings: string[] /** Null without a baseline. */ inSync: boolean | null /** What applying would change: this server's config to its effective config. */ vsBaseline?: Diff /** This server's config relative to each other server's, local fields left out. */ vsOthers: Record } export interface TargetPreview { server: string ok: boolean error?: string writable: boolean current: string | null currentHash: string effective: string changes: Diff unchanged: boolean warnings: string[] } export interface TargetResult { server: string ok: boolean message?: string } /* 9.10 is newer than 9.9: compared part by part, numerically. */ export function compareVersions(a: string, b: string): number { const [x, y] = [a, b].map((v) => v.split('.').map((p) => Number.parseInt(p, 10) || 0)) for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] ?? 0) - (y[i] ?? 0) if (d !== 0) return d } return 0 } /* The read token first, the admin token when the server refuses it. */ export async function readConfigText(upstream: Upstream, server: HubServer, name: string): Promise { let response: unknown try { response = await upstream.call(server, 'apps/config/get', { name }) } catch (e) { if (!(e instanceof UpstreamError) || e.kind !== 'rejected' || server.adminToken === undefined) throw e response = await upstream.call(server, 'apps/config/get', { name }, server.adminToken) } const config = (response as { config?: unknown } | null)?.config return typeof config === 'string' ? config : null } export class AppsConfig { #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)) } async #readConfig(server: HubServer, name: string, fresh: boolean): Promise { if (fresh) this.#upstream.invalidate(server.id) return readConfigText(this.#upstream, server, name) } async #live(server: HubServer, name: string, fresh: boolean): Promise { let version: string | undefined try { if (fresh) this.#upstream.invalidate(server.id) const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null const app = (list?.apps ?? []).find((a) => a.name === name) if (app === undefined) return { installed: false, ok: true, text: null } version = app.version } catch (e) { return { installed: false, ok: false, error: reason(e), text: null } } try { return { installed: true, ok: true, version, text: await this.#readConfig(server, name, false) } } catch (e) { return { installed: true, ok: false, version, error: reason(e), text: null } } } async #liveAll(name: string, fresh: boolean): Promise> { const entries = await Promise.all(this.#config.servers.map(async (s): Promise<[string, Live]> => [s.id, await this.#live(s, name, fresh)])) return new Map(entries) } getBaseline(name: string): Baseline | null { return readBaseline(this.#config.dataDir, name) } getOverrides(name: string): Overrides { return readOverrides(this.#config.dataDir, name) } async compare(name: string): Promise { const baseline = this.getBaseline(name) const overrides = this.getOverrides(name) const live = await this.#liveAll(name, false) const views: ServerView[] = this.#config.servers.map((s) => { const l = live.get(s.id) as Live const text = l.text const view: ServerView = { id: s.id, ok: l.ok, installed: l.installed, writable: s.adminToken !== undefined, text, hash: textHash(text ?? ''), warnings: [], inSync: null, vsOthers: {}, } if (l.error !== undefined) view.error = l.error if (l.version !== undefined) view.version = l.version if (baseline !== null && l.installed && l.ok) { const effective = effectiveConfig(baseline.text, overrides, s.id) view.effective = effective.text view.warnings = effective.warnings view.inSync = sameConfig(text ?? '', effective.text) view.vsBaseline = diffConfigs(text ?? '', effective.text, []) } return view }) for (const v of views) { if (!v.installed || !v.ok) continue for (const o of views) { if (o.id !== v.id && o.installed && o.ok) v.vsOthers[o.id] = diffConfigs(o.text ?? '', v.text ?? '', overrides.paths) } } return { name, baseline, overrides, servers: views } } async importBaseline(name: string, serverId: string, user: string): Promise<{ detail: string }> { const server = this.#server(serverId) let text: string | null try { text = await this.#readConfig(server, name, true) } catch (e) { throw new UserError(`${serverId}: ${reason(e)}`) } if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}" to import.`) if (text.length > MAX_CONFIG) throw new UserError('The config is larger than 1 MB.') this.#setBaseline(name, text, serverId, user) return { detail: `from ${serverId}, ${text.length} bytes, ${textHash(text)}` } } /* Writes the baseline; the text it replaces goes to the history first (when it differs). */ #setBaseline(name: string, text: string, importedFrom: string | undefined, user: string): void { const old = this.getBaseline(name) if (old !== null && old.text !== text) { try { writeHistory(this.#config.dataDir, name, 'baseline', old.text, 'before-save', this.#now()) } catch (e) { throw new UserError(`The old baseline could not be saved first (${reason(e)}); nothing was changed.`) } } writeBaseline(this.#config.dataDir, { app: name, text, ...(importedFrom === undefined ? {} : { importedFrom }), updatedAt: new Date(this.#now()).toISOString(), updatedBy: user, }) } saveBaseline(name: string, text: string, user: string): { detail: string } { if (text.length > MAX_CONFIG) throw new UserError('The baseline is larger than 1 MB.') if (looksLikeJson(text)) { const parsed = parseJson(text) if (!parsed.ok) throw new UserError(`The baseline is not valid JSON: ${parsed.error}`) } this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user) return { detail: `${text.length} bytes, ${textHash(text)}` } } saveOverrides(name: string, pathsRaw: unknown, valuesRaw: unknown): { detail: string } { let paths: string[] let values: Overrides['values'] try { paths = validatePaths(pathsRaw) values = cleanValues(valuesRaw ?? {}, paths, this.#config.servers.map((s) => s.id)) } catch (e) { throw new UserError(reason(e)) } const baseline = this.getBaseline(name) if (paths.length > 0 && baseline !== null && (!looksLikeJson(baseline.text) || !parseJson(baseline.text).ok)) { throw new UserError('The baseline is plain text, so it cannot have local fields.') } if (JSON.stringify(values).length > MAX_CONFIG) throw new UserError('The local values are larger than 1 MB.') writeOverrides(this.#config.dataDir, name, { paths, values }) return { detail: `${paths.length} local field(s)` } } /* Reads each local path from the servers' live configs, for the "fill from current" button. */ async currentLocalValues(name: string, pathsRaw: unknown): Promise { let paths: string[] try { paths = validatePaths(pathsRaw) } catch (e) { throw new UserError(reason(e)) } const live = await this.#liveAll(name, true) return importLocalValues(Object.fromEntries([...live].map(([id, l]) => [id, l.installed && l.ok ? l.text : null])), paths) } async preview(name: string, ids: string[], overrideText?: string): Promise<{ targets: TargetPreview[] }> { const targets = this.#targets(ids) const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline) if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.') const overrides = this.getOverrides(name) const live = await this.#liveAll(name, true) const versions = [...live].filter(([, l]) => l.installed && l.version !== undefined).map(([id, l]) => ({ id, version: l.version as string })) const newest = versions.reduce<{ id: string; version: string } | null>((best, v) => (best === null || compareVersions(v.version, best.version) > 0 ? v : best), null) return { targets: targets.map((s): TargetPreview => { const l = live.get(s.id) as Live const effective = effectiveConfig(baseline.text, overrides, s.id) const warnings = [...effective.warnings] if (s.adminToken === undefined) warnings.push('The hub has no admin token for this server, so it cannot write to it.') if (l.installed && l.version !== undefined && newest !== null && compareVersions(l.version, newest.version) < 0) { warnings.push(`Version ${l.version} here, ${newest.version} on ${newest.id}: versions differ, align versions first.`) } const current = l.text const base = { server: s.id, writable: s.adminToken !== undefined, current, currentHash: textHash(current ?? ''), effective: effective.text, warnings, } if (!l.ok) return { ...base, ok: false, error: l.error ?? 'did not answer', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false } if (!l.installed) return { ...base, ok: false, error: 'The app is not installed on this server.', changes: { kind: 'text', lines: [], truncated: false, changed: 0 }, unchanged: false } const changes = diffConfigs(current ?? '', effective.text, []) return { ...base, ok: true, changes, unchanged: sameConfig(current ?? '', effective.text) } }), } } /* Brings one server to `desired`: re-read, refuse if it changed since `expectHash`, save the old config to the history, write with the admin token, read back and check. */ async #applyText(s: HubServer, name: string, desired: (current: string) => string, expectHash: string | undefined): Promise<{ result: TargetResult; detail?: string }> { if (s.adminToken === undefined) return { result: { server: s.id, ok: false, message: 'no admin token configured' } } try { const l = await this.#live(s, name, true) if (!l.ok) return { result: { server: s.id, ok: false, message: l.error ?? 'did not answer' } } if (!l.installed) return { result: { server: s.id, ok: false, message: 'the app is not installed on this server' } } const current = l.text ?? '' if (expectHash !== undefined && expectHash !== textHash(current)) { return { result: { server: s.id, ok: false, message: 'the config changed on the server since the preview; run the preview again' } } } const text = desired(current) if (sameConfig(current, text)) return { result: { server: s.id, ok: true, message: 'Already in sync; nothing was written.' } } if (text.length > MAX_CONFIG) return { result: { server: s.id, ok: false, message: 'the config is larger than 1 MB' } } try { writeHistory(this.#config.dataDir, name, s.id, current, 'before-apply', this.#now()) } catch (e) { return { result: { server: s.id, ok: false, message: `the old config could not be saved first (${reason(e)}); nothing was changed` } } } await this.#upstream.write(s, 'apps/config/set', {}, new URLSearchParams({ name, config: text })) const after = await this.#readConfig(s, name, true) const detail = `${textHash(current)} to ${textHash(text)}, ${text.length} bytes` if (after === null || !sameConfig(after, text)) { return { result: { server: s.id, ok: false, message: 'the server accepted the config but reads back a different one' }, detail } } return { result: { server: s.id, ok: true, message: 'Applied and verified.' }, detail } } catch (e) { return { result: { server: s.id, ok: false, message: reason(e) } } } } async #applyMany( name: string, ids: string[], desiredFor: (s: HubServer) => (current: string) => string, expect: Record, ): Promise<{ results: TargetResult[]; details: Record }> { const targets = this.#targets(ids) const done = await Promise.all(targets.map((s) => this.#applyText(s, name, desiredFor(s), expect[s.id]))) const details: Record = {} for (const d of done) if (d.detail !== undefined) details[d.result.server] = d.detail return { results: done.map((d) => d.result), details } } /* `overrideText` is a config that is not the hub's baseline (a profile's). The server's local values still apply. */ async apply(name: string, ids: string[], expect: Record, overrideText?: string): Promise<{ results: TargetResult[]; details: Record }> { const baseline = overrideText === undefined ? this.getBaseline(name) : ({ app: name, text: overrideText, updatedAt: '', updatedBy: '' } satisfies Baseline) if (baseline === null) throw new UserError('This app has no baseline yet. Import one from a server first.') const overrides = this.getOverrides(name) return this.#applyMany(name, ids, (s) => () => effectiveConfig(baseline.text, overrides, s.id).text, expect) } /* ---- History ---- */ historyList(name: string): HistoryEntry[] { return listHistory(this.#config.dataDir, name) } #entry(name: string, id: string): { entry: HistoryEntry; text: string } { const entry = this.historyList(name).find((e) => e.id === id) const text = entry === undefined ? null : readHistory(this.#config.dataDir, name, id) if (entry === undefined || text === null) throw new UserError('There is no such history entry.') return { entry, text } } historyGet(name: string, id: string): { entry: HistoryEntry; text: string } { return this.#entry(name, id) } /* A saved version against a server's config now: what rolling back would change. */ async historyDiff(name: string, id: string, serverId: string): Promise { const { entry, text } = this.#entry(name, id) const server = this.#server(serverId) const l = await this.#live(server, name, true) if (!l.ok || !l.installed) throw new UserError(l.ok ? `The app is not installed on ${serverId}.` : `${serverId}: ${l.error ?? 'did not answer'}`) const current = l.text ?? '' return { entry, server: serverId, writable: server.adminToken !== undefined, current, currentHash: textHash(current), version: text, changes: diffConfigs(current, text, []), unchanged: sameConfig(current, text), } } async rollback(name: string, id: string, ids: string[], expect: Record): Promise<{ results: TargetResult[]; details: Record }> { const { entry, text } = this.#entry(name, id) if (entry.kind === 'before-save') throw new UserError('That entry is an old baseline. Restore it as the baseline instead.') return this.#applyMany(name, ids, () => () => text, expect) } restoreBaseline(name: string, id: string, user: string): { detail: string } { const { entry, text } = this.#entry(name, id) if (entry.kind !== 'before-save') throw new UserError('Only an old baseline can be restored as the baseline.') this.#setBaseline(name, text, this.getBaseline(name)?.importedFrom, user) return { detail: `entry ${entry.hash}, ${text.length} bytes` } } /* "Accept as baseline" for a drifted server: its config becomes the baseline, except that local fields keep the baseline's values and the server's own values go into the overrides. */ async acceptDrift(name: string, serverId: string, user: string): Promise<{ detail: string }> { const server = this.#server(serverId) let text: string | null try { text = await this.#readConfig(server, name, true) } catch (e) { throw new UserError(`${serverId}: ${reason(e)}`) } if (text === null || text.trim() === '') throw new UserError(`${serverId} has no config for "${name}".`) const overrides = this.getOverrides(name) let next try { next = acceptServerConfig(text, this.getBaseline(name)?.text ?? null, overrides, serverId) } catch (e) { throw new UserError(reason(e)) } this.#setBaseline(name, next.baselineText, serverId, user) if (overrides.paths.length > 0) writeOverrides(this.#config.dataDir, name, next.overrides) return { detail: `from ${serverId}, ${next.baselineText.length} bytes, ${textHash(next.baselineText)}` } } /* ---- Export and import of all baselines ---- */ exportAll(): { hub: string; version: number; exportedAt: string; apps: { app: string; baseline: Baseline; overrides: Overrides }[] } { const apps = listBaselineApps(this.#config.dataDir).flatMap((app) => { const baseline = this.getBaseline(app) return baseline === null ? [] : [{ app, baseline, overrides: this.getOverrides(app) }] }) return { hub: 'technitium-hub', version: 1, exportedAt: new Date(this.#now()).toISOString(), apps } } /* Validates a file and says what it would change; with `apply` also writes baselines and overrides (never to a server). */ importAll(raw: string, apply: boolean, user: string): { apps: { app: string; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[]; applied: boolean } { const parsed = parseJson(raw) if (!parsed.ok) throw new UserError('The file is not valid JSON.') const file = parsed.value as { hub?: unknown; version?: unknown; apps?: unknown } if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub') throw new UserError('This is not a baselines file of the hub.') if (file.version !== 1) throw new UserError('This file has a version the hub does not know.') if (!Array.isArray(file.apps) || file.apps.length > 500) throw new UserError('The file has no list of apps, or has more than 500.') const known = this.#config.servers.map((s) => s.id) const plan: { app: string; text: string; overrides: Overrides; status: 'new' | 'changed' | 'same'; ignoredServers: string[] }[] = [] const seen = new Set() for (const item of file.apps as Record[]) { const app = item?.app const baseline = item?.baseline as { text?: unknown } | null | undefined if (typeof app !== 'string' || app === '' || app.length > 200 || seen.has(app)) throw new UserError('An app in the file has no valid, unique name.') seen.add(app) if (typeof baseline?.text !== 'string') throw new UserError(`"${app}" has no baseline text.`) if (baseline.text.length > MAX_CONFIG) throw new UserError(`The baseline of "${app}" is larger than 1 MB.`) if (looksLikeJson(baseline.text) && !parseJson(baseline.text).ok) throw new UserError(`The baseline of "${app}" is not valid JSON.`) const rawOverrides = (item.overrides ?? { paths: [], values: {} }) as { paths?: unknown; values?: unknown } let paths: string[] try { paths = validatePaths(rawOverrides.paths ?? []) } catch (e) { throw new UserError(`"${app}": ${reason(e)}`) } if (paths.length > 0 && !looksLikeJson(baseline.text)) throw new UserError(`"${app}": a plain text baseline cannot have local fields.`) const values = rawOverrides.values !== null && typeof rawOverrides.values === 'object' && !Array.isArray(rawOverrides.values) ? (rawOverrides.values as Record) : {} const ignoredServers = Object.keys(values).filter((id) => !known.includes(id)) let cleaned: Overrides['values'] try { cleaned = cleanValues(Object.fromEntries(Object.entries(values).filter(([id]) => known.includes(id))), paths, known) } catch (e) { throw new UserError(`"${app}": ${reason(e)}`) } const overrides: Overrides = { paths, values: cleaned } const old = this.getBaseline(app) const oldOverrides = this.getOverrides(app) const status = old === null ? 'new' : old.text === baseline.text && JSON.stringify(oldOverrides) === JSON.stringify(overrides) ? 'same' : 'changed' plan.push({ app, text: baseline.text, overrides, status, ignoredServers }) } if (apply) { for (const p of plan) { if (p.status === 'same') continue this.#setBaseline(p.app, p.text, this.getBaseline(p.app)?.importedFrom, user) writeOverrides(this.#config.dataDir, p.app, p.overrides) } } return { apps: plan.map(({ app, status, ignoredServers }) => ({ app, status, ignoredServers })), applied: apply } } }