feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

@@ -1,7 +1,9 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface CronEntry {
@@ -78,14 +80,12 @@ export function buildApplyCrontabCommand(body: string): string {
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
}
export function registerCronRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerCronRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(runOnHost, "connect", "cron_list", async (client) => {
managerHandler(deps, "connect", "cron_list", async (client) => {
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
return { entries: parseCrontab(stdout) };
}),
@@ -95,7 +95,7 @@ export function registerCronRoutes(
"/host-metrics/managers/cron/:id",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"cron_replace",
async (client, _host, req) => {
@@ -1,15 +1,14 @@
import type { Express } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execElevated, detectPlatform } from "@termix/plugin-sdk/host-commands";
import {
isValidPort,
isValidIpProtocol,
isValidFirewallTarget,
type IpProtocol,
type FirewallTarget,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface FirewallRuleSpec {
@@ -45,13 +44,14 @@ export function buildNftRuleCommand(
}
export function registerFirewallRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/firewall/:id",
validateHostId,
managerHandler(runOnHost, "connect", "firewall_read", async (client) => {
managerHandler(deps, "connect", "firewall_read", async (client) => {
return await collectFirewallMetrics(client);
}),
);
@@ -60,7 +60,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/rule",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_rule",
async (client, host, req) => {
@@ -105,7 +105,7 @@ export function registerFirewallRoutes(
"/host-metrics/managers/firewall/:id/persist",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"firewall_persist",
async (client, host) => {
@@ -1,13 +1,13 @@
import type { Express } from "express";
import {
execCommand,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { isValidPort } from "./validation.js";
import type { Router } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { createCurrentHostHealthRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { shellSingleQuote } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isValidPort } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
import type { HostMetricsRepository } from "../repository.js";
export interface HealthCheck {
id: string;
@@ -111,24 +111,12 @@ async function runChecks(
const HISTORY_KEEP = 500;
function recordHistory(
userId: string,
hostId: number,
results: HealthResult[],
): Promise<void> {
return createCurrentHostHealthRepository()
.recordHistory(userId, hostId, results, HISTORY_KEEP)
.then(() => undefined);
}
async function loadChecks(
repository: HostMetricsRepository,
userId: string,
hostId: number,
): Promise<HealthCheck[]> {
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
userId,
hostId,
);
const row = await repository.findChecks(userId, hostId);
if (!row?.checks) return [];
try {
const parsed = JSON.parse(row.checks);
@@ -139,60 +127,50 @@ async function loadChecks(
}
export function registerHealthRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, repository } = deps;
const record = async (
userId: string,
hostId: number,
results: HealthResult[],
) => {
if (!results.length) return;
await repository.recordHealth(userId, hostId, results, HISTORY_KEEP);
for (const result of results) {
deps.onHealthCheck({
hostId,
userId,
checkId: result.checkId,
ok: result.ok,
detail: result.detail ?? undefined,
});
}
};
app.get(
"/host-metrics/managers/health/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_get",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
const history = (
await createCurrentHostHealthRepository().listHistory(
userId,
host.id,
200,
)
).map((row) => ({
checkId: row.checkId,
ts: row.ts,
ok: row.ok,
latencyMs: row.latencyMs,
detail: row.detail,
}));
return { checks, results, history };
},
),
managerHandler(deps, "connect", "health_get", async (client, host) => {
const userId = host.actorId;
const checks = await loadChecks(repository, userId, host.id);
const results = checks.length ? await runChecks(client, checks) : [];
await record(userId, host.id, results);
const history = await repository.listHealth(userId, host.id, 200);
return { checks, results, history };
}),
);
app.post(
"/host-metrics/managers/health/:id/config",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"health_config",
async (_client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const userId = host.actorId;
const { checks, intervalSeconds } = req.body as {
checks?: unknown;
intervalSeconds?: number;
@@ -206,13 +184,11 @@ export function registerHealthRoutes(
intervalSeconds <= 86400
? Math.round(intervalSeconds)
: 300;
const now = new Date().toISOString();
await createCurrentHostHealthRepository().upsertChecks(
await repository.saveChecks(
userId,
host.id,
JSON.stringify(checks),
interval,
now,
);
return { success: true };
},
@@ -222,28 +198,11 @@ export function registerHealthRoutes(
app.post(
"/host-metrics/managers/health/:id/run",
validateHostId,
managerHandler(
runOnHost,
"connect",
"health_run",
async (client, host, req) => {
const userId = (req as AuthenticatedRequest).userId;
const checks = await loadChecks(userId, host.id);
const results = await runChecks(client, checks);
if (results.length) {
await recordHistory(userId, host.id, results);
for (const r of results) {
notifyAutomationHealthCheck(
host.id,
userId,
r.checkId,
r.ok,
r.detail ?? undefined,
);
}
}
return { results };
},
),
managerHandler(deps, "connect", "health_run", async (client, host) => {
const checks = await loadChecks(repository, host.actorId, host.id);
const results = await runChecks(client, checks);
await record(host.actorId, host.id, results);
return { results };
}),
);
}
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import { detectPlatform } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { registerServiceRoutes } from "./services.js";
@@ -19,10 +19,10 @@ import { registerWireGuardRoutes } from "./wireguard.js";
* prefix on the stats app. All routes are on-demand (not polled).
*/
export function registerManagerRoutes(
app: Express,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId, runOnHost } = deps;
const { validateHostId } = deps;
/**
* @openapi
@@ -44,7 +44,7 @@ export function registerManagerRoutes(
app.get(
"/host-metrics/platform/:id",
validateHostId,
managerHandler(runOnHost, "connect", "platform_detect", (client) =>
managerHandler(deps, "connect", "platform_detect", (client) =>
detectPlatform(client),
),
);
@@ -1,14 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
} from "@termix/plugin-sdk/host-commands";
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isAllowedPath,
isValidSystemdUnit,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
/** Directories from which arbitrary log files may be tailed. */
@@ -44,14 +41,12 @@ export function buildJournalCommand(unit: string, lines: number): string {
return `journalctl -u ${shellSingleQuote(unit)} -n ${lines} --no-pager`;
}
export function registerLogRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerLogRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/logs/:id/files",
validateHostId,
managerHandler(runOnHost, "connect", "logs_list", async (client) => {
managerHandler(deps, "connect", "logs_list", async (client) => {
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
const found = stdout
.split("\n")
@@ -65,33 +60,28 @@ export function registerLogRoutes(
app.get(
"/host-metrics/managers/logs/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"logs_tail",
async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
managerHandler(deps, "connect", "logs_tail", async (client, host, req) => {
const path = req.query.path as string | undefined;
const unit = req.query.unit as string | undefined;
const lines = clampLines(req.query.lines);
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
let cmd: string;
if (unit) {
if (!isValidSystemdUnit(unit))
throw new ManagerInputError("Invalid unit");
cmd = buildJournalCommand(unit, lines);
} else if (path) {
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
throw new ManagerInputError("Path not allowed");
}
cmd = buildTailCommand(path, lines);
} else {
throw new ManagerInputError("Provide a path or unit");
}
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
},
),
// Try unprivileged; many logs need root (auth.log, etc.).
const result = await execElevated(client, cmd, host.sudoPassword);
return { content: result.stdout, lines };
}),
);
}
@@ -1,25 +1,26 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { isValidPackageName } from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
import {
execCommand,
execElevated,
detectPlatform,
buildListUpgradableCommand,
buildPackageActionCommand,
parseUpgradable,
type PackageAction,
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
} from "@termix/plugin-sdk/host-commands";
import { isValidPackageName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export function registerPackageRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/packages/:id",
validateHostId,
managerHandler(runOnHost, "connect", "packages_list", async (client) => {
managerHandler(deps, "connect", "packages_list", async (client) => {
const platform = await detectPlatform(client);
const cmd = buildListUpgradableCommand(platform.pkg);
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
@@ -35,7 +36,7 @@ export function registerPackageRoutes(
"/host-metrics/managers/packages/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"packages_action",
async (client, host, req) => {
@@ -1,12 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidPid,
isValidSignal,
type Signal,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface ProcessRow {
@@ -54,9 +49,10 @@ export function buildKillCommand(pid: number, signal: Signal): string {
}
export function registerProcessRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/processes/{id}:
@@ -74,7 +70,7 @@ export function registerProcessRoutes(
app.get(
"/host-metrics/managers/processes/:id",
validateHostId,
managerHandler(runOnHost, "connect", "processes_list", async (client) => {
managerHandler(deps, "connect", "processes_list", async (client) => {
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
return { processes: parseProcessList(stdout) };
}),
@@ -109,7 +105,7 @@ export function registerProcessRoutes(
"/host-metrics/managers/processes/:id/signal",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"processes_signal",
async (client, host, req) => {
@@ -1,11 +1,10 @@
import { getErrorMessage } from "../../../../../src/backend/utils/error-message.js";
import { ElevationError } from "@termix/plugin-sdk/host-commands";
import type { Request, Response } from "express";
import type { Client } from "ssh2";
import type { AuthenticatedRequest } from "../../../../../src/types/index.js";
import { statsLogger } from "../../../../../src/backend/utils/logger.js";
import { ElevationError } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import { errorMessage } from "../util.js";
import type { MetricsLogger } from "../log.js";
import type { ManagerHost, RunOnHost } from "./types.js";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
export class AccessDeniedError extends Error {
constructor(message = "No access to this host") {
@@ -26,16 +25,15 @@ export class ManagerInputError extends Error {
* given access level, and maps known errors to clean HTTP responses.
*/
export function managerHandler(
runOnHost: RunOnHost,
level: HostAction,
{ runOnHost, log }: { runOnHost: RunOnHost; log: MetricsLogger },
level: PluginHostShareLevel,
operation: string,
fn: (client: Client, host: ManagerHost, req: Request) => Promise<unknown>,
) {
return async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
const result = await runOnHost(hostId, userId, level, (client, host) =>
const result = await runOnHost(hostId, level, (client, host) =>
fn(client, host, req),
);
return res.json(result);
@@ -49,13 +47,13 @@ export function managerHandler(
if (error instanceof ElevationError) {
return res.status(403).json({ error: error.message, code: error.code });
}
statsLogger.error(`Manager operation failed: ${operation}`, {
log.error(`Manager operation failed: ${operation}`, {
operation,
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res.status(500).json({
error: getErrorMessage(error, "Operation failed"),
error: errorMessage(error, "Operation failed"),
});
}
};
@@ -1,12 +1,11 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidSystemdUnit,
isValidServiceAction,
type ServiceAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemdService {
@@ -50,9 +49,10 @@ export function buildServiceActionCommand(
}
export function registerServiceRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/services/{id}:
@@ -70,7 +70,7 @@ export function registerServiceRoutes(
app.get(
"/host-metrics/managers/services/:id",
validateHostId,
managerHandler(runOnHost, "connect", "services_list", async (client) => {
managerHandler(deps, "connect", "services_list", async (client) => {
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
return { services: parseServiceList(stdout) };
}),
@@ -105,7 +105,7 @@ export function registerServiceRoutes(
"/host-metrics/managers/services/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"services_action",
async (client, host, req) => {
@@ -1,5 +1,5 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import type { Router } from "express";
import { managerHandler } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
@@ -106,13 +106,14 @@ export function parseDfMounts(output: string): MountUsage[] {
}
export function registerSimpleReadRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/top-memory/:id",
validateHostId,
managerHandler(runOnHost, "connect", "top_memory", async (client) => {
managerHandler(deps, "connect", "top_memory", async (client) => {
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
return { processes: parseTopMemory(stdout) };
}),
@@ -121,7 +122,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/timers/:id",
validateHostId,
managerHandler(runOnHost, "connect", "systemd_timers", async (client) => {
managerHandler(deps, "connect", "systemd_timers", async (client) => {
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
return { timers: parseTimers(stdout) };
}),
@@ -130,7 +131,7 @@ export function registerSimpleReadRoutes(
app.get(
"/host-metrics/managers/disk-breakdown/:id",
validateHostId,
managerHandler(runOnHost, "connect", "disk_breakdown", async (client) => {
managerHandler(deps, "connect", "disk_breakdown", async (client) => {
const { stdout } = await execCommand(client, DF_CMD, 15000);
return { mounts: parseDfMounts(stdout) };
}),
+87 -104
View File
@@ -1,16 +1,16 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
execCommand,
execElevated,
shellSingleQuote,
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
detectPlatform,
} from "@termix/plugin-sdk/host-commands";
import {
isValidDomain,
isValidDnsProvider,
isAllowedPath,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import { detectPlatform } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export type AcmeClient = "certbot" | "acme.sh";
@@ -149,14 +149,12 @@ export function buildRevokeCommand(client: AcmeClient, name: string): string {
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
}
export function registerSslRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/ssl/:id",
validateHostId,
managerHandler(runOnHost, "connect", "ssl_list", async (client, host) => {
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
const platform = await detectPlatform(client);
const certs: CertInfo[] = [];
if (platform.hasCertbot) {
@@ -188,78 +186,68 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/issue",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_issue",
async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
const body = req.body as Partial<IssueRequest>;
if (body.client !== "certbot" && body.client !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!Array.isArray(body.domains) || body.domains.length === 0) {
throw new ManagerInputError("At least one domain is required");
}
for (const d of body.domains) {
if (!isValidDomain(d))
throw new ManagerInputError(`Invalid domain: ${d}`);
}
const challenge = body.challenge;
if (
challenge !== "http-standalone" &&
challenge !== "http-webroot" &&
challenge !== "dns"
) {
throw new ManagerInputError("Invalid challenge type");
}
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
throw new ManagerInputError("Invalid DNS provider");
}
if (
challenge === "http-webroot" &&
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
) {
throw new ManagerInputError("Invalid or disallowed webroot path");
}
const cmd = buildIssueCommand(body as IssueRequest);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
app.post(
"/host-metrics/managers/ssl/:id/renew",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_renew",
async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
const { client: acmeClient, dryRun } = req.body as {
client?: AcmeClient;
dryRun?: boolean;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
const cmd = buildRenewCommand(acmeClient, !!dryRun);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 300000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
/**
@@ -290,31 +278,26 @@ export function registerSslRoutes(
app.post(
"/host-metrics/managers/ssl/:id/revoke",
validateHostId,
managerHandler(
runOnHost,
"connect",
"ssl_revoke",
async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
},
),
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
const { client: acmeClient, name } = req.body as {
client?: AcmeClient;
name?: string;
};
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
throw new ManagerInputError("Invalid ACME client");
}
if (!isValidCertName(name)) {
throw new ManagerInputError("Invalid certificate name");
}
const cmd = buildRevokeCommand(acmeClient, name);
const result = await execElevated(client, cmd, host.sudoPassword, {
forceSudo: true,
timeoutMs: 120000,
});
return {
success: result.code === 0,
output: (result.stdout || result.stderr).slice(-8000),
};
}),
);
}
@@ -1,28 +1,43 @@
import type { Client } from "ssh2";
import type { RequestHandler } from "express";
import type { HostAction } from "../../../../../src/backend/utils/permission-manager.js";
import type { PluginHostShareLevel } from "@termix/plugin-sdk/backend";
import type { HostMetricsRepository } from "../repository.js";
import type { MetricsLogger } from "../log.js";
/** Minimal host shape managers need (includes the decrypted sudo password). */
export interface ManagerHost {
id: number;
userId: string;
/** The user making the request, who may not own the host. */
actorId: string;
sudoPassword?: string;
enableDocker?: boolean;
}
/**
* Runs `fn` against a pooled SSH connection for the host, after verifying the
* user has at least `level` access. Resolves the host (with sudoPassword) so
* managers can elevate. Rejects with an access error if not permitted.
* acting user has at least `level` access. Resolves the host (with
* sudoPassword) so managers can elevate. Rejects with an access error if not
* permitted.
*/
export type RunOnHost = <T>(
hostId: number,
userId: string,
level: HostAction,
level: PluginHostShareLevel,
fn: (client: Client, host: ManagerHost) => Promise<T>,
) => Promise<T>;
export interface HealthCheckEvent {
hostId: number;
userId: string;
checkId: string;
ok: boolean;
detail?: string;
}
export interface ManagerRoutesDeps {
validateHostId: RequestHandler;
runOnHost: RunOnHost;
log: MetricsLogger;
repository: HostMetricsRepository;
onHealthCheck: (event: HealthCheckEvent) => void;
}
@@ -1,11 +1,7 @@
import type { Express } from "express";
import { execCommand } from "../../../../../src/backend/hosts/metrics-shared/common-utils.js";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
import { isValidUsername, isValidGroupName } from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import {
isValidUsername,
isValidGroupName,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface SystemUser {
@@ -75,14 +71,12 @@ export function parseSudoers(output: string): string[] {
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
export function registerUserRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
): void {
export function registerUserRoutes(app: Router, deps: ManagerRoutesDeps): void {
const { validateHostId } = deps;
app.get(
"/host-metrics/managers/users/:id",
validateHostId,
managerHandler(runOnHost, "connect", "users_list", async (client) => {
managerHandler(deps, "connect", "users_list", async (client) => {
const [passwd, groups, sudoers] = await Promise.all([
execCommand(client, READ_USERS_CMD, 15000),
execCommand(client, READ_GROUPS_CMD, 15000),
@@ -100,7 +94,7 @@ export function registerUserRoutes(
"/host-metrics/managers/users/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"users_action",
async (client, host, req) => {
@@ -0,0 +1,127 @@
/**
* Strict allowlist validators for every dynamic value that reaches a shell
* command. Managers MUST validate inputs through these before interpolation;
* never pass raw user text to the shell.
*/
const SYSTEMD_UNIT_RE =
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
const DOMAIN_RE =
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
const PROVIDER_RE = /^[a-z0-9_]+$/;
export function isValidSystemdUnit(unit: unknown): unit is string {
return (
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
);
}
export function isValidPid(pid: unknown): pid is number {
const n = typeof pid === "string" ? Number(pid) : pid;
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
}
export function isValidPort(port: unknown): port is number {
const n = typeof port === "string" ? Number(port) : port;
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
}
export function isValidPackageName(pkg: unknown): pkg is string {
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
}
export function isValidUsername(name: unknown): name is string {
return (
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
);
}
export function isValidGroupName(name: unknown): name is string {
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
}
export function isValidDomain(domain: unknown): domain is string {
return (
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
);
}
export function isValidDnsProvider(provider: unknown): provider is string {
return (
typeof provider === "string" &&
provider.length <= 64 &&
PROVIDER_RE.test(provider)
);
}
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
export function isValidSignal(sig: unknown): sig is Signal {
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
}
export type ServiceAction =
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
const SERVICE_ACTIONS: ServiceAction[] = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
];
export function isValidServiceAction(a: unknown): a is ServiceAction {
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
}
export type IpProtocol = "tcp" | "udp";
export function isValidIpProtocol(p: unknown): p is IpProtocol {
return p === "tcp" || p === "udp";
}
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
}
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
export function isValidWireGuardInterface(name: unknown): name is string {
return typeof name === "string" && WG_IFACE_RE.test(name);
}
export type WireGuardAction = "up" | "down";
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
}
export type TailscaleAction = "up" | "down";
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
}
/**
* Validate an absolute file path against an allowlist of permitted prefixes and
* reject traversal. Used by the log viewer (e.g. only under /var/log).
*/
export function isAllowedPath(
path: unknown,
allowedPrefixes: string[],
): path is string {
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
return false;
}
if (!path.startsWith("/")) return false;
if (path.includes("\0")) return false;
if (path.split("/").some((seg) => seg === "..")) return false;
return allowedPrefixes.some(
(prefix) =>
path === prefix ||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
);
}
@@ -1,11 +1,11 @@
import type { Express } from "express";
import { execElevated } from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
import { execElevated } from "@termix/plugin-sdk/host-commands";
import {
isValidWireGuardInterface,
isValidWireGuardAction,
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
} from "./validation.js";
import type { Router } from "express";
import { managerHandler, ManagerInputError } from "./route-helpers.js";
import type { ManagerRoutesDeps } from "./types.js";
export interface WireGuardPeer {
publicKey: string;
@@ -117,9 +117,10 @@ export function parseWireGuardData(output: string): WireGuardData {
}
export function registerWireGuardRoutes(
app: Express,
{ validateHostId, runOnHost }: ManagerRoutesDeps,
app: Router,
deps: ManagerRoutesDeps,
): void {
const { validateHostId } = deps;
/**
* @openapi
* /host-metrics/managers/wireguard/{id}:
@@ -140,23 +141,13 @@ export function registerWireGuardRoutes(
app.get(
"/host-metrics/managers/wireguard/:id",
validateHostId,
managerHandler(
runOnHost,
"connect",
"wireguard_read",
async (client, host) => {
const result = await execElevated(
client,
PROBE_CMD,
host.sudoPassword,
{
forceSudo: false,
timeoutMs: 15000,
},
);
return parseWireGuardData(result.stdout);
},
),
managerHandler(deps, "connect", "wireguard_read", async (client, host) => {
const result = await execElevated(client, PROBE_CMD, host.sudoPassword, {
forceSudo: false,
timeoutMs: 15000,
});
return parseWireGuardData(result.stdout);
}),
);
/**
@@ -192,7 +183,7 @@ export function registerWireGuardRoutes(
"/host-metrics/managers/wireguard/:id/action",
validateHostId,
managerHandler(
runOnHost,
deps,
"connect",
"wireguard_action",
async (client, host, req) => {