412 lines
11 KiB
JavaScript
412 lines
11 KiB
JavaScript
/**
|
|
* Unified log query: anomalies, agent audit.log, host journalctl (on by default).
|
|
*/
|
|
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 || 'journal').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
|
|
}
|
|
|
|
/** Host journal is on by default; set PEARDATA_JOURNAL=0 to disable. */
|
|
export function isJournalEnabled() {
|
|
const raw = process.env.PEARDATA_JOURNAL
|
|
if (raw == null || String(raw).trim() === '') return true
|
|
const v = String(raw).trim().toLowerCase()
|
|
if (v === '0' || v === 'false' || v === 'no' || v === 'off') return false
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* @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 || 'journal')
|
|
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: 'Journal disabled (PEARDATA_JOURNAL=0). Re-enable and ensure the agent user is in systemd-journal (installer / SupplementaryGroups)',
|
|
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 })
|
|
})
|
|
})
|
|
}
|