feat: serve plugin http and websockets from one mount instead of ports

This commit is contained in:
LukeGus committed 2026-09-22 16:39:41 -05:00
1 parent c496a093ca
commit 8431570ff7
102 files changed
+2894 -2308

No files matched your search

+23 -12
View File
@@ -1,20 +1,31 @@
import type { Server } from "node:http";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { PORT, shutdown, startHostMetricsService } from "./routes.js";
let httpServer: Server | null = null;
import {
onHostDeleted,
onHostLogin,
onHostUpdated,
shutdown,
startHostMetricsService,
} from "./routes.js";
export async function activate(ctx: PluginContext) {
httpServer = startHostMetricsService();
ctx.log.info(`Host Metrics listening on ${PORT ?? 30005}`);
await startHostMetricsService(ctx.http.router());
// Core used to POST these to this plugin's own port with an internal auth
// token. They are events now: no port, no shared secret, and the
// subscriptions are torn down with the plugin.
ctx.events.on("host.updated", (payload) => {
void onHostUpdated(payload as Parameters<typeof onHostUpdated>[0]);
});
ctx.events.on("host.deleted", (payload) => {
onHostDeleted(payload as Parameters<typeof onHostDeleted>[0]);
});
ctx.events.on("host.login", (payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
});
ctx.log.info("Host Metrics mounted at /plugin-api/host-metrics");
}
export async function deactivate() {
shutdown();
if (httpServer) {
const server = httpServer;
httpServer = null;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
+62 -167
View File
@@ -1,10 +1,7 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { usesIssuedCertificate } from "../../../../src/backend/hosts/issued-certificate-auth.js";
import express from "express";
import express, { type Router } from "express";
import net from "net";
import { createCorsMiddleware } from "../../../../src/backend/utils/cors-config.js";
import { createCompressionMiddleware } from "../../../../src/backend/utils/compression-config.js";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { SSH_ALGORITHMS } from "../../../../src/backend/utils/ssh-algorithms.js";
import {
@@ -116,7 +113,6 @@ import {
requestQueue,
statusPollLimiter,
} from "../../../../src/backend/hosts/metrics-shared/state.js";
import { listenOnServicePort } from "../../../../src/backend/utils/service-listen.js";
const authManager = AuthManager.getInstance();
const permissionManager = PermissionManager.getInstance();
@@ -1075,52 +1071,18 @@ function validateHostId(
next();
}
// Mounted onto the router core hands this plugin at /plugin-api/host-metrics,
// rather than listening on a port of its own. Compression, CORS, cookies and
// body parsing all run in core's stack in front of this, so only the
// cache header and the routes themselves stay here.
export const app = express();
app.set("trust proxy", "loopback");
app.use(createCompressionMiddleware());
app.use(createCorsMiddleware());
app.use(cookieParser());
app.use(express.json({ limit: "1mb" }));
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
// Internal endpoint — only accepts calls from localhost. Registered before
// the auth middleware since it's a service-to-service call authenticated by
// IP + shared secret, not a user JWT.
// 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("../../../../src/backend/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;
};
notifyAutomationInternalEvent("user_login", userId, hostId, {
sshUser,
fromIp,
});
res.json({ ok: true });
});
app.use(authManager.createAuthMiddleware());
// Core authenticates every /plugin-api request before it reaches this app.
const requireAdmin = authManager.createAdminMiddleware();
async function fetchAllHosts(
@@ -2163,124 +2125,68 @@ app.post("/refresh", async (req, res) => {
});
/**
* @openapi
* /host-updated:
* post:
* summary: Start polling for updated host
* description: Starts polling for a specific host after it has been updated.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Host polling started.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Host not found.
* 500:
* description: Failed to start polling.
* Reacts to a host being created or updated somewhere in core.
*
* Used to be POST /host-updated on this plugin's own port. An event now, so
* there is no service-to-service HTTP call and no port. The acting user comes
* with the event because re-reading a host needs their data key.
*/
app.post("/host-updated", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.body;
export async function onHostUpdated(payload: {
hostId: number;
userId: string;
}): Promise<void> {
const { hostId, userId } = payload;
if (!hostId || !userId) return;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
// No data key means that user is not unlocked on this process, so there is
// nothing to decrypt the host with. Polling resumes when they next log in.
if (DataCrypto.getUserDataKey(userId) === null) return;
try {
hostPollCache.invalidate(hostId);
const host = await fetchHostById(hostId, userId);
if (host) {
connectionPool.clearKeyConnections(getPoolKey(host));
if (!host) return;
await pollingManager.startPollingForHost(host);
res.json({ message: "Host polling started" });
} else {
res.status(404).json({ error: "Host not found" });
}
connectionPool.clearKeyConnections(getPoolKey(host));
await pollingManager.startPollingForHost(host);
} catch (error) {
statsLogger.error("Failed to start polling for host", error, {
operation: "host_updated",
hostId,
userId,
});
res.status(500).json({ error: "Failed to start polling" });
}
});
}
/**
* @openapi
* /host-deleted:
* post:
* summary: Stop polling for deleted host
* description: Stops polling for a specific host after it has been deleted.
* tags:
* - Host Metrics
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* hostId:
* type: integer
* responses:
* 200:
* description: Host polling stopped.
* 400:
* description: Invalid hostId.
* 401:
* description: Session expired - please log in again.
* 500:
* description: Failed to stop polling.
*/
app.post("/host-deleted", async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostId } = req.body;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
if (!hostId || typeof hostId !== "number") {
return res.status(400).json({ error: "Invalid hostId" });
}
/** Reacts to a host being deleted. Was POST /host-deleted. */
export function onHostDeleted(payload: { hostId: number }): void {
const { hostId } = payload;
if (!hostId) return;
try {
pollingManager.stopPollingForHost(hostId, true);
res.json({ message: "Host polling stopped" });
} catch (error) {
statsLogger.error("Failed to stop polling for host", error, {
operation: "host_deleted",
hostId,
userId,
});
res.status(500).json({ error: "Failed to stop polling" });
}
});
}
/** Reacts to an SSH login. Was the internal POST /internal/login-alert. */
export function onHostLogin(payload: {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
}): void {
const { hostId, userId, sshUser, fromIp } = payload;
if (!hostId || !userId) return;
notifyAutomationInternalEvent("user_login", userId, hostId, {
sshUser,
fromIp,
});
}
/**
* @openapi
@@ -3147,41 +3053,30 @@ registerManagerRoutes(app, {
},
});
export const PORT = 30005;
let cleanupInterval: NodeJS.Timeout | undefined;
/**
* Starts the Host Metrics HTTP service. Called from the plugin's activate()
* instead of running at module scope, so enabling/disabling the plugin
* actually starts and stops the listener.
* Mounts the Host Metrics routes on the router core provides. Called from the
* plugin's activate(); there is no port to bind any more.
*/
export function startHostMetricsService(): ReturnType<
typeof listenOnServicePort
> {
return listenOnServicePort({
app,
port: PORT,
logger: statsLogger,
serviceName: "metrics",
onListening: async () => {
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
export async function startHostMetricsService(mountOn: Router): Promise<void> {
mountOn.use(app);
cleanupInterval = setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
10 * 60 * 1000,
);
try {
await authManager.initialize();
} catch (err) {
statsLogger.error("Failed to initialize AuthManager", err, {
operation: "auth_init_error",
});
}
cleanupInterval = setInterval(
() => {
authFailureTracker.cleanup();
pollingBackoff.cleanup();
},
});
10 * 60 * 1000,
);
}
/**
@@ -1,94 +0,0 @@
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
// Load the production route without starting metrics polling or SSH services.
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const route = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
ts.isCallExpression(node.expression) &&
node.expression.expression.getText(ast) === "app.post" &&
node.expression.arguments[0]?.getText(ast) === '"/internal/login-alert"',
);
if (!route) throw new Error("Login event route is missing");
const notify = vi.fn();
let handler: (req: unknown, res: unknown) => Promise<void>;
runInNewContext(
ts.transpileModule(route.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
{
app: {
post: (_path: string, callback: typeof handler) => {
handler = callback;
},
},
require: () => ({
SystemCrypto: {
getInstance: () => ({
getInternalAuthToken: async () => "internal-test-token",
}),
},
}),
notifyAutomationInternalEvent: notify,
},
);
const body = {
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
};
const response = () => ({ status: vi.fn().mockReturnThis(), json: vi.fn() });
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", async () => {
const res = response();
await handler(
{
socket: { remoteAddress: "127.0.0.1" },
headers: { "x-internal-auth": "internal-test-token" },
body,
},
res,
);
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(res.json).toHaveBeenCalledWith({ ok: true });
});
it.each([
["192.0.2.9", "internal-test-token"],
["127.0.0.1", "incorrect"],
])(
"rejects untrusted login notifications (%s)",
async (remoteAddress, token) => {
const res = response();
await handler(
{
socket: { remoteAddress },
headers: { "x-internal-auth": token },
body,
},
res,
);
expect(res.status).toHaveBeenCalledWith(403);
expect(notify).not.toHaveBeenCalled();
},
);
@@ -1,27 +0,0 @@
import { describe, it, expect } from "vitest";
import fs from "fs";
import path from "path";
// Regression guard for: the /internal/login-alert route was registered
// after the global JWT auth middleware, so every service-to-service login
// alert got rejected with 401 before the route's own IP+token check ever
// ran. Spinning up the full metrics-service Express app (DB, SSH clients,
// polling managers, etc.) just to hit this one route is out of scope, so
// this asserts the registration order directly against the source instead.
describe("metrics service /internal/login-alert route order", () => {
it("is registered before the global auth middleware", () => {
const source = fs.readFileSync(
path.resolve(__dirname, "../../src/backend/routes.ts"),
"utf8",
);
const routeIndex = source.indexOf('app.post("/internal/login-alert"');
const authMiddlewareIndex = source.indexOf(
"app.use(authManager.createAuthMiddleware())",
);
expect(routeIndex).toBeGreaterThan(-1);
expect(authMiddlewareIndex).toBeGreaterThan(-1);
expect(routeIndex).toBeLessThan(authMiddlewareIndex);
});
});
@@ -0,0 +1,84 @@
/**
* SSH login alerts reach automations.
*
* This used to be POST /internal/login-alert on this plugin's own port,
* guarded by a localhost IP check plus a shared internal token, and the tests
* here covered both of those plus the middleware ordering bug that once made
* every alert 401. A4 replaced the call with an in-process event, so the
* transport, the shared secret and the ordering hazard are all gone; what is
* left to assert is that the handler forwards the right thing and ignores a
* payload it cannot use.
*
* The route module pulls in the whole metrics stack, so this extracts the
* handler from source rather than importing it, the same way the old test did.
*/
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { beforeEach, expect, it, vi } from "vitest";
const source = readFileSync(
new URL("../../src/backend/routes.ts", import.meta.url),
"utf8",
);
const ast = ts.createSourceFile(
"routes.ts",
source,
ts.ScriptTarget.Latest,
true,
);
const declaration = ast.statements.find(
(node) =>
ts.isFunctionDeclaration(node) && node.name?.getText(ast) === "onHostLogin",
);
if (!declaration) throw new Error("onHostLogin is missing");
const notify = vi.fn();
const context: Record<string, unknown> = {
notifyAutomationInternalEvent: notify,
exports: {},
};
runInNewContext(
ts.transpileModule(declaration.getText(ast), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
context,
);
const onHostLogin = context.onHostLogin as (payload: {
hostId: number;
userId: string;
sshUser: string;
fromIp: string;
}) => void;
beforeEach(() => vi.clearAllMocks());
it("delivers successful SSH login details to automations", () => {
onHostLogin({
hostId: 42,
userId: "owner",
sshUser: "alice",
fromIp: "192.0.2.4",
});
expect(notify).toHaveBeenCalledExactlyOnceWith("user_login", "owner", 42, {
sshUser: "alice",
fromIp: "192.0.2.4",
});
});
it.each([
["no host", { hostId: 0, userId: "owner", sshUser: "a", fromIp: "1.2.3.4" }],
["no user", { hostId: 42, userId: "", sshUser: "a", fromIp: "1.2.3.4" }],
])("ignores an unusable payload (%s)", (_label, payload) => {
onHostLogin(payload as Parameters<typeof onHostLogin>[0]);
expect(notify).not.toHaveBeenCalled();
});