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

511 lines
25 KiB
TypeScript

/*
Profiles (stage 8): a named bundle of what a server should have, kept in `${HUB_DATA}/profiles/`,
to bring a new or reset server in line in one go.
A profile holds, as the admin chose when it was made from the hub's current baselines:
apps store apps only (never a zip): the name and the version they had, and for each the hub's
baseline config at that moment
settings the SHARED fields of the settings baseline (host fields never travel), secrets as the
baseline file keeps them
lists the custom Blocked and Allowed lists baseline
Applying a profile to a server runs four steps in order, and a failed step stops the later ones for
that server (the other servers go on):
apps install the apps that are missing, from THAT server's own store listing (apps-write.ts)
configs the profile's config of each app plus that server's own local values; the old config
goes to the history first (apps-config.ts)
settings the changed shared fields, through the same checks as a per-server settings/set
lists the missing domains; extras only when asked
`from` restarts a server at a given step (the retry of a failed run), so what already worked is not
done again. Everything here is for admins. A view never carries a secret and never an app config.
*/
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import type { HubConfig, HubServer } from './config.ts'
import { MAX_CONFIG, looksLikeJson, parseJson, type Diff } from './apps-sync.ts'
import { UserError, compareVersions, type AppsConfig } from './apps-config.ts'
import type { AppsWriter } from './apps-write.ts'
import { isFieldName, isBuiltInLocal, MAX_LIST, SECRET_PLACEHOLDER, maskSettings } from './settings-sync.ts'
import type { SettingsConfig } from './settings-config.ts'
import { listBaselineApps, readBaseline, readListsBaseline, readSettingsBaseline, safeName, writeAtomic } from './store.ts'
import type { Upstream } from './upstream.ts'
export const STEPS = ['apps', 'configs', 'settings', 'lists'] as const
export type Step = (typeof STEPS)[number]
export interface ProfileApp {
name: string
/** The version the app had when the profile was made. */
version: string
/** The hub's baseline config of the app then; null when it had none. */
config: string | null
}
export interface Profile {
name: string
createdAt: string
updatedAt: string
updatedBy: string
apps: ProfileApp[]
settings: Record<string, unknown> | null
lists: { blocked: string[]; allowed: string[] } | null
}
export interface StepResult {
step: Step
/* `skipped`: done by an earlier run. `not-run`: an earlier step failed. */
status: 'ok' | 'failed' | 'skipped' | 'not-run'
message: string
}
export interface ApplyResult {
server: string
ok: boolean
message: string
steps: StepResult[]
}
const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,59}$/
const MAX_PROFILES = 50
const MAX_APPS = 100
const MAX_SETTINGS_BYTES = 1024 * 1024
const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed')
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
const hasPlaceholder = (value: unknown): boolean => JSON.stringify(value).includes(JSON.stringify(SECRET_PLACEHOLDER))
/* The stored shape, checked field by field. Used for files on disk and for imports. */
export function cleanProfile(raw: unknown, fallbackName?: string): Profile {
if (!isObject(raw)) throw new UserError('A profile must be an object.')
const name = typeof raw.name === 'string' ? raw.name : (fallbackName ?? '')
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
if (!Array.isArray(raw.apps) || raw.apps.length > MAX_APPS) throw new UserError(`"${name}": the apps must be a list of at most ${MAX_APPS}.`)
const seen = new Set<string>()
const apps = raw.apps.map((a): ProfileApp => {
if (!isObject(a) || typeof a.name !== 'string' || a.name === '' || a.name.length > 200 || seen.has(a.name)) throw new UserError(`"${name}": an app has no valid, unique name.`)
seen.add(a.name)
const version = typeof a.version === 'string' ? a.version.slice(0, 40) : ''
const config = typeof a.config === 'string' ? a.config : null
if (config !== null) {
if (config.length > MAX_CONFIG) throw new UserError(`"${name}": the config of "${a.name}" is larger than 1 MB.`)
if (looksLikeJson(config) && !parseJson(config).ok) throw new UserError(`"${name}": the config of "${a.name}" is not valid JSON.`)
}
return { name: a.name, version, config }
})
let settings: Profile['settings'] = null
if (raw.settings !== null && raw.settings !== undefined) {
if (!isObject(raw.settings)) throw new UserError(`"${name}": the settings must be an object.`)
if (JSON.stringify(raw.settings).length > MAX_SETTINGS_BYTES) throw new UserError(`"${name}": the settings are larger than 1 MB.`)
if (hasPlaceholder(raw.settings)) throw new UserError(`"${name}": the settings hold ${SECRET_PLACEHOLDER} where a secret should be. Export the profile from the hub that has it.`)
settings = {}
for (const [k, v] of Object.entries(raw.settings)) {
if (!isFieldName(k)) throw new UserError(`"${name}": "${k.slice(0, 40)}" is not a setting name.`)
// Host fields never travel.
if (!isBuiltInLocal(k)) settings[k] = v
}
}
let lists: Profile['lists'] = null
if (raw.lists !== null && raw.lists !== undefined) {
const l = raw.lists as { blocked?: unknown; allowed?: unknown }
if (!isObject(raw.lists) || !Array.isArray(l.blocked) || !Array.isArray(l.allowed)) throw new UserError(`"${name}": the lists need a blocked and an allowed array.`)
const clean = (v: unknown[]): string[] => {
if (v.length > MAX_LIST || v.some((d) => typeof d !== 'string' || d.length > 253)) throw new UserError(`"${name}": a list is longer than ${MAX_LIST} domains or holds a bad entry.`)
return [...new Set(v as string[])].sort()
}
lists = { blocked: clean(l.blocked), allowed: clean(l.allowed) }
}
const text = (v: unknown): string => (typeof v === 'string' ? v : '')
return { name, createdAt: text(raw.createdAt), updatedAt: text(raw.updatedAt), updatedBy: text(raw.updatedBy), apps, settings, lists }
}
const shape = (p: Profile): string => JSON.stringify({ apps: p.apps, settings: p.settings, lists: p.lists })
export class Profiles {
#config: HubConfig
#upstream: Upstream
#appsConfig: AppsConfig
#settings: SettingsConfig
#writer: AppsWriter
#now: () => number
constructor(config: HubConfig, upstream: Upstream, appsConfig: AppsConfig, settings: SettingsConfig, writer: AppsWriter, now: () => number = Date.now) {
this.#config = config
this.#upstream = upstream
this.#appsConfig = appsConfig
this.#settings = settings
this.#writer = writer
this.#now = now
}
#dir(): string {
return path.join(this.#config.dataDir, 'profiles')
}
#file(name: string): string {
return path.join(this.#dir(), `${safeName(name)}.json`)
}
#read(name: string): Profile | null {
const file = this.#file(name)
if (!existsSync(file)) return null
try {
const p = cleanProfile(JSON.parse(readFileSync(file, 'utf8')))
return p.name === name ? p : null
} catch {
return null
}
}
#all(): Profile[] {
if (!existsSync(this.#dir())) return []
const out: Profile[] = []
for (const f of readdirSync(this.#dir())) {
if (!f.endsWith('.json')) continue
try {
out.push(cleanProfile(JSON.parse(readFileSync(path.join(this.#dir(), f), 'utf8'))))
} catch {
// A file that is not a profile is left alone.
}
}
return out.sort((a, b) => a.name.localeCompare(b.name))
}
#require(name: string): Profile {
const found = this.#read(name)
if (found === null) throw new UserError(`There is no profile named "${name.slice(0, 60)}".`)
return found
}
#write(p: Profile): void {
writeAtomic(this.#file(p.name), JSON.stringify(p, null, 2) + '\n')
}
/* What the console lists: counts, no config and no value. */
list(): { name: string; apps: number; configs: number; settings: number | null; lists: { blocked: number; allowed: number } | null; updatedAt: string; updatedBy: string }[] {
return this.#all().map((p) => ({
name: p.name,
apps: p.apps.length,
configs: p.apps.filter((a) => a.config !== null).length,
settings: p.settings === null ? null : Object.keys(p.settings).length,
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
updatedAt: p.updatedAt,
updatedBy: p.updatedBy,
}))
}
/* One profile for the console: secrets masked, no app config text. */
get(name: string): unknown {
const p = this.#require(name)
return {
name: p.name,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
updatedBy: p.updatedBy,
apps: p.apps.map((a) => ({ name: a.name, version: a.version, hasConfig: a.config !== null, bytes: a.config === null ? 0 : a.config.length })),
settings: p.settings === null ? null : maskSettings(p.settings),
lists: p.lists === null ? null : { blocked: p.lists.blocked.length, allowed: p.lists.allowed.length },
}
}
delete(name: string): void {
this.#require(name)
rmSync(this.#file(name), { force: true })
}
/* ---- Making one from the hub's current baselines ---- */
async #installedVersions(): Promise<Map<string, string>> {
const versions = new Map<string, string>()
let answered = 0
await Promise.all(
this.#config.servers.map(async (s) => {
try {
this.#upstream.invalidate(s.id)
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
answered++
for (const a of list?.apps ?? []) {
const have = versions.get(a.name)
const v = typeof a.version === 'string' ? a.version : ''
if (have === undefined || compareVersions(v, have) > 0) versions.set(a.name, v)
}
} catch {
// A server that does not answer adds nothing.
}
}),
)
if (answered === 0) throw new UserError('No server answered, so the installed apps cannot be read.')
return versions
}
async #storeNames(): Promise<Set<string>> {
const names = new Set<string>()
let answered = 0
await Promise.all(
this.#config.servers.map(async (s) => {
try {
const r = (await this.#upstream.call(s, 'apps/listStoreApps')) as { storeApps?: { name: string }[] } | null
answered++
for (const a of r?.storeApps ?? []) names.add(a.name)
} catch {
// Same.
}
}),
)
if (answered === 0) throw new UserError('No server answered, so the store listing cannot be read.')
return names
}
/*
Makes or replaces a profile from the hub's current baselines. `apps`: the names to take (undefined
takes every app that has a baseline). Settings and lists are taken whole when asked.
*/
async snapshot(name: string, parts: { apps?: string[]; settings: boolean; lists: boolean }, user: string): Promise<{ replaced: boolean; detail: string }> {
if (!NAME.test(name)) throw new UserError('A profile name uses letters, digits, spaces and . _ - (up to 60, starting with a letter or digit).')
const appNames = parts.apps ?? listBaselineApps(this.#config.dataDir)
if (appNames.length === 0 && !parts.settings && !parts.lists) throw new UserError('Choose at least one part: apps, settings or lists.')
if (appNames.length > MAX_APPS) throw new UserError(`At most ${MAX_APPS} apps in a profile.`)
const existing = this.#read(name)
if (existing === null && this.#all().length >= MAX_PROFILES) throw new UserError(`At most ${MAX_PROFILES} profiles.`)
let apps: ProfileApp[] = []
if (appNames.length > 0) {
const [versions, store] = await Promise.all([this.#installedVersions(), this.#storeNames()])
apps = [...new Set(appNames)].map((app) => {
const version = versions.get(app)
if (version === undefined) throw new UserError(`"${app}" is not installed on any server, so its version is not known.`)
if (!store.has(app)) throw new UserError(`"${app}" is not in the store listing. A profile holds store apps only, not apps installed from a file.`)
return { name: app, version, config: readBaseline(this.#config.dataDir, app)?.text ?? null }
})
}
let settings: Profile['settings'] = null
if (parts.settings) {
const baseline = readSettingsBaseline(this.#config.dataDir)
if (baseline === null) throw new UserError('There is no settings baseline yet. Accept one from a server first.')
const extra = this.#settings.extraLocal()
settings = Object.fromEntries(Object.entries(baseline.settings).filter(([k]) => !isBuiltInLocal(k) && !extra.includes(k)))
}
let lists: Profile['lists'] = null
if (parts.lists) {
const baseline = readListsBaseline(this.#config.dataDir)
if (baseline === null) throw new UserError('There is no lists baseline yet. Accept one from a server first.')
lists = { blocked: [...baseline.blocked], allowed: [...baseline.allowed] }
}
const at = new Date(this.#now()).toISOString()
this.#write({ name, createdAt: existing?.createdAt || at, updatedAt: at, updatedBy: user, apps, settings, lists })
return {
replaced: existing !== null,
detail: `${existing === null ? 'created' : 'replaced'}: ${apps.length} app(s), settings ${settings === null ? 'no' : Object.keys(settings).length + ' fields'}, lists ${lists === null ? 'no' : 'yes'}`,
}
}
/* ---- Export and import ---- */
exportFile(name?: string): { hub: string; type: string; version: number; exportedAt: string; profiles: Profile[] } {
const profiles = name === undefined || name === '' ? this.#all() : [this.#require(name)]
return { hub: 'technitium-hub', type: 'profiles', version: 1, exportedAt: new Date(this.#now()).toISOString(), profiles }
}
importFile(raw: string, apply: boolean, user: string): { profiles: { name: string; status: 'new' | 'changed' | 'same'; apps: number; settings: boolean; lists: boolean }[]; 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; type?: unknown; version?: unknown; profiles?: unknown }
if (file === null || typeof file !== 'object' || file.hub !== 'technitium-hub' || file.type !== 'profiles') throw new UserError('This is not a profiles 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.profiles) || file.profiles.length > MAX_PROFILES) throw new UserError(`The file has no list of profiles, or has more than ${MAX_PROFILES}.`)
const plan: { profile: Profile; status: 'new' | 'changed' | 'same' }[] = []
const seen = new Set<string>()
for (const item of file.profiles) {
const profile = cleanProfile(item)
if (seen.has(profile.name)) throw new UserError(`"${profile.name}" is in the file twice.`)
seen.add(profile.name)
const old = this.#read(profile.name)
plan.push({ profile, status: old === null ? 'new' : shape(old) === shape(profile) ? 'same' : 'changed' })
}
const total = this.#all().filter((p) => !seen.has(p.name)).length + plan.length
if (total > MAX_PROFILES) throw new UserError(`That would make more than ${MAX_PROFILES} profiles.`)
if (apply) {
const at = new Date(this.#now()).toISOString()
for (const p of plan) {
if (p.status === 'same') continue
const old = this.#read(p.profile.name)
this.#write({ ...p.profile, createdAt: old?.createdAt || at, updatedAt: at, updatedBy: user })
}
}
return { profiles: plan.map((p) => ({ name: p.profile.name, status: p.status, apps: p.profile.apps.length, settings: p.profile.settings !== null, lists: p.profile.lists !== null })), applied: apply }
}
/* ---- Preview ---- */
#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 #look(server: HubServer): Promise<{ installed: Map<string, string>; store: Map<string, string> }> {
this.#upstream.invalidate(server.id)
const list = (await this.#upstream.call(server, 'apps/list')) as { apps?: { name: string; version?: string }[] } | null
const store = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; version?: string }[] } | null
return {
installed: new Map((list?.apps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
store: new Map((store?.storeApps ?? []).map((a) => [a.name, typeof a.version === 'string' ? a.version : ''])),
}
}
/* What applying the profile would change on each target. Nothing is written. */
async preview(name: string, ids: string[], removeExtras: boolean): Promise<{ profile: string; targets: unknown[] }> {
const profile = this.#require(name)
const targets = this.#targets(ids)
const out = await Promise.all(
targets.map(async (s) => {
const writable = s.adminToken !== undefined
const base = { server: s.id, writable }
try {
const { installed, store } = await this.#look(s)
const apps = profile.apps.map((a) => {
const have = installed.get(a.name)
if (have === undefined) {
const offered = store.get(a.name)
return offered === undefined
? { name: a.name, state: 'not-in-store' as const, profileVersion: a.version }
: { name: a.name, state: 'install' as const, profileVersion: a.version, storeVersion: offered }
}
return { name: a.name, state: have === a.version ? ('present' as const) : ('version-differs' as const), profileVersion: a.version, installedVersion: have }
})
const configs = await Promise.all(
profile.apps
.filter((a) => a.config !== null)
.map(async (a) => {
if (!installed.has(a.name)) return { name: a.name, state: 'after-install' as const }
try {
const p = (await this.#appsConfig.preview(a.name, [s.id], a.config as string)).targets[0]
if (!p.ok) return { name: a.name, state: 'unreadable' as const, error: p.error }
return p.unchanged ? { name: a.name, state: 'same' as const } : { name: a.name, state: 'differs' as const, changes: p.changes as Diff }
} catch (e) {
return { name: a.name, state: 'unreadable' as const, error: reason(e) }
}
}),
)
let settings: unknown = null
if (profile.settings !== null) {
const p = (await this.#settings.preview([s.id], undefined, { settings: profile.settings, updatedAt: '', updatedBy: '' })).targets[0]
settings = p.ok ? { fields: p.changes.changes.map((c) => c.path), secretFields: p.secretFields, unchanged: p.unchanged, changes: p.changes, warnings: p.warnings } : { error: p.error }
}
let lists: unknown = null
if (profile.lists !== null) {
const p = (await this.#settings.listsPreview([s.id], removeExtras, { ...profile.lists, updatedAt: '', updatedBy: '' })).targets[0]
lists = p.ok ? { blocked: p.blocked, allowed: p.allowed, status: p.status, tooMany: p.tooMany } : { error: p.error }
}
return { ...base, ok: true, apps, configs, settings, lists, warnings: writable ? [] : ['The hub has no admin token for this server, so it cannot write to it.'] }
} catch (e) {
return { ...base, ok: false, error: reason(e), apps: [], configs: [], settings: null, lists: null, warnings: [] }
}
}),
)
return { profile: profile.name, targets: out }
}
/* ---- Apply ---- */
async #run(profile: Profile, s: HubServer, step: Step, removeExtras: boolean): Promise<{ ok: boolean; message: string }> {
const failed = (message: string) => ({ ok: false, message })
if (step === 'apps') {
this.#upstream.invalidate(s.id)
const list = (await this.#upstream.call(s, 'apps/list')) as { apps?: { name: string }[] } | null
const have = new Set((list?.apps ?? []).map((a) => a.name))
const installed: string[] = []
for (const a of profile.apps) {
if (have.has(a.name)) continue
const outcome = await this.#writer.runMany('install-store', a.name, [s.id])
if ('error' in outcome) return failed(`${a.name}: ${outcome.error}`)
const r = outcome.results[0]
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'could not be installed'}`)
installed.push(a.name)
}
return { ok: true, message: installed.length === 0 ? 'Every app is already installed.' : `Installed ${installed.join(', ')}.` }
}
if (step === 'configs') {
let written = 0
let same = 0
for (const a of profile.apps) {
if (a.config === null) continue
const done = await this.#appsConfig.apply(a.name, [s.id], {}, a.config)
const r = done.results[0]
if (!r.ok) return failed(`${a.name}: ${r.message ?? 'failed'}`)
if (/nothing was written/.test(r.message ?? '')) same++
else written++
}
return { ok: true, message: `${written} config(s) applied, ${same} already in sync.` }
}
if (step === 'settings') {
const done = await this.#settings.apply([s.id], {}, undefined, { settings: profile.settings ?? {}, updatedAt: '', updatedBy: '' })
const r = done.results[0]
return { ok: r.ok, message: r.message ?? '' }
}
const done = await this.#settings.listsApply([s.id], removeExtras, { ...(profile.lists ?? { blocked: [], allowed: [] }), updatedAt: '', updatedBy: '' })
const r = done.results[0]
return { ok: r.ok, message: r.message ?? '' }
}
#stepsOf(profile: Profile): Step[] {
return STEPS.filter((step) => (step === 'apps' ? profile.apps.length > 0 : step === 'configs' ? profile.apps.some((a) => a.config !== null) : step === 'settings' ? profile.settings !== null : profile.lists !== null))
}
async #applyOne(profile: Profile, s: HubServer, from: Step | undefined, removeExtras: boolean): Promise<ApplyResult> {
const steps: StepResult[] = []
const plan = this.#stepsOf(profile)
if (plan.length === 0) return { server: s.id, ok: true, message: 'The profile has nothing to apply.', steps }
if (s.adminToken === undefined) {
return { server: s.id, ok: false, message: 'no admin token configured', steps: plan.map((step, i) => ({ step, status: i === 0 ? ('failed' as const) : ('not-run' as const), message: i === 0 ? 'no admin token configured' : 'An earlier step failed.' })) }
}
const start = from === undefined ? 0 : Math.max(0, plan.indexOf(from))
let stopped: Step | null = null
for (const [i, step] of plan.entries()) {
if (stopped !== null) {
steps.push({ step, status: 'not-run', message: 'An earlier step failed.' })
continue
}
if (i < start) {
steps.push({ step, status: 'skipped', message: 'Done by an earlier run.' })
continue
}
let outcome: { ok: boolean; message: string }
try {
outcome = await this.#run(profile, s, step, removeExtras)
} catch (e) {
outcome = { ok: false, message: reason(e) }
}
steps.push({ step, status: outcome.ok ? 'ok' : 'failed', message: outcome.message })
if (!outcome.ok) stopped = step
}
return {
server: s.id,
ok: stopped === null,
message: stopped === null ? 'Profile applied.' : `Stopped at ${stopped}: ${steps.find((x) => x.step === stopped)?.message ?? 'failed'}`.slice(0, 300),
steps,
}
}
/* Several servers go on in parallel; the steps of one server go one after the other. */
async apply(name: string, ids: string[], options: { removeExtras: boolean; from: Record<string, string> }): Promise<{ results: ApplyResult[] }> {
const profile = this.#require(name)
const targets = this.#targets(ids)
const results = await Promise.all(
targets.map((s) => {
const from = (STEPS as readonly string[]).includes(options.from[s.id]) ? (options.from[s.id] as Step) : undefined
return this.#applyOne(profile, s, from, options.removeExtras)
}),
)
return { results }
}
}