diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index 8f71f1d9..b4baddd3 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -857,6 +857,36 @@ app.use((_req, res, next) => { next(); }); +// Internal endpoint — only accepts calls from localhost. +// Used by the main backend to notify the metrics service of SSH login events. +app.post("/internal/login-alert", async (req, res) => { + const remoteIp = req.socket.remoteAddress; + if ( + remoteIp !== "127.0.0.1" && + remoteIp !== "::1" && + remoteIp !== "::ffff:127.0.0.1" + ) { + return res.status(403).json({ error: "Forbidden" }); + } + const systemCrypto = (await import("../../utils/system-crypto.js")) + .SystemCrypto; + const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); + const token = req.headers["x-internal-auth"]; + if (!token || token !== expectedToken) { + return res.status(403).json({ error: "Forbidden" }); + } + const { hostId, userId, sshUser, fromIp } = req.body as { + hostId: number; + userId: string; + sshUser: string; + fromIp: string; + }; + AlertEngine.getInstance() + .evaluateUserLogin(hostId, userId, sshUser, fromIp) + .catch(() => {}); + res.json({ ok: true }); +}); + app.use(authManager.createAuthMiddleware()); const requireAdmin = authManager.createAdminMiddleware(); @@ -2803,36 +2833,6 @@ registerManagerRoutes(app, { }, }); -// Internal endpoint — only accepts calls from localhost. -// Used by the main backend to notify the metrics service of SSH login events. -app.post("/internal/login-alert", async (req, res) => { - const remoteIp = req.socket.remoteAddress; - if ( - remoteIp !== "127.0.0.1" && - remoteIp !== "::1" && - remoteIp !== "::ffff:127.0.0.1" - ) { - return res.status(403).json({ error: "Forbidden" }); - } - const systemCrypto = (await import("../../utils/system-crypto.js")) - .SystemCrypto; - const expectedToken = await systemCrypto.getInstance().getInternalAuthToken(); - const token = req.headers["x-internal-auth"]; - if (!token || token !== expectedToken) { - return res.status(403).json({ error: "Forbidden" }); - } - const { hostId, userId, sshUser, fromIp } = req.body as { - hostId: number; - userId: string; - sshUser: string; - fromIp: string; - }; - AlertEngine.getInstance() - .evaluateUserLogin(hostId, userId, sshUser, fromIp) - .catch(() => {}); - res.json({ ok: true }); -}); - process.on("SIGINT", () => { pollingManager.destroy(); connectionPool.destroy(); diff --git a/src/backend/tests/utils/alert-trigger.test.ts b/src/backend/tests/utils/alert-trigger.test.ts new file mode 100644 index 00000000..d4ed63c1 --- /dev/null +++ b/src/backend/tests/utils/alert-trigger.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { triggerLoginAlert } from "../../utils/alert-trigger.js"; +import { SystemCrypto } from "../../utils/system-crypto.js"; +import { sshLogger } from "../../utils/logger.js"; + +describe("triggerLoginAlert", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports a rejected metrics-service request", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"Missing authentication token"}', { + status: 401, + }), + ); + const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + + await triggerLoginAlert(7, "user-1", "root", "192.0.2.1"); + + expect(warn).toHaveBeenCalledWith( + "Failed to trigger login alert", + expect.objectContaining({ + operation: "login_alert_trigger_error", + hostId: 7, + error: + 'Metrics service returned 401: {"error":"Missing authentication token"}', + }), + ); + }); + + it("does not log a warning when the metrics service accepts the event", async () => { + vi.spyOn(SystemCrypto, "getInstance").mockReturnValue({ + getInternalAuthToken: vi.fn().mockResolvedValue("internal-token"), + } as never); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"ok":true}', { status: 200 }), + ); + const warn = vi.spyOn(sshLogger, "warn").mockImplementation(() => {}); + + await triggerLoginAlert(7, "user-1", "root", "192.0.2.1"); + + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/utils/alert-trigger.ts b/src/backend/utils/alert-trigger.ts index c716465b..2098bec0 100644 --- a/src/backend/utils/alert-trigger.ts +++ b/src/backend/utils/alert-trigger.ts @@ -11,14 +11,23 @@ export async function triggerLoginAlert( ): Promise { try { const token = await SystemCrypto.getInstance().getInternalAuthToken(); - await fetch(`${METRICS_SERVICE_URL}/internal/login-alert`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-internal-auth": token, + const response = await fetch( + `${METRICS_SERVICE_URL}/internal/login-alert`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-internal-auth": token, + }, + body: JSON.stringify({ hostId, userId, sshUser, fromIp }), }, - body: JSON.stringify({ hostId, userId, sshUser, fromIp }), - }); + ); + if (!response.ok) { + const details = await response.text(); + throw new Error( + `Metrics service returned ${response.status}${details ? `: ${details}` : ""}`, + ); + } } catch (err) { sshLogger.warn("Failed to trigger login alert", { operation: "login_alert_trigger_error",