Hub stage 8: settings drift, webhook notices, profiles
CI / check (push) Successful in 4m27s
Hub / check (push) Successful in 1m17s
CI / image (push) Successful in 1m54s
Hub / image (push) Successful in 1m56s

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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-01 02:11:19 +07:00
1 parent 392b644faa
commit 9742f0fe69
32 files changed
+3123 -106

No files matched your search

+101 -2
View File
@@ -32,6 +32,8 @@ import { AppsWriter, actionForPath, type FileAction, type TargetResult } from '.
import { TooLarge, parseMultipart, readLimited, type UploadedFile } from './multipart.ts'
import { AppsConfig, UserError } from './apps-config.ts'
import { Drift } from './drift.ts'
import { Webhook, type WebhookOptions } from './webhook.ts'
import { Profiles } from './profiles.ts'
import { SettingsConfig } from './settings-config.ts'
import { BLOCKING_FIELDS } from './settings-sync.ts'
import { ZoneUserError, ZonesSync, buildZoneMatrix } from './zones-sync.ts'
@@ -141,17 +143,31 @@ function clientIp(req: http.IncomingMessage, trustProxy: boolean): string {
return req.socket.remoteAddress ?? 'unknown'
}
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap)): http.Server {
export function createHub(config: HubConfig, users: Users = Users.load(config.dataDir, config.bootstrap), options: { webhook?: WebhookOptions } = {}): http.Server {
const upstream = new Upstream(config)
const drift = new Drift(config, upstream)
const webhook = new Webhook(config, options.webhook)
const drift = new Drift(config, upstream, Date.now, { webhook })
const sessions = new Sessions(config, users, Date.now, () => drift.count())
const limiter = new LoginLimiter()
const audit = new Audit(config.dataDir)
// A failed write is told to the webhook: who, what, where and the audited reason (never a value). Logins and the test itself are left out.
audit.onEntry((e) => {
if (e.outcome !== 'failed' || e.action === 'login' || e.action.startsWith('webhook.')) return
webhook.queueFailed({
action: e.action,
...(e.server === undefined ? {} : { server: e.server }),
...(e.target === undefined ? {} : { target: e.target }),
user: e.user,
...(e.detail === undefined ? {} : { detail: e.detail.slice(0, 200) }),
at: e.ts,
})
})
const writer = new AppsWriter(config, upstream)
const runner = new ServersRunner(config, upstream)
const appsConfig = new AppsConfig(config, upstream)
const zonesSync = new ZonesSync(config, upstream)
const settingsConfig = new SettingsConfig(config, upstream)
const profiles = new Profiles(config, upstream, appsConfig, settingsConfig, writer)
const byId = new Map(config.servers.map((s) => [s.id, s]))
/* Ask every server; the ones that fail become warnings (see mergeSettled). */
@@ -488,6 +504,71 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
}
}
/* Profiles (profiles.ts): list, view, export and the preview. No change is made. */
async function profilesRead(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.') }
try {
if (apiPath === 'hub/profiles/list') send(res, 200, ok({ profiles: profiles.list() }))
else if (apiPath === 'hub/profiles/get') send(res, 200, ok(profiles.get(params.name ?? '')))
else if (apiPath === 'hub/profiles/preview') {
const preview = await profiles.preview(params.name ?? '', csv(params.targets), params.removeExtras === 'true')
audit.log({ ...line, target: params.name, outcome: 'ok', detail: csv(params.targets).join(',') })
send(res, 200, ok(preview))
} else if (apiPath === 'hub/profiles/export') {
const file = profiles.exportFile(params.name)
audit.log({ ...line, target: params.name || undefined, outcome: 'ok', detail: `${file.profiles.length} profile(s)` })
const text = JSON.stringify(file, null, 2) + '\n'
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-disposition': `attachment; filename="hub-profiles-${file.exportedAt.slice(0, 10)}.json"`,
'cache-control': 'no-store',
'content-length': Buffer.byteLength(text),
})
res.end(text)
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
send(res, 400, fail(message))
}
}
/* Make, delete, import and apply a profile. Audit lines carry names, counts and the outcome of each step, never a value. */
async function profilesWrite(res: http.ServerResponse, apiPath: string, params: Params, who: Who, ip: string): Promise<void> {
const line = { user: who.user, role: who.role, ip, action: apiPath.replace('hub/', '').replace(/\//g, '.'), target: (params.name ?? '').slice(0, 60) || undefined }
try {
if (apiPath === 'hub/profiles/snapshot') {
const done = await profiles.snapshot(params.name ?? '', { apps: params.apps === undefined ? undefined : csv(params.apps), settings: params.settings === 'true', lists: params.lists === 'true' }, who.user)
audit.log({ ...line, outcome: 'ok', detail: done.detail })
send(res, 200, ok({ profile: profiles.get(params.name ?? ''), replaced: done.replaced }))
} else if (apiPath === 'hub/profiles/delete') {
profiles.delete(params.name ?? '')
audit.log({ ...line, outcome: 'ok' })
send(res, 200, ok({ deleted: true }))
} else if (apiPath === 'hub/profiles/import') {
const done = profiles.importFile(params.data ?? '', params.apply === 'true', who.user)
const count = (status: string) => done.profiles.filter((p) => p.status === status).length
audit.log({ ...line, target: undefined, outcome: 'ok', detail: `${done.applied ? 'applied' : 'dry run'}: ${count('new')} new, ${count('changed')} changed, ${count('same')} same` })
send(res, 200, ok(done))
} else if (apiPath === 'hub/profiles/apply') {
const from = jsonParam(params.from, {}) as Record<string, string>
const done = await profiles.apply(params.name ?? '', csv(params.targets), { removeExtras: params.removeExtras === 'true', from: from !== null && typeof from === 'object' ? from : {} })
for (const r of done.results) {
for (const s of r.steps.length > 0 ? r.steps : [{ step: 'apps', status: r.ok ? 'ok' : 'failed', message: r.message }]) {
if (s.status === 'not-run' || s.status === 'skipped') continue
audit.log({ ...line, server: r.server, outcome: s.status === 'ok' ? 'ok' : 'failed', detail: `${s.step}: ${s.message}`.slice(0, 300) })
}
}
send(res, 200, ok({ results: done.results }))
} else send(res, 403, fail('The hub is read-only.'))
} catch (e) {
const message = userMessage(e)
if (message === null) throw e
audit.log({ ...line, outcome: 'refused', detail: message.slice(0, 120) })
send(res, 400, fail(message))
}
}
/* The write endpoints. The role has already been checked against the route's capability. */
async function write(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string, route: Route, params: Params, who: Who, ip: string): Promise<void> {
if (route.kind === 'write-all') {
@@ -495,6 +576,12 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
send(res, 405, fail('Use POST.'))
return
}
if (apiPath === 'hub/webhook/test') {
const sent = await webhook.deliver('webhook.test', [{ message: 'A test notice from the hub.', by: who.user }])
audit.log({ user: who.user, role: who.role, ip, action: 'webhook.test', outcome: sent.ok ? 'ok' : 'failed', detail: sent.ok ? undefined : sent.error })
send(res, 200, ok(webhook.status()))
return
}
if (apiPath === 'hub/drift/check') {
const state = await drift.check()
audit.log({ user: who.user, role: who.role, ip, action: 'drift.check', outcome: 'ok', detail: `${state.apps.length} finding(s), ${drift.count()} differ` })
@@ -505,6 +592,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await configWrite(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/profiles/')) {
await profilesWrite(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsWrite(res, apiPath, params, who, ip)
return
@@ -822,6 +913,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await configRead(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/profiles/')) {
await profilesRead(res, apiPath, params, who, ip)
return
}
if (apiPath.startsWith('hub/settings/') || apiPath.startsWith('hub/blocking/')) {
await settingsRead(res, apiPath, params, who, ip)
return
@@ -830,6 +925,10 @@ export function createHub(config: HubConfig, users: Users = Users.load(config.da
await zonesRead(res, apiPath, params)
return
}
if (apiPath === 'hub/webhook/status') {
send(res, 200, ok(webhook.status()))
return
}
if (apiPath === 'hub/drift/get') {
send(res, 200, ok(drift.get()))
return