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]>
126 lines
3.9 KiB
TypeScript
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
|
|
}
|
|
}
|