mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user