System Log
CI / test (push) Successful in 1m4s
Release rolling / release (push) Successful in 6m57s

This commit is contained in:
Raven Scott
2026-07-18 23:05:44 -04:00
parent 7815037737
commit 6ef3afe821
33 changed files with 1360 additions and 32 deletions
+6 -1
View File
@@ -18,11 +18,16 @@ const MUTATING = new Set([
'handshake',
])
function auditPath() {
export function getAuditPath() {
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
return path.join(dir, 'audit.log')
}
/** @deprecated use getAuditPath */
function auditPath() {
return getAuditPath()
}
/**
* @param {string} method
*/
+5
View File
@@ -28,6 +28,7 @@ import { getCollector } from '../services/collector.js'
import { getStore } from '../services/store.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import { computeWeights } from '../services/weights.js'
import { queryLogs } from '../services/logs.js'
import {
listAlerts,
getAlert,
@@ -150,6 +151,10 @@ export function registerMonitorHandlers(session) {
session.respond('queryData', async (args) => store.query(args), { hot: true })
session.respond('getWeights', async (args) => computeWeights(args || {}), { hot: true })
session.respond('queryLogs', async (args) =>
queryLogs({ ...(args || {}), role: session.role })
)
session.respond('getDbInfo', async () => {
const db = getDb()
if (!db) return { enabled: false }
+29 -1
View File
@@ -4,7 +4,13 @@
* Local-agent style GET endpoints for charts/data/contexts/nodes/info/allmetrics.
*/
import os from 'os'
import { APP_NAME, APP_VERSION, PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import {
APP_NAME,
APP_VERSION,
PROTOCOL,
PROTOCOL_VERSION,
Roles,
} from '../../shared/protocol.js'
import {
getAllChartDefs,
getContextIds,
@@ -15,6 +21,7 @@ import { getStore } from '../services/store.js'
import { getCollector } from '../services/collector.js'
import { getAnomalyEngine } from '../services/anomaly.js'
import { computeWeights } from '../services/weights.js'
import { queryLogs } from '../services/logs.js'
import { listAlerts, getAlert } from '../services/alerts.js'
import { getServerPublicKeyHex } from '../core/auth-keys.js'
import { formatAllMetrics } from './formatters.js'
@@ -179,6 +186,27 @@ export async function handleRest(pathname, query) {
}
return json(result)
}
// ── logs (anomaly / audit / journal) ─────────────────────
if (path === '/api/v3/logs' || path === '/api/v2/logs' || path === '/api/v1/logs') {
const result = await queryLogs({
source: query.get('source') || 'anomaly',
q: query.get('q') || query.get('query') || '',
since: query.get('since') || undefined,
until: query.get('until') || undefined,
priority: query.get('priority') || undefined,
unit: query.get('unit') || undefined,
limit: query.get('limit') || undefined,
cursor: query.get('cursor') || undefined,
// REST is localhost-open; treat as admin for audit/journal
role: Roles.admin,
})
if (!result.ok) {
const status = result.code === 'PERMISSION_DENIED' ? 403 : 400
return { status, contentType: 'application/json', body: JSON.stringify(result) }
}
return json(result)
}
if (path === '/api/v3/q' || path === '/api/v2/q') {
const q = (query.get('q') || query.get('query') || '').toLowerCase()
const hits = getAllChartDefs()
+407
View File
@@ -0,0 +1,407 @@
/**
* Unified log query: anomalies, agent audit.log, optional host journalctl.
*/
import { spawn } from 'child_process'
import fs from 'fs'
import os from 'os'
import { Roles, roleAllows } from '../../shared/protocol.js'
import { getAnomalyEngine } from './anomaly.js'
import { getAuditPath } from '../core/audit.js'
const DEFAULT_LIMIT = 200
const MAX_LIMIT = 2000
const JOURNAL_TIMEOUT_MS = 3000
/**
* @param {string} role
* @param {string} source
*/
export function assertLogSourceAllowed(role, source) {
const src = String(source || 'anomaly').toLowerCase()
if (src === 'anomaly') return { ok: true, source: src }
if (src === 'audit' || src === 'journal') {
if (!roleAllows(role || Roles.viewer, Roles.admin)) {
return {
ok: false,
source: src,
error: 'PERMISSION_DENIED',
code: 'PERMISSION_DENIED',
hint: 'Audit and journal sources require admin role',
}
}
return { ok: true, source: src }
}
return { ok: false, source: src, error: `unknown source: ${src}` }
}
/**
* @param {unknown} n
* @param {number} fallback
*/
export function clampLimit(n, fallback = DEFAULT_LIMIT) {
const v = Number(n)
if (!Number.isFinite(v) || v < 1) return fallback
return Math.min(MAX_LIMIT, Math.floor(v))
}
/**
* @param {string|number|undefined|null} v
* @returns {number|null} unix ms
*/
export function parseTimeBound(v) {
if (v == null || v === '') return null
if (typeof v === 'number' && Number.isFinite(v)) {
// seconds vs ms heuristic
return v < 1e12 ? Math.floor(v * 1000) : Math.floor(v)
}
const s = String(v).trim()
if (!s) return null
// relative like -3600 or -1h
const rel = s.match(/^(-?\d+)([smhd])?$/i)
if (rel) {
const n = Number(rel[1])
const unit = (rel[2] || 's').toLowerCase()
const mult =
unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : unit === 'd' ? 86_400_000 : 1000
return Date.now() + n * mult
}
const ms = Date.parse(s)
return Number.isFinite(ms) ? ms : null
}
/**
* @param {{ q?: string, since?: number|null, until?: number|null }} filters
* @param {{ ts: number, message: string, unit?: string, severity?: string }} row
*/
export function matchesFilters(filters, row) {
const q = String(filters.q || '')
.trim()
.toLowerCase()
if (q) {
const hay = `${row.message || ''} ${row.unit || ''} ${row.severity || ''}`.toLowerCase()
if (!hay.includes(q)) return false
}
if (filters.since != null && row.ts < filters.since) return false
if (filters.until != null && row.ts > filters.until) return false
return true
}
/**
* @param {object} ev
* @param {number} index
*/
export function normalizeAnomaly(ev, index = 0) {
const ts = Number(ev.ts) || Date.now()
return {
id: `anomaly:${ts}:${ev.chart || index}`,
ts,
source: 'anomaly',
severity: String(ev.severity || (ev.cleared ? 'ok' : 'warning')),
unit: String(ev.chart || ev.context || ''),
message: String(ev.message || ''),
fields: {
chart: ev.chart || null,
context: ev.context || null,
score: ev.score ?? null,
cleared: Boolean(ev.cleared),
},
}
}
/**
* @param {object} entry
* @param {number} index
*/
export function normalizeAudit(entry, index = 0) {
const ts = entry.ts ? Date.parse(entry.ts) || Date.now() : Date.now()
const ok = entry.ok !== false
const msg = [
entry.method || 'rpc',
ok ? 'ok' : 'fail',
entry.peerId ? `peer=${entry.peerId}` : '',
entry.role ? `role=${entry.role}` : '',
entry.error ? `error=${entry.error}` : '',
]
.filter(Boolean)
.join(' ')
return {
id: `audit:${ts}:${index}`,
ts,
source: 'audit',
severity: ok ? 'info' : 'warning',
unit: String(entry.method || 'rpc'),
message: msg,
fields: {
peerId: entry.peerId || null,
role: entry.role || null,
ok,
error: entry.error || null,
},
}
}
/**
* @param {object} j
* @param {number} index
*/
export function normalizeJournal(j, index = 0) {
const usec = Number(j.__REALTIME_TIMESTAMP || j.TIMESTAMP || 0)
const ts = usec > 1e15 ? Math.floor(usec / 1000) : usec > 1e12 ? Math.floor(usec) : Date.now()
const priority = j.PRIORITY != null ? String(j.PRIORITY) : ''
const sevMap = {
0: 'emerg',
1: 'alert',
2: 'crit',
3: 'err',
4: 'warning',
5: 'notice',
6: 'info',
7: 'debug',
}
const severity = sevMap[priority] || priority || 'info'
const unit = String(j._SYSTEMD_UNIT || j.SYSLOG_IDENTIFIER || j._COMM || '')
const message = String(j.MESSAGE || j.message || '')
return {
id: `journal:${j.__CURSOR || ts}:${index}`,
ts,
source: 'journal',
severity,
unit,
message,
fields: {
priority: priority || null,
cursor: j.__CURSOR || null,
pid: j._PID || null,
},
}
}
/**
* Build journalctl argv (no shell). Exported for tests.
* @param {{
* limit?: number,
* sinceMs?: number|null,
* untilMs?: number|null,
* priority?: string,
* unit?: string,
* q?: string,
* }} opts
*/
export function buildJournalArgv(opts = {}) {
const limit = clampLimit(opts.limit)
const args = ['--output=json', '--no-pager', '-n', String(limit)]
if (opts.sinceMs != null) {
args.push('--since', new Date(opts.sinceMs).toISOString())
}
if (opts.untilMs != null) {
args.push('--until', new Date(opts.untilMs).toISOString())
}
if (opts.priority != null && opts.priority !== '') {
args.push('-p', String(opts.priority))
}
if (opts.unit) {
args.push('-u', String(opts.unit))
}
const q = String(opts.q || '').trim()
if (q) {
args.push('--grep', q)
}
return args
}
export function isJournalEnabled() {
const v = String(process.env.PEARDATA_JOURNAL || '').trim().toLowerCase()
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
}
/**
* @param {string} file
* @param {number} maxBytes
*/
export function readAuditFileLines(file, maxBytes = 512_000) {
if (!fs.existsSync(file)) return []
const stat = fs.statSync(file)
const start = Math.max(0, stat.size - maxBytes)
const buf = Buffer.alloc(stat.size - start)
const fd = fs.openSync(file, 'r')
try {
fs.readSync(fd, buf, 0, buf.length, start)
} finally {
fs.closeSync(fd)
}
const text = buf.toString('utf8')
const lines = text.split('\n').filter(Boolean)
// If we started mid-line, drop first fragment
if (start > 0 && lines.length) lines.shift()
/** @type {object[]} */
const parsed = []
for (const line of lines) {
try {
parsed.push(JSON.parse(line))
} catch {
// skip corrupt
}
}
return parsed
}
/**
* @param {object} args
* @param {{ anomalies?: { listRecent: (n: number) => object[] }, spawnJournal?: Function }} [deps]
*/
export async function queryLogs(args = {}, deps = {}) {
const gate = assertLogSourceAllowed(args.role || Roles.viewer, args.source || 'anomaly')
if (!gate.ok) {
return {
ok: false,
error: gate.error,
code: gate.code || undefined,
hint: gate.hint || undefined,
source: gate.source,
entries: [],
}
}
const source = gate.source
const limit = clampLimit(args.limit)
const cursor = Math.max(0, Math.floor(Number(args.cursor) || 0))
const since = parseTimeBound(args.since)
const until = parseTimeBound(args.until)
const q = String(args.q || '').trim()
const filters = { q, since, until }
if (source === 'anomaly') {
const engine = deps.anomalies || getAnomalyEngine()
const raw = engine.listRecent(Math.min(MAX_LIMIT, limit + cursor + 50))
const all = raw
.map((ev, i) => normalizeAnomaly(ev, i))
.filter((row) => matchesFilters(filters, row))
const slice = all.slice(cursor, cursor + limit)
return {
ok: true,
source,
entries: slice,
nextCursor: cursor + slice.length < all.length ? cursor + slice.length : null,
stats: { matched: all.length, returned: slice.length },
}
}
if (source === 'audit') {
const file = args.auditPath || getAuditPath()
const raw = readAuditFileLines(file).reverse()
const all = raw
.map((entry, i) => normalizeAudit(entry, i))
.filter((row) => matchesFilters(filters, row))
const slice = all.slice(cursor, cursor + limit)
return {
ok: true,
source,
entries: slice,
nextCursor: cursor + slice.length < all.length ? cursor + slice.length : null,
stats: { matched: all.length, returned: slice.length },
}
}
// journal
if (!isJournalEnabled()) {
return {
ok: false,
source,
error: 'journal_disabled',
hint: 'Set PEARDATA_JOURNAL=1 and ensure the agent user can read the journal (e.g. SupplementaryGroups=systemd-journal)',
entries: [],
}
}
if (os.platform() !== 'linux') {
return {
ok: false,
source,
error: 'unsupported',
hint: 'Host journal is only available on Linux agents',
entries: [],
}
}
const argv = buildJournalArgv({
limit,
sinceMs: since,
untilMs: until,
priority: args.priority,
unit: args.unit,
q,
})
const spawnFn = deps.spawnJournal || spawnJournalctl
try {
const { stdout, stderr, code } = await spawnFn(argv)
if (code !== 0 && !stdout.trim()) {
return {
ok: false,
source,
error: 'journalctl_failed',
hint: stderr.trim() || `journalctl exited ${code}`,
entries: [],
}
}
const entries = []
const lines = stdout.split('\n').filter(Boolean)
for (let i = 0; i < lines.length; i++) {
try {
const j = JSON.parse(lines[i])
const row = normalizeJournal(j, i)
// --grep already applied when q set; still apply since/until if journal ignored them
if (matchesFilters({ q: '', since, until }, row)) entries.push(row)
} catch {
// skip
}
}
// journalctl returns oldest-first typically; present newest first
entries.reverse()
return {
ok: true,
source,
entries: entries.slice(0, limit),
nextCursor: null,
stats: { matched: entries.length, returned: Math.min(limit, entries.length) },
}
} catch (err) {
return {
ok: false,
source,
error: 'journalctl_failed',
hint: err?.message || String(err),
entries: [],
}
}
}
/**
* @param {string[]} argv
* @returns {Promise<{ stdout: string, stderr: string, code: number }>}
*/
function spawnJournalctl(argv) {
return new Promise((resolve, reject) => {
const child = spawn('journalctl', argv, {
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`journalctl timed out after ${JOURNAL_TIMEOUT_MS}ms`))
}, JOURNAL_TIMEOUT_MS)
child.stdout.on('data', (d) => {
stdout += d.toString('utf8')
})
child.stderr.on('data', (d) => {
stderr += d.toString('utf8')
})
child.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
resolve({ stdout, stderr, code: code ?? 1 })
})
})
}