Files
DenozordecandClaude Opus 5.5 9742f0fe69
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s
Hub stage 8: settings drift, webhook notices, profiles
The drift check now also covers the settings baseline and the Blocked
and Allowed lists baseline; the drift dialog offers Open compare,
Accept as baseline and Revert to baseline for them. Old drift.json
files still load.

HUB_WEBHOOK_URL (and an optional HUB_WEBHOOK_SECRET for an HMAC
signature) receives drift.found, drift.cleared and write.failed
notices, only on change, without values, never blocking a request and
never logging the address beyond its host. Admins see the webhook
status and can send a test from About and from the drift dialog.

Profiles bundle the hub's app, settings and lists baselines. Applying
one to a server runs install, app configs, settings and lists in order
after a preview; a failed step stops that server's later steps and
Retry failed resumes from it. Profiles export and import as a file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:19 +07:00

514 lines
23 KiB
TypeScript

/*
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<string, Diff>
}
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<string | null> {
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<string | null> {
if (fresh) this.#upstream.invalidate(server.id)
return readConfigText(this.#upstream, server, name)
}
async #live(server: HubServer, name: string, fresh: boolean): Promise<Live> {
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<Map<string, Live>> {
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<unknown> {
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<Overrides['values']> {
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<string, string>,
): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
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<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 }
}
/* `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<string, string>, overrideText?: string): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
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<unknown> {
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<string, string>): Promise<{ results: TargetResult[]; details: Record<string, string> }> {
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<string>()
for (const item of file.apps as Record<string, unknown>[]) {
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<string, unknown>) : {}
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 }
}
}