Files
technitium-console/hub/audit.ts
T
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 <noreply@anthropic.com>
2026-11-01 02:11:19 +07:00

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
}
}