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]>
246 lines
9.7 KiB
TypeScript
246 lines
9.7 KiB
TypeScript
/*
|
|
What the hub keeps per app under `${HUB_DATA}/apps/<app>/`:
|
|
|
|
baseline.json the desired config, as TEXT (some configs are not JSON)
|
|
overrides.json the fields that stay local to each server, and their values
|
|
history/ the config a server had before the hub changed it (50 files kept)
|
|
|
|
Every write is a temp file and a rename, mode 0600: these files hold configs.
|
|
*/
|
|
import { createHash } from 'node:crypto'
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
export const KEEP_HISTORY = 50
|
|
|
|
/* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */
|
|
export function safeName(name: string): string {
|
|
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80)
|
|
if (cleaned === name && cleaned !== '') return cleaned
|
|
return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
|
}
|
|
|
|
export const appDir = (dataDir: string, app: string): string => path.join(dataDir, 'apps', safeName(app))
|
|
|
|
export function writeAtomic(file: string, text: string): void {
|
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
const temp = `${file}.${process.pid}.tmp`
|
|
try {
|
|
writeFileSync(temp, text, { mode: 0o600 })
|
|
renameSync(temp, file)
|
|
} catch (e) {
|
|
rmSync(temp, { force: true })
|
|
throw e
|
|
}
|
|
}
|
|
|
|
export interface Baseline {
|
|
app: string
|
|
text: string
|
|
importedFrom?: string
|
|
updatedAt: string
|
|
updatedBy: string
|
|
}
|
|
|
|
export interface Overrides {
|
|
/** Dot paths (`a.b.0.c`) of the fields that are not synchronised. */
|
|
paths: string[]
|
|
/** server -> path -> value */
|
|
values: Record<string, Record<string, unknown>>
|
|
}
|
|
|
|
export const EMPTY_OVERRIDES: Overrides = { paths: [], values: {} }
|
|
|
|
function readJson(file: string): unknown {
|
|
if (!existsSync(file)) return null
|
|
try {
|
|
return JSON.parse(readFileSync(file, 'utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function readBaseline(dataDir: string, app: string): Baseline | null {
|
|
const data = readJson(path.join(appDir(dataDir, app), 'baseline.json')) as Partial<Baseline> | null
|
|
if (data === null || typeof data.text !== 'string') return null
|
|
return {
|
|
app,
|
|
text: data.text,
|
|
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
|
updatedAt: String(data.updatedAt ?? ''),
|
|
updatedBy: String(data.updatedBy ?? ''),
|
|
}
|
|
}
|
|
|
|
export function writeBaseline(dataDir: string, baseline: Baseline): void {
|
|
writeAtomic(path.join(appDir(dataDir, baseline.app), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|
|
}
|
|
|
|
export function readOverrides(dataDir: string, app: string): Overrides {
|
|
const data = readJson(path.join(appDir(dataDir, app), 'overrides.json')) as Partial<Overrides> | null
|
|
if (data === null || !Array.isArray(data.paths)) return { paths: [], values: {} }
|
|
const values = data.values !== null && typeof data.values === 'object' ? data.values : {}
|
|
return { paths: data.paths.filter((p): p is string => typeof p === 'string'), values }
|
|
}
|
|
|
|
export function writeOverrides(dataDir: string, app: string, overrides: Overrides): void {
|
|
writeAtomic(path.join(appDir(dataDir, app), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n')
|
|
}
|
|
|
|
export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' | 'before-lists'
|
|
|
|
export interface HistoryEntry {
|
|
/** The file name; the only handle `readHistory` accepts. */
|
|
id: string
|
|
at: string
|
|
/** A server id, or `baseline` for an earlier text of the baseline. */
|
|
server: string
|
|
kind: string
|
|
size: number
|
|
hash: string
|
|
}
|
|
|
|
const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+)__(before-[a-z-]+)\.txt$/
|
|
|
|
const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history')
|
|
|
|
export const listHistory = (dataDir: string, app: string): HistoryEntry[] => listHistoryIn(historyDir(dataDir, app))
|
|
|
|
/* Newest first. Never the text. `dir` is a history folder. */
|
|
export function listHistoryIn(dir: string): HistoryEntry[] {
|
|
if (!existsSync(dir)) return []
|
|
const entries: HistoryEntry[] = []
|
|
for (const name of readdirSync(dir)) {
|
|
const m = HISTORY_FILE.exec(name)
|
|
if (m === null) continue
|
|
try {
|
|
const text = readFileSync(path.join(dir, name), 'utf8')
|
|
entries.push({
|
|
id: name,
|
|
at: `${m[1]}T${m[2]}:${m[3]}:${m[4]}`,
|
|
server: m[5],
|
|
kind: m[6],
|
|
size: Buffer.byteLength(text),
|
|
hash: createHash('sha256').update(text).digest('hex').slice(0, 16),
|
|
})
|
|
} catch {
|
|
// A file that vanished while listing is skipped.
|
|
}
|
|
}
|
|
return entries.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
}
|
|
|
|
/* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */
|
|
export const readHistory = (dataDir: string, app: string, id: string): string | null => readHistoryIn(historyDir(dataDir, app), id)
|
|
|
|
export function readHistoryIn(dir: string, id: string): string | null {
|
|
const known = listHistoryIn(dir).find((e) => e.id === id)
|
|
return known === undefined ? null : readFileSync(path.join(dir, known.id), 'utf8')
|
|
}
|
|
|
|
/* The apps that have a baseline, by the name stored in the file. */
|
|
export function listBaselineApps(dataDir: string): string[] {
|
|
const root = path.join(dataDir, 'apps')
|
|
if (!existsSync(root)) return []
|
|
const apps: string[] = []
|
|
for (const dir of readdirSync(root)) {
|
|
try {
|
|
if (!statSync(path.join(root, dir)).isDirectory()) continue
|
|
const data = readJson(path.join(root, dir, 'baseline.json')) as Partial<Baseline> | null
|
|
if (data !== null && typeof data.app === 'string' && typeof data.text === 'string') apps.push(data.app)
|
|
} catch {
|
|
// Not an app folder.
|
|
}
|
|
}
|
|
return apps.sort((a, b) => a.localeCompare(b))
|
|
}
|
|
|
|
/*
|
|
Saves a server's config as it was before the hub changed it. The name starts with the time, so
|
|
sorting the names sorts the files by age; the oldest beyond KEEP_HISTORY are deleted.
|
|
*/
|
|
export function writeHistory(
|
|
dataDir: string,
|
|
app: string,
|
|
server: string,
|
|
text: string,
|
|
kind: HistoryKind,
|
|
now: number,
|
|
): string {
|
|
return writeHistoryIn(historyDir(dataDir, app), server, text, kind, now)
|
|
}
|
|
|
|
export function writeHistoryIn(dir: string, server: string, text: string, kind: HistoryKind, now: number): string {
|
|
const stamp = new Date(now).toISOString().replace(/:/g, '-')
|
|
const file = path.join(dir, `${stamp}__${safeName(server)}__${kind}.txt`)
|
|
writeAtomic(file, text)
|
|
const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort()
|
|
for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
|
|
return file
|
|
}
|
|
|
|
/* ---- Settings and blocking lists (stage 7): `${HUB_DATA}/settings/` and `${HUB_DATA}/blocking/` ---- */
|
|
|
|
export const settingsDir = (dataDir: string): string => path.join(dataDir, 'settings')
|
|
export const settingsHistoryDir = (dataDir: string): string => path.join(settingsDir(dataDir), 'history')
|
|
export const blockingDir = (dataDir: string): string => path.join(dataDir, 'blocking')
|
|
|
|
export interface SettingsBaseline {
|
|
/** The settings as `settings/get` gave them, secrets included (the file is 0600). */
|
|
settings: Record<string, unknown>
|
|
importedFrom?: string
|
|
updatedAt: string
|
|
updatedBy: string
|
|
}
|
|
|
|
/* Extra local fields, on top of the built-in ones. Values are read live from the servers, not stored. */
|
|
export interface SettingsOverrides {
|
|
paths: string[]
|
|
}
|
|
|
|
export function readSettingsBaseline(dataDir: string): SettingsBaseline | null {
|
|
const data = readJson(path.join(settingsDir(dataDir), 'baseline.json')) as Partial<SettingsBaseline> | null
|
|
if (data === null || data.settings === null || typeof data.settings !== 'object' || Array.isArray(data.settings)) return null
|
|
return {
|
|
settings: data.settings,
|
|
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
|
updatedAt: String(data.updatedAt ?? ''),
|
|
updatedBy: String(data.updatedBy ?? ''),
|
|
}
|
|
}
|
|
|
|
export const writeSettingsBaseline = (dataDir: string, baseline: SettingsBaseline): void =>
|
|
writeAtomic(path.join(settingsDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|
|
|
|
export function readSettingsOverrides(dataDir: string): SettingsOverrides {
|
|
const data = readJson(path.join(settingsDir(dataDir), 'overrides.json')) as Partial<SettingsOverrides> | null
|
|
return { paths: Array.isArray(data?.paths) ? data.paths.filter((p): p is string => typeof p === 'string') : [] }
|
|
}
|
|
|
|
export const writeSettingsOverrides = (dataDir: string, overrides: SettingsOverrides): void =>
|
|
writeAtomic(path.join(settingsDir(dataDir), 'overrides.json'), JSON.stringify(overrides, null, 2) + '\n')
|
|
|
|
export interface ListsBaseline {
|
|
blocked: string[]
|
|
allowed: string[]
|
|
importedFrom?: string
|
|
updatedAt: string
|
|
updatedBy: string
|
|
}
|
|
|
|
export function readListsBaseline(dataDir: string): ListsBaseline | null {
|
|
const data = readJson(path.join(blockingDir(dataDir), 'baseline.json')) as Partial<ListsBaseline> | null
|
|
if (data === null || !Array.isArray(data.blocked) || !Array.isArray(data.allowed)) return null
|
|
const strings = (v: unknown[]): string[] => v.filter((x): x is string => typeof x === 'string')
|
|
return {
|
|
blocked: strings(data.blocked),
|
|
allowed: strings(data.allowed),
|
|
...(typeof data.importedFrom === 'string' ? { importedFrom: data.importedFrom } : {}),
|
|
updatedAt: String(data.updatedAt ?? ''),
|
|
updatedBy: String(data.updatedBy ?? ''),
|
|
}
|
|
}
|
|
|
|
export const writeListsBaseline = (dataDir: string, baseline: ListsBaseline): void =>
|
|
writeAtomic(path.join(blockingDir(dataDir), 'baseline.json'), JSON.stringify(baseline, null, 2) + '\n')
|