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]>
137 lines
4.2 KiB
TypeScript
137 lines
4.2 KiB
TypeScript
/*
|
|
The audit log: JSON Lines in `${HUB_DATA}/audit.log`, one entry per line, newest last on
|
|
disk and newest first from `list`. Rotated at 10 MB to audit.log.1 (one kept). If the
|
|
folder is not writable, one line goes to stderr and the log lives in memory (last 1000).
|
|
|
|
Entries carry fixed fields only. Callers never pass passwords, tokens or request bodies.
|
|
*/
|
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
export const MAX_BYTES = 10 * 1024 * 1024
|
|
const RING = 1000
|
|
const DETAIL_MAX = 300
|
|
|
|
export interface AuditEntry {
|
|
ts: string
|
|
user: string
|
|
role?: string
|
|
ip: string
|
|
action: string
|
|
server?: string
|
|
target?: string
|
|
outcome: 'ok' | 'failed' | 'refused'
|
|
detail?: string
|
|
}
|
|
|
|
export type AuditInput = Omit<AuditEntry, 'ts'>
|
|
|
|
const cut = (value: string, max: number): string => value.slice(0, max)
|
|
|
|
export class Audit {
|
|
#file: string
|
|
#maxBytes: number
|
|
#now: () => number
|
|
#ring: AuditEntry[] = []
|
|
#size = 0
|
|
#memoryOnly = false
|
|
#listener: ((entry: AuditEntry) => void) | null = null
|
|
|
|
constructor(dir: string, options: { maxBytes?: number; now?: () => number } = {}) {
|
|
this.#maxBytes = options.maxBytes ?? MAX_BYTES
|
|
this.#now = options.now ?? Date.now
|
|
this.#file = path.join(dir, 'audit.log')
|
|
try {
|
|
mkdirSync(dir, { recursive: true })
|
|
this.#size = existsSync(this.#file) ? statSync(this.#file).size : 0
|
|
appendFileSync(this.#file, '')
|
|
} catch (e) {
|
|
this.#fallBack(e)
|
|
}
|
|
}
|
|
|
|
get inMemory(): boolean {
|
|
return this.#memoryOnly
|
|
}
|
|
|
|
#fallBack(e: unknown): void {
|
|
if (!this.#memoryOnly) {
|
|
console.error(
|
|
`hub: audit log is not writable (${e instanceof Error ? e.message : String(e)}); keeping the last ${RING} entries in memory.`,
|
|
)
|
|
}
|
|
this.#memoryOnly = true
|
|
}
|
|
|
|
/** One function is told about every entry as it is written (the webhook uses it for failed writes). */
|
|
onEntry(listener: (entry: AuditEntry) => void): void {
|
|
this.#listener = listener
|
|
}
|
|
|
|
log(input: AuditInput): void {
|
|
const entry: AuditEntry = {
|
|
ts: new Date(this.#now()).toISOString(),
|
|
user: cut(input.user, 64),
|
|
...(input.role === undefined ? {} : { role: input.role }),
|
|
ip: cut(input.ip, 64),
|
|
action: input.action,
|
|
...(input.server === undefined ? {} : { server: input.server }),
|
|
...(input.target === undefined ? {} : { target: cut(input.target, 200) }),
|
|
outcome: input.outcome,
|
|
...(input.detail === undefined ? {} : { detail: cut(input.detail, DETAIL_MAX) }),
|
|
}
|
|
this.#ring.push(entry)
|
|
try {
|
|
this.#listener?.(entry)
|
|
} catch {
|
|
// A listener never stops the log.
|
|
}
|
|
if (this.#ring.length > RING) this.#ring.shift()
|
|
if (this.#memoryOnly) return
|
|
const line = JSON.stringify(entry) + '\n'
|
|
try {
|
|
if (this.#size + Buffer.byteLength(line) > this.#maxBytes) this.#rotate()
|
|
appendFileSync(this.#file, line)
|
|
this.#size += Buffer.byteLength(line)
|
|
} catch (e) {
|
|
this.#fallBack(e)
|
|
}
|
|
}
|
|
|
|
#rotate(): void {
|
|
rmSync(`${this.#file}.1`, { force: true })
|
|
renameSync(this.#file, `${this.#file}.1`)
|
|
this.#size = 0
|
|
}
|
|
|
|
#read(file: string): AuditEntry[] {
|
|
if (!existsSync(file)) return []
|
|
const out: AuditEntry[] = []
|
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
if (line === '') continue
|
|
try {
|
|
out.push(JSON.parse(line) as AuditEntry)
|
|
} catch {
|
|
// A torn line is skipped.
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Newest first. `before` is an ISO time or epoch milliseconds: only older entries are returned. */
|
|
list(limit = 200, before?: string): AuditEntry[] {
|
|
const max = Math.min(Math.max(Math.trunc(limit) || 200, 1), 1000)
|
|
let cutoff = Infinity
|
|
if (before !== undefined && before !== '') {
|
|
const t = /^\d+$/.test(before) ? Number(before) : Date.parse(before)
|
|
if (Number.isFinite(t)) cutoff = t
|
|
}
|
|
const all = this.#memoryOnly ? this.#ring : [...this.#read(`${this.#file}.1`), ...this.#read(this.#file)]
|
|
const result: AuditEntry[] = []
|
|
for (let i = all.length - 1; i >= 0 && result.length < max; i--) {
|
|
if (Date.parse(all[i].ts) < cutoff) result.push(all[i])
|
|
}
|
|
return result
|
|
}
|
|
}
|