mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
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) };
|
||||
}),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user