Files
technitium-console/hub/audit.ts
T
DenozordecandClaude Opus 5.5 0ef2d53a65 Hub stage 0: own users with roles, login limit, audit log, permission table
Users in HUB_DATA/users.json with scrypt hashes and roles (viewer, operator,
apps-admin, admin), managed by hub/users-cli.ts; HUB_USER/HUB_PASSWORD remain a
bootstrap admin while the file does not exist. Five failed logins in 15 minutes lock
the address or the name for 15 minutes. Logins, logouts and every refused request go
to a JSON Lines audit log that admins read through hub/audit/list. Endpoints are
listed in routes.ts with their kind and capability; writes stay refused for now.
Optional <ID>_ADMIN_TOKEN is read for the coming write stages. The management plan
is in docs/hub-management-plan.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:25:38 +07:00

126 lines
3.9 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
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
}
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)
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
}
}