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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 01:20:05 +07:00
1 parent 34de910e04
commit 392b644faa
25 files changed
+4865 -22

No files matched your search

+79 -8
View File
@@ -87,7 +87,7 @@ export function writeOverrides(dataDir: string, app: string, overrides: Override
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'
export type HistoryKind = 'before-uninstall' | 'before-apply' | 'before-set' | 'before-save' | 'before-lists'
export interface HistoryEntry {
/** The file name; the only handle `readHistory` accepts. */
@@ -104,9 +104,10 @@ const HISTORY_FILE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2}\.\d{3}Z)__(.+)
const historyDir = (dataDir: string, app: string): string => path.join(appDir(dataDir, app), 'history')
/* Newest first. Never the text. */
export function listHistory(dataDir: string, app: string): HistoryEntry[] {
const dir = historyDir(dataDir, app)
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)) {
@@ -130,9 +131,11 @@ export function listHistory(dataDir: string, app: string): HistoryEntry[] {
}
/* The text of one entry. `id` has to be a name the listing returns, so nothing outside the folder can be read. */
export function readHistory(dataDir: string, app: string, id: string): string | null {
const known = listHistory(dataDir, app).find((e) => e.id === id)
return known === undefined ? null : readFileSync(path.join(historyDir(dataDir, app), known.id), 'utf8')
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. */
@@ -164,7 +167,10 @@ export function writeHistory(
kind: HistoryKind,
now: number,
): string {
const dir = path.join(appDir(dataDir, app), 'history')
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)
@@ -172,3 +178,68 @@ export function writeHistory(
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')