/* 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 | 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 => 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() 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> { const versions = new Map() 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> { const names = new Set() 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() 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; store: Map }> { 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 { 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 }): 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 } } }