mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
refactor(backend): reorganize top-level layout
- ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface CronEntry {
|
||||
raw: string;
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const READ_CRONTAB_CMD = "crontab -l 2>/dev/null";
|
||||
|
||||
/** Parse a crontab into entries (comments/blank lines are dropped except as toggles). */
|
||||
export function parseCrontab(output: string): CronEntry[] {
|
||||
const entries: CronEntry[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.replace(/\s+$/, "");
|
||||
if (!line.trim()) continue;
|
||||
// A commented-out job: "# <schedule> <command>" (our toggle convention).
|
||||
const disabled = line.match(
|
||||
/^#\s*((?:@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+.+)$/,
|
||||
);
|
||||
if (disabled) {
|
||||
const { schedule, command } = splitScheduleCommand(disabled[1]);
|
||||
if (command) {
|
||||
entries.push({ raw: line, enabled: false, schedule, command });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip pure comments / env assignments.
|
||||
if (line.trimStart().startsWith("#")) continue;
|
||||
if (/^\s*[A-Z_]+=/.test(line)) continue;
|
||||
const { schedule, command } = splitScheduleCommand(line.trim());
|
||||
if (!command) continue;
|
||||
entries.push({ raw: line, enabled: true, schedule, command });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function splitScheduleCommand(line: string): {
|
||||
schedule: string;
|
||||
command: string;
|
||||
} {
|
||||
if (line.startsWith("@")) {
|
||||
const [sched, ...rest] = line.split(/\s+/);
|
||||
return { schedule: sched, command: rest.join(" ") };
|
||||
}
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 6) return { schedule: "", command: "" };
|
||||
return {
|
||||
schedule: parts.slice(0, 5).join(" "),
|
||||
command: parts.slice(5).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
const CRON_SCHEDULE_RE =
|
||||
/^(@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)|[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,-]+\s+[\d*/,A-Za-z-]+\s+[\d*/,A-Za-z-]+)$/;
|
||||
|
||||
export function isValidCronSchedule(schedule: string): boolean {
|
||||
return CRON_SCHEDULE_RE.test(schedule.trim());
|
||||
}
|
||||
|
||||
/** Serialize entries into a full crontab body (toggled entries are commented). */
|
||||
export function serializeCrontab(entries: CronEntry[]): string {
|
||||
const lines = entries.map((e) => {
|
||||
const body = `${e.schedule} ${e.command}`.trim();
|
||||
return e.enabled ? body : `# ${body}`;
|
||||
});
|
||||
return lines.join("\n") + (lines.length ? "\n" : "");
|
||||
}
|
||||
|
||||
/** Build a command that atomically replaces the user crontab from the given body. */
|
||||
export function buildApplyCrontabCommand(body: string): string {
|
||||
// printf the exact bytes into `crontab -` (reads new crontab from stdin).
|
||||
return `printf '%s' ${shellSingleQuote(body)} | crontab -`;
|
||||
}
|
||||
|
||||
export function registerCronRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "cron_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, READ_CRONTAB_CMD, 15000);
|
||||
return { entries: parseCrontab(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/cron/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"cron_replace",
|
||||
async (client, _host, req) => {
|
||||
const { entries } = req.body as { entries?: CronEntry[] };
|
||||
if (!Array.isArray(entries)) {
|
||||
throw new ManagerInputError("entries must be an array");
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (typeof e?.command !== "string" || !e.command.trim()) {
|
||||
throw new ManagerInputError("Each entry needs a command");
|
||||
}
|
||||
if (e.command.includes("\n")) {
|
||||
throw new ManagerInputError("Commands cannot contain newlines");
|
||||
}
|
||||
if (!isValidCronSchedule(String(e.schedule))) {
|
||||
throw new ManagerInputError(`Invalid schedule: ${e.schedule}`);
|
||||
}
|
||||
}
|
||||
const body = serializeCrontab(entries);
|
||||
const { stdout, stderr, code } = await execCommand(
|
||||
client,
|
||||
buildApplyCrontabCommand(body),
|
||||
15000,
|
||||
);
|
||||
return { success: code === 0, output: stdout || stderr };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../widgets/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
|
||||
import { execElevated, ElevationError } from "./exec-elevated.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
const fakeClient = {} as Client;
|
||||
|
||||
function result(stdout: string, stderr = "", code: number | null = 0) {
|
||||
return { stdout, stderr, code };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
execCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("execElevated (no force)", () => {
|
||||
it("returns the direct result when the command succeeds unprivileged", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("hello", "", 0));
|
||||
const r = await execElevated(fakeClient, "echo hello", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(r.stdout).toBe("hello");
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT escalate when stdout merely contains scary words", async () => {
|
||||
// A non-zero exit whose OUTPUT contains 'permission denied' but stderr does
|
||||
// not should be surfaced as-is, never retried under sudo.
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("log line: permission denied for user foo", "", 1),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "grep denied /tmp/app.log", "pw");
|
||||
expect(r.usedSudo).toBe(false);
|
||||
expect(execCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("escalates when stderr indicates a permission problem", async () => {
|
||||
execCommand
|
||||
.mockResolvedValueOnce(result("", "Permission denied", 1))
|
||||
.mockResolvedValueOnce(result("__TX_SUDO_OK__\nelevated output", "", 0));
|
||||
const r = await execElevated(fakeClient, "cat /etc/shadow", "pw");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toBe("elevated output");
|
||||
expect(execCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when elevation is needed but no password is set", async () => {
|
||||
execCommand.mockResolvedValueOnce(result("", "Permission denied", 1));
|
||||
await expect(
|
||||
execElevated(fakeClient, "cat /etc/shadow", undefined),
|
||||
).rejects.toMatchObject({ code: "SUDO_REQUIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("execElevated (forced)", () => {
|
||||
it("strips the success marker from stdout", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("__TX_SUDO_OK__\nthe real output\n", "", 0),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "id", "pw", { forceSudo: true });
|
||||
expect(r.stdout).toBe("the real output\n");
|
||||
expect(r.usedSudo).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT throw when command output contains 'incorrect password' but sudo authenticated", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result(
|
||||
"__TX_SUDO_OK__\nUser entered an incorrect password earlier",
|
||||
"",
|
||||
0,
|
||||
),
|
||||
);
|
||||
const r = await execElevated(fakeClient, "tail /var/log/auth.log", "pw", {
|
||||
forceSudo: true,
|
||||
});
|
||||
expect(r.usedSudo).toBe(true);
|
||||
expect(r.stdout).toContain("incorrect password");
|
||||
});
|
||||
|
||||
it("throws SUDO_FAILED on a real wrong-password (no marker, sudo stderr)", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "sudo: 1 incorrect password attempt", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "wrong", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "SUDO_FAILED" });
|
||||
});
|
||||
|
||||
it("throws NOT_SUDOER when the user is not in sudoers", async () => {
|
||||
execCommand.mockResolvedValueOnce(
|
||||
result("", "deploy is not in the sudoers file.", 1),
|
||||
);
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", "pw", { forceSudo: true }),
|
||||
).rejects.toMatchObject({ code: "NOT_SUDOER" });
|
||||
});
|
||||
|
||||
it("throws SUDO_REQUIRED when forced without a password", async () => {
|
||||
await expect(
|
||||
execElevated(fakeClient, "id", undefined, { forceSudo: true }),
|
||||
).rejects.toBeInstanceOf(ElevationError);
|
||||
expect(execCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
|
||||
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
|
||||
|
||||
export class ElevationError extends Error {
|
||||
code: ElevationErrorCode;
|
||||
constructor(code: ElevationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ElevationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElevatedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
usedSudo: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED = [
|
||||
"permission denied",
|
||||
"operation not permitted",
|
||||
"must be run as root",
|
||||
"must be superuser",
|
||||
"you need to be root",
|
||||
"are you root",
|
||||
"access denied",
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases sudo itself prints to STDERR when authentication or authorization
|
||||
* fails. We only match these against sudo's stderr (never command output), so a
|
||||
* command that happens to print "permission denied" on stdout is not mistaken
|
||||
* for a sudo failure.
|
||||
*/
|
||||
const SUDO_AUTH_FAILED = [
|
||||
"incorrect password",
|
||||
"a password is required",
|
||||
"a terminal is required",
|
||||
"no tty present",
|
||||
"sorry, try again",
|
||||
"no password was provided",
|
||||
"1 incorrect password attempt",
|
||||
];
|
||||
|
||||
const SUDO_NOT_SUDOER = [
|
||||
"is not in the sudoers file",
|
||||
"not allowed to run sudo",
|
||||
"not allowed to execute",
|
||||
];
|
||||
|
||||
/**
|
||||
* Marker printed only after sudo has successfully authenticated and started the
|
||||
* inner shell. Its presence on stdout is the authoritative "elevation worked"
|
||||
* signal; its absence (together with sudo stderr) means auth failed.
|
||||
*/
|
||||
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
|
||||
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
|
||||
|
||||
/** Escape a value for single-quoted shell context. */
|
||||
export function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the elevated command string:
|
||||
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
|
||||
*
|
||||
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
|
||||
* command's own output stays clean and sudo's auth errors stay on stderr. The
|
||||
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
|
||||
* genuine auth failure from command output that merely contains scary words.
|
||||
*/
|
||||
export function buildSudoCommand(
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): string {
|
||||
const pw = shellSingleQuote(sudoPassword);
|
||||
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
|
||||
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
|
||||
}
|
||||
|
||||
function includesAny(text: string, needles: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return needles.some((n) => lower.includes(n));
|
||||
}
|
||||
|
||||
function looksLikePermissionDenied(text: string): boolean {
|
||||
return includesAny(text, PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
/** Strip the success marker line from the front of stdout. */
|
||||
function stripMarker(stdout: string): string {
|
||||
return stdout.replace(SUDO_OK_LINE_RE, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on a pooled client, elevating with the host's stored sudo
|
||||
* password only when needed (or when forced). Throws a typed `ElevationError`
|
||||
* when elevation is required but unavailable/incorrect.
|
||||
*/
|
||||
export async function execElevated(
|
||||
client: Client,
|
||||
command: string,
|
||||
sudoPassword: string | undefined,
|
||||
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
|
||||
): Promise<ElevatedResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||
|
||||
if (!opts.forceSudo) {
|
||||
const direct = await execCommand(client, command, timeoutMs);
|
||||
if (direct.code === 0) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
// Only escalate when the failure looks like a privilege problem. A
|
||||
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
|
||||
if (!looksLikePermissionDenied(direct.stderr)) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
} else if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
|
||||
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
|
||||
const result = await execCommand(client, sudoCmd, timeoutMs);
|
||||
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
|
||||
|
||||
if (!authenticated) {
|
||||
// Elevation never started: diagnose from sudo's stderr only.
|
||||
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
|
||||
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
|
||||
if (notSudoer) {
|
||||
throw new ElevationError(
|
||||
"NOT_SUDOER",
|
||||
"The connected user is not permitted to use sudo on this host.",
|
||||
);
|
||||
}
|
||||
if (authFailed) {
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
// No marker and no recognizable sudo error: treat as a generic failure but
|
||||
// keep the original output so the caller can surface it.
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stripMarker(result.stdout),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
usedSudo: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Express } from "express";
|
||||
import { collectFirewallMetrics } from "../widgets/firewall-collector.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidPort,
|
||||
isValidIpProtocol,
|
||||
isValidFirewallTarget,
|
||||
type IpProtocol,
|
||||
type FirewallTarget,
|
||||
} from "./validation.js";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface FirewallRuleSpec {
|
||||
protocol: IpProtocol;
|
||||
port: number;
|
||||
target: FirewallTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an iptables add/delete for an INPUT rule. We only ever touch INPUT for a
|
||||
* specific dport with an explicit target, and never the chain policy, so an
|
||||
* existing ESTABLISHED/SSH rule is left intact.
|
||||
*/
|
||||
export function buildIptablesRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
const flag = op === "add" ? "-A" : "-D";
|
||||
return `iptables ${flag} INPUT -p ${spec.protocol} --dport ${spec.port} -j ${spec.target}`;
|
||||
}
|
||||
|
||||
export function buildNftRuleCommand(
|
||||
op: "add" | "delete",
|
||||
spec: FirewallRuleSpec,
|
||||
): string {
|
||||
// nftables uses the inet filter table's input chain by convention.
|
||||
const verb = op === "add" ? "add" : "delete";
|
||||
const action =
|
||||
spec.target.toLowerCase() === "reject"
|
||||
? "reject"
|
||||
: spec.target.toLowerCase();
|
||||
return `nft ${verb} rule inet filter input ${spec.protocol} dport ${spec.port} ${action}`;
|
||||
}
|
||||
|
||||
export function registerFirewallRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/firewall/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "firewall_read", async (client) => {
|
||||
return await collectFirewallMetrics(client);
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/rule",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"firewall_rule",
|
||||
async (client, host, req) => {
|
||||
const { op, protocol, port, target } = req.body as {
|
||||
op?: "add" | "delete";
|
||||
protocol?: string;
|
||||
port?: number;
|
||||
target?: string;
|
||||
};
|
||||
if (op !== "add" && op !== "delete") {
|
||||
throw new ManagerInputError("Invalid op");
|
||||
}
|
||||
if (!isValidIpProtocol(protocol))
|
||||
throw new ManagerInputError("Invalid protocol");
|
||||
if (!isValidPort(port)) throw new ManagerInputError("Invalid port");
|
||||
if (!isValidFirewallTarget(target))
|
||||
throw new ManagerInputError("Invalid target");
|
||||
|
||||
const spec: FirewallRuleSpec = {
|
||||
protocol,
|
||||
port: Number(port),
|
||||
target,
|
||||
};
|
||||
const fw = await collectFirewallMetrics(client);
|
||||
const cmd =
|
||||
fw.type === "nftables"
|
||||
? buildNftRuleCommand(op, spec)
|
||||
: buildIptablesRuleCommand(op, spec);
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
backend: fw.type,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/firewall/:id/persist",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"firewall_persist",
|
||||
async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
// Best-effort persistence across common tools.
|
||||
const cmd =
|
||||
"(command -v netfilter-persistent >/dev/null 2>&1 && netfilter-persistent save) || " +
|
||||
"(command -v service >/dev/null 2>&1 && service iptables save) || " +
|
||||
"(command -v nft >/dev/null 2>&1 && nft list ruleset > /etc/nftables.conf) || true";
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
pkg: platform.pkg,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { Express } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { createCurrentHostHealthRepository } from "../../database/repositories/factory.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import { isValidPort } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { AlertEngine } from "../alert-engine.js";
|
||||
|
||||
export interface HealthCheck {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "tcp" | "http";
|
||||
target: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface HealthResult {
|
||||
checkId: string;
|
||||
ok: boolean;
|
||||
latencyMs: number | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
|
||||
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
|
||||
|
||||
export function isValidHealthCheck(c: unknown): c is HealthCheck {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const o = c as Record<string, unknown>;
|
||||
if (typeof o.id !== "string" || !o.id) return false;
|
||||
if (typeof o.name !== "string") return false;
|
||||
if (o.type !== "tcp" && o.type !== "http") return false;
|
||||
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
|
||||
if (o.type === "tcp" && !isValidPort(o.port)) return false;
|
||||
if (
|
||||
o.path !== undefined &&
|
||||
(typeof o.path !== "string" || !PATH_RE.test(o.path))
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Build a command that runs the check from the host and prints "ok latency". */
|
||||
export function buildHealthCheckCommand(check: HealthCheck): string {
|
||||
if (check.type === "tcp") {
|
||||
const host = shellSingleQuote(check.target);
|
||||
const port = check.port;
|
||||
// Prefer bash /dev/tcp; time it with date in ms.
|
||||
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
|
||||
}
|
||||
// http
|
||||
const scheme = check.target.includes("://") ? "" : "http://";
|
||||
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
|
||||
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
|
||||
}
|
||||
|
||||
export function parseHealthResult(
|
||||
check: HealthCheck,
|
||||
output: string,
|
||||
): HealthResult {
|
||||
const line = output.trim().split("\n").pop() ?? "";
|
||||
if (check.type === "tcp") {
|
||||
const [status, ms] = line.split(/\s+/);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: status === "ok",
|
||||
latencyMs: Number(ms) || null,
|
||||
detail: status === "ok" ? "open" : "closed/timeout",
|
||||
};
|
||||
}
|
||||
const m = line.match(/^(\d{3})\s+([\d.]+)/);
|
||||
if (!m)
|
||||
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
|
||||
const code = Number(m[1]);
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: code >= 200 && code < 400,
|
||||
latencyMs: Math.round(Number(m[2]) * 1000),
|
||||
detail: `HTTP ${code}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runChecks(
|
||||
client: Client,
|
||||
checks: HealthCheck[],
|
||||
): Promise<HealthResult[]> {
|
||||
return Promise.all(
|
||||
checks.map(async (check) => {
|
||||
try {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
buildHealthCheckCommand(check),
|
||||
8000,
|
||||
);
|
||||
return parseHealthResult(check, stdout);
|
||||
} catch {
|
||||
return {
|
||||
checkId: check.id,
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "error",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
): Promise<HealthCheck[]> {
|
||||
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!row?.checks) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.checks);
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHealthRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/health/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"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) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/config",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"health_config",
|
||||
async (_client, host, req) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { checks, intervalSeconds } = req.body as {
|
||||
checks?: unknown;
|
||||
intervalSeconds?: number;
|
||||
};
|
||||
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
|
||||
throw new ManagerInputError("Invalid checks");
|
||||
}
|
||||
const interval =
|
||||
typeof intervalSeconds === "number" &&
|
||||
intervalSeconds >= 30 &&
|
||||
intervalSeconds <= 86400
|
||||
? Math.round(intervalSeconds)
|
||||
: 300;
|
||||
const now = new Date().toISOString();
|
||||
await createCurrentHostHealthRepository().upsertChecks(
|
||||
userId,
|
||||
host.id,
|
||||
JSON.stringify(checks),
|
||||
interval,
|
||||
now,
|
||||
);
|
||||
return { success: true };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/health/:id/run",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"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) {
|
||||
AlertEngine.getInstance()
|
||||
.evaluateHealthCheck(
|
||||
host.id,
|
||||
userId,
|
||||
r.checkId,
|
||||
r.ok,
|
||||
r.detail ?? undefined,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Express } from "express";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { registerServiceRoutes } from "./services.js";
|
||||
import { registerProcessRoutes } from "./processes.js";
|
||||
import { registerSimpleReadRoutes } from "./simple-reads.js";
|
||||
import { registerCronRoutes } from "./cron.js";
|
||||
import { registerPackageRoutes } from "./packages.js";
|
||||
import { registerSslRoutes } from "./ssl.js";
|
||||
import { registerFirewallRoutes } from "./firewall.js";
|
||||
import { registerUserRoutes } from "./users.js";
|
||||
import { registerHealthRoutes } from "./health.js";
|
||||
import { registerLogRoutes } from "./logs.js";
|
||||
import { registerWireGuardRoutes } from "./wireguard.js";
|
||||
import { registerTailscaleRoutes } from "./tailscale.js";
|
||||
|
||||
/**
|
||||
* Registers every Host Metrics manager route under the `/host-metrics/managers`
|
||||
* prefix on the stats app. All routes are on-demand (not polled).
|
||||
*/
|
||||
export function registerManagerRoutes(
|
||||
app: Express,
|
||||
deps: ManagerRoutesDeps,
|
||||
): void {
|
||||
const { validateHostId, runOnHost } = deps;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/platform/{id}:
|
||||
* get:
|
||||
* summary: Detect available management tooling on a host
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Platform capabilities (systemd, package manager, certbot, docker).
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/platform/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "platform_detect", (client) =>
|
||||
detectPlatform(client),
|
||||
),
|
||||
);
|
||||
|
||||
registerServiceRoutes(app, deps);
|
||||
registerProcessRoutes(app, deps);
|
||||
registerSimpleReadRoutes(app, deps);
|
||||
registerCronRoutes(app, deps);
|
||||
registerPackageRoutes(app, deps);
|
||||
registerSslRoutes(app, deps);
|
||||
registerFirewallRoutes(app, deps);
|
||||
registerUserRoutes(app, deps);
|
||||
registerHealthRoutes(app, deps);
|
||||
registerLogRoutes(app, deps);
|
||||
registerWireGuardRoutes(app, deps);
|
||||
registerTailscaleRoutes(app, deps);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { shellSingleQuote } from "./exec-elevated.js";
|
||||
import { isAllowedPath, isValidSystemdUnit } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
/** Directories from which arbitrary log files may be tailed. */
|
||||
export const LOG_PATH_ALLOWLIST = ["/var/log"];
|
||||
|
||||
const COMMON_LOGS = [
|
||||
"/var/log/syslog",
|
||||
"/var/log/messages",
|
||||
"/var/log/auth.log",
|
||||
"/var/log/secure",
|
||||
"/var/log/kern.log",
|
||||
"/var/log/dpkg.log",
|
||||
"/var/log/nginx/access.log",
|
||||
"/var/log/nginx/error.log",
|
||||
];
|
||||
|
||||
const LIST_LOGS_CMD = `ls -1 ${LOG_PATH_ALLOWLIST.map(shellSingleQuote).join(" ")} 2>/dev/null`;
|
||||
|
||||
export function clampLines(n: unknown): number {
|
||||
const v = typeof n === "string" ? Number(n) : n;
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return 200;
|
||||
return Math.min(2000, Math.max(1, Math.round(v)));
|
||||
}
|
||||
|
||||
export function buildTailCommand(path: string, lines: number): string {
|
||||
// Keep stderr intact so execElevated can detect a permission error and
|
||||
// escalate; suppressing it (2>/dev/null) would hide the denial and return an
|
||||
// empty log with no chance to retry under sudo.
|
||||
return `tail -n ${lines} ${shellSingleQuote(path)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id/files",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "logs_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_LOGS_CMD, 10000);
|
||||
const found = stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => `/var/log/${name}`);
|
||||
return { common: COMMON_LOGS, files: found };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/logs/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"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");
|
||||
}
|
||||
|
||||
// Try unprivileged; many logs need root (auth.log, etc.).
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return { content: result.stdout, lines };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildSudoCommand, shellSingleQuote } from "./exec-elevated.js";
|
||||
import { parsePlatformProbe } from "./platform.js";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidPid,
|
||||
isValidPort,
|
||||
isValidPackageName,
|
||||
isValidUsername,
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isValidSignal,
|
||||
isValidServiceAction,
|
||||
isAllowedPath,
|
||||
} from "./validation.js";
|
||||
import { parseServiceList, buildServiceActionCommand } from "./services.js";
|
||||
import { parseProcessList, buildKillCommand } from "./processes.js";
|
||||
import { parseDfMounts, parseTopMemory } from "./simple-reads.js";
|
||||
import {
|
||||
parseCrontab,
|
||||
serializeCrontab,
|
||||
isValidCronSchedule,
|
||||
buildApplyCrontabCommand,
|
||||
} from "./cron.js";
|
||||
import {
|
||||
buildPackageActionCommand,
|
||||
parseUpgradable,
|
||||
buildListUpgradableCommand,
|
||||
} from "./packages.js";
|
||||
import {
|
||||
buildIssueCommand,
|
||||
buildRenewCommand,
|
||||
buildRevokeCommand,
|
||||
isValidCertName,
|
||||
parseCertbotCertificates,
|
||||
} from "./ssl.js";
|
||||
import { buildIptablesRuleCommand, buildNftRuleCommand } from "./firewall.js";
|
||||
import { parsePasswd, parseSudoers } from "./users.js";
|
||||
import { buildHealthCheckCommand, parseHealthResult } from "./health.js";
|
||||
import { buildTailCommand, clampLines } from "./logs.js";
|
||||
|
||||
describe("exec-elevated", () => {
|
||||
it("single-quotes and escapes for the shell", () => {
|
||||
expect(shellSingleQuote("abc")).toBe("'abc'");
|
||||
expect(shellSingleQuote("a'b")).toBe(`'a'"'"'b'`);
|
||||
});
|
||||
it("builds the sudo -S pipeline wrapping the command in sh -c with a success marker", () => {
|
||||
expect(buildSudoCommand("systemctl restart nginx", "pw")).toBe(
|
||||
`echo 'pw' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; systemctl restart nginx'`,
|
||||
);
|
||||
});
|
||||
it("does not merge stderr into stdout (no 2>&1)", () => {
|
||||
expect(buildSudoCommand("id", "pw")).not.toContain("2>&1");
|
||||
});
|
||||
it("escapes a password containing a quote", () => {
|
||||
expect(buildSudoCommand("id", "p'w")).toContain(`echo 'p'"'"'w'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform probe parsing", () => {
|
||||
it("parses capabilities and prefers dnf over yum", () => {
|
||||
const out = [
|
||||
"systemd=1",
|
||||
"apt=0",
|
||||
"dnf=1",
|
||||
"yum=1",
|
||||
"pacman=0",
|
||||
"certbot=1",
|
||||
"acmesh=0",
|
||||
"docker=1",
|
||||
"os=Fedora Linux 40",
|
||||
].join("\n");
|
||||
const p = parsePlatformProbe(out);
|
||||
expect(p.hasSystemd).toBe(true);
|
||||
expect(p.pkg).toBe("dnf");
|
||||
expect(p.hasCertbot).toBe(true);
|
||||
expect(p.hasAcmeSh).toBe(false);
|
||||
expect(p.hasDocker).toBe(true);
|
||||
expect(p.osPrettyName).toBe("Fedora Linux 40");
|
||||
});
|
||||
it("picks apt when present", () => {
|
||||
expect(parsePlatformProbe("apt=1\ndnf=1").pkg).toBe("apt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation (injection defense)", () => {
|
||||
it("systemd units", () => {
|
||||
expect(isValidSystemdUnit("nginx.service")).toBe(true);
|
||||
expect(isValidSystemdUnit("ssh.socket")).toBe(true);
|
||||
expect(isValidSystemdUnit("nginx.service; rm -rf /")).toBe(false);
|
||||
expect(isValidSystemdUnit("nginx")).toBe(false);
|
||||
});
|
||||
it("pids", () => {
|
||||
expect(isValidPid(123)).toBe(true);
|
||||
expect(isValidPid("123")).toBe(true);
|
||||
expect(isValidPid(0)).toBe(false);
|
||||
expect(isValidPid("1; reboot")).toBe(false);
|
||||
expect(isValidPid(-5)).toBe(false);
|
||||
});
|
||||
it("ports", () => {
|
||||
expect(isValidPort(443)).toBe(true);
|
||||
expect(isValidPort(0)).toBe(false);
|
||||
expect(isValidPort(70000)).toBe(false);
|
||||
});
|
||||
it("package names", () => {
|
||||
expect(isValidPackageName("nginx")).toBe(true);
|
||||
expect(isValidPackageName("lib-foo.bar+1")).toBe(true);
|
||||
expect(isValidPackageName("nginx && curl evil")).toBe(false);
|
||||
expect(isValidPackageName("-rf")).toBe(false);
|
||||
});
|
||||
it("usernames and domains", () => {
|
||||
expect(isValidUsername("deploy")).toBe(true);
|
||||
expect(isValidUsername("root; rm")).toBe(false);
|
||||
expect(isValidDomain("example.com")).toBe(true);
|
||||
expect(isValidDomain("*.example.com")).toBe(true);
|
||||
expect(isValidDomain("ex ample.com")).toBe(false);
|
||||
expect(isValidDomain("a;b.com")).toBe(false);
|
||||
});
|
||||
it("dns providers, signals, service actions", () => {
|
||||
expect(isValidDnsProvider("cloudflare")).toBe(true);
|
||||
expect(isValidDnsProvider("cf; rm")).toBe(false);
|
||||
expect(isValidSignal("KILL")).toBe(true);
|
||||
expect(isValidSignal("BOOM")).toBe(false);
|
||||
expect(isValidServiceAction("restart")).toBe(true);
|
||||
expect(isValidServiceAction("destroy")).toBe(false);
|
||||
});
|
||||
it("path allowlist rejects traversal and out-of-allowlist", () => {
|
||||
expect(isAllowedPath("/var/log/syslog", ["/var/log"])).toBe(true);
|
||||
expect(isAllowedPath("/var/log/../../etc/passwd", ["/var/log"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAllowedPath("/etc/passwd", ["/var/log"])).toBe(false);
|
||||
expect(isAllowedPath("relative/path", ["/var/log"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("services", () => {
|
||||
it("parses list-units --plain", () => {
|
||||
const out =
|
||||
"nginx.service loaded active running A high performance web server\n" +
|
||||
"ssh.service loaded active running OpenBSD Secure Shell server\n" +
|
||||
"cron.service loaded inactive dead Regular background program";
|
||||
const rows = parseServiceList(out);
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]).toMatchObject({ unit: "nginx.service", active: "active" });
|
||||
expect(rows[2].active).toBe("inactive");
|
||||
});
|
||||
it("builds action command", () => {
|
||||
expect(buildServiceActionCommand("nginx.service", "restart")).toBe(
|
||||
"systemctl restart nginx.service",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("processes", () => {
|
||||
it("parses ps output", () => {
|
||||
const out =
|
||||
"1234 1 root 12.5 3.2 45000 S /usr/bin/node node server.js\n" +
|
||||
"5678 1234 deploy 0.0 1.1 12000 Sl bash -bash";
|
||||
const rows = parseProcessList(out);
|
||||
expect(rows[0]).toMatchObject({
|
||||
pid: 1234,
|
||||
user: "root",
|
||||
cpu: 12.5,
|
||||
command: "/usr/bin/node",
|
||||
});
|
||||
expect(rows[1].pid).toBe(5678);
|
||||
});
|
||||
it("builds kill command", () => {
|
||||
expect(buildKillCommand(42, "TERM")).toBe("kill -TERM 42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("simple reads", () => {
|
||||
it("parses df -Pk and skips tmpfs", () => {
|
||||
const out =
|
||||
"/dev/sda1 100000 40000 60000 40% /\n" +
|
||||
"tmpfs 8000 0 8000 0% /dev/shm\n" +
|
||||
"/dev/sdb1 200000 100000 100000 50% /data";
|
||||
const mounts = parseDfMounts(out);
|
||||
expect(mounts).toHaveLength(2);
|
||||
expect(mounts[0].mount).toBe("/");
|
||||
expect(mounts[1].usePct).toBe(50);
|
||||
});
|
||||
it("parses top by memory", () => {
|
||||
const rows = parseTopMemory("1234 root 5.5 50000 node");
|
||||
expect(rows[0]).toMatchObject({ pid: 1234, mem: 5.5, command: "node" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cron", () => {
|
||||
it("parses enabled and toggled entries", () => {
|
||||
const out = "0 2 * * * /backup.sh\n# 30 4 * * * /old.sh\nPATH=/usr/bin";
|
||||
const entries = parseCrontab(out);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toMatchObject({ enabled: true, schedule: "0 2 * * *" });
|
||||
expect(entries[1].enabled).toBe(false);
|
||||
});
|
||||
it("validates schedules", () => {
|
||||
expect(isValidCronSchedule("0 2 * * *")).toBe(true);
|
||||
expect(isValidCronSchedule("@daily")).toBe(true);
|
||||
expect(isValidCronSchedule("not a schedule")).toBe(false);
|
||||
});
|
||||
it("serializes (commenting disabled entries) and round-trips", () => {
|
||||
const body = serializeCrontab([
|
||||
{ raw: "", enabled: true, schedule: "0 2 * * *", command: "/a.sh" },
|
||||
{ raw: "", enabled: false, schedule: "@daily", command: "/b.sh" },
|
||||
]);
|
||||
expect(body).toBe("0 2 * * * /a.sh\n# @daily /b.sh\n");
|
||||
const reparsed = parseCrontab(body);
|
||||
expect(reparsed[0].enabled).toBe(true);
|
||||
expect(reparsed[1].enabled).toBe(false);
|
||||
});
|
||||
it("builds apply command piping into crontab -", () => {
|
||||
expect(buildApplyCrontabCommand("x\n")).toBe(
|
||||
`printf '%s' 'x\n' | crontab -`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("packages", () => {
|
||||
it("builds per-distro commands", () => {
|
||||
expect(buildPackageActionCommand("apt", "install", "nginx")).toContain(
|
||||
"apt-get -y install nginx",
|
||||
);
|
||||
expect(buildPackageActionCommand("pacman", "upgrade-all")).toBe(
|
||||
"pacman -Syu --noconfirm",
|
||||
);
|
||||
expect(buildPackageActionCommand(null, "install", "x")).toBeNull();
|
||||
});
|
||||
it("lists per distro", () => {
|
||||
expect(buildListUpgradableCommand("apt")).toContain(
|
||||
"apt list --upgradable",
|
||||
);
|
||||
expect(buildListUpgradableCommand(null)).toBeNull();
|
||||
});
|
||||
it("parses apt upgradable", () => {
|
||||
const out =
|
||||
"nginx/focal-updates 1.18.0-2 amd64 [upgradable from: 1.18.0-1]";
|
||||
const pkgs = parseUpgradable("apt", out);
|
||||
expect(pkgs[0]).toMatchObject({
|
||||
name: "nginx",
|
||||
newVersion: "1.18.0-2",
|
||||
currentVersion: "1.18.0-1",
|
||||
});
|
||||
});
|
||||
it("parses pacman upgradable", () => {
|
||||
const pkgs = parseUpgradable("pacman", "linux 6.1 -> 6.2");
|
||||
expect(pkgs[0]).toMatchObject({ name: "linux", newVersion: "6.2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssl (dual client)", () => {
|
||||
it("builds certbot issue for http + dns", () => {
|
||||
expect(
|
||||
buildIssueCommand({
|
||||
client: "certbot",
|
||||
domains: ["a.com"],
|
||||
challenge: "http-standalone",
|
||||
}),
|
||||
).toContain(
|
||||
"certbot certonly --non-interactive --agree-tos --standalone -d 'a.com'",
|
||||
);
|
||||
expect(
|
||||
buildIssueCommand({
|
||||
client: "certbot",
|
||||
domains: ["a.com"],
|
||||
challenge: "dns",
|
||||
dnsProvider: "cloudflare",
|
||||
}),
|
||||
).toContain("--dns-cloudflare");
|
||||
});
|
||||
it("builds acme.sh issue", () => {
|
||||
const cmd = buildIssueCommand({
|
||||
client: "acme.sh",
|
||||
domains: ["a.com", "b.com"],
|
||||
challenge: "dns",
|
||||
dnsProvider: "cf",
|
||||
});
|
||||
expect(cmd).toContain("--issue --dns dns_cf");
|
||||
expect(cmd).toContain("-d 'a.com'");
|
||||
expect(cmd).toContain("-d 'b.com'");
|
||||
});
|
||||
it("builds renew per client", () => {
|
||||
expect(buildRenewCommand("certbot", true)).toBe("certbot renew --dry-run");
|
||||
expect(buildRenewCommand("acme.sh", false)).toContain("--renew-all");
|
||||
});
|
||||
it("builds revoke per client", () => {
|
||||
expect(buildRevokeCommand("certbot", "example.com")).toBe(
|
||||
"certbot revoke --non-interactive --cert-name 'example.com' --delete-after-revoke",
|
||||
);
|
||||
const acme = buildRevokeCommand("acme.sh", "example.com");
|
||||
expect(acme).toContain("--revoke -d 'example.com'");
|
||||
expect(acme).toContain("--remove -d 'example.com'");
|
||||
});
|
||||
it("validates certificate names (rejects shell metachars)", () => {
|
||||
expect(isValidCertName("example.com")).toBe(true);
|
||||
expect(isValidCertName("example.com-0001")).toBe(true);
|
||||
expect(isValidCertName("*.example.com")).toBe(true);
|
||||
expect(isValidCertName("a.com; rm -rf /")).toBe(false);
|
||||
expect(isValidCertName("")).toBe(false);
|
||||
expect(isValidCertName(undefined)).toBe(false);
|
||||
});
|
||||
it("parses certbot certificates", () => {
|
||||
const out = [
|
||||
"Found the following certs:",
|
||||
" Certificate Name: example.com",
|
||||
" Domains: example.com www.example.com",
|
||||
" Expiry Date: 2026-09-01 12:00:00+00:00 (VALID: 80 days)",
|
||||
" Certificate Path: /etc/letsencrypt/live/example.com/fullchain.pem",
|
||||
].join("\n");
|
||||
const certs = parseCertbotCertificates(out);
|
||||
expect(certs[0]).toMatchObject({
|
||||
name: "example.com",
|
||||
client: "certbot",
|
||||
});
|
||||
expect(certs[0].domains).toContain("www.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firewall", () => {
|
||||
it("builds iptables add/delete on INPUT only", () => {
|
||||
expect(
|
||||
buildIptablesRuleCommand("add", {
|
||||
protocol: "tcp",
|
||||
port: 443,
|
||||
target: "ACCEPT",
|
||||
}),
|
||||
).toBe("iptables -A INPUT -p tcp --dport 443 -j ACCEPT");
|
||||
expect(
|
||||
buildIptablesRuleCommand("delete", {
|
||||
protocol: "udp",
|
||||
port: 53,
|
||||
target: "DROP",
|
||||
}),
|
||||
).toBe("iptables -D INPUT -p udp --dport 53 -j DROP");
|
||||
});
|
||||
it("builds nft rules", () => {
|
||||
expect(
|
||||
buildNftRuleCommand("add", {
|
||||
protocol: "tcp",
|
||||
port: 22,
|
||||
target: "ACCEPT",
|
||||
}),
|
||||
).toContain("add rule inet filter input tcp dport 22 accept");
|
||||
});
|
||||
});
|
||||
|
||||
describe("users", () => {
|
||||
it("parses passwd for human users only", () => {
|
||||
const out =
|
||||
"root:x:0:0:root:/root:/bin/bash\n" +
|
||||
"deploy:x:1000:1000:Deploy:/home/deploy:/bin/bash\n" +
|
||||
"nobody:x:65534:65534:nobody:/:/usr/sbin/nologin";
|
||||
const users = parsePasswd(out);
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].name).toBe("deploy");
|
||||
});
|
||||
it("parses sudoers membership", () => {
|
||||
const out = "sudo:x:27:deploy,alice\nwheel:x:10:bob";
|
||||
expect(parseSudoers(out).sort()).toEqual(["alice", "bob", "deploy"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("health checks", () => {
|
||||
it("builds tcp and http commands", () => {
|
||||
const tcp = buildHealthCheckCommand({
|
||||
id: "1",
|
||||
name: "ssh",
|
||||
type: "tcp",
|
||||
target: "localhost",
|
||||
port: 22,
|
||||
});
|
||||
expect(tcp).toContain("/dev/tcp/");
|
||||
const http = buildHealthCheckCommand({
|
||||
id: "2",
|
||||
name: "web",
|
||||
type: "http",
|
||||
target: "example.com",
|
||||
path: "/health",
|
||||
});
|
||||
expect(http).toContain("curl -s -o /dev/null");
|
||||
expect(http).toContain("http://example.com/health");
|
||||
});
|
||||
it("parses tcp and http results", () => {
|
||||
const tcp = parseHealthResult(
|
||||
{ id: "1", name: "ssh", type: "tcp", target: "h", port: 22 },
|
||||
"ok 12",
|
||||
);
|
||||
expect(tcp).toMatchObject({ ok: true, latencyMs: 12 });
|
||||
const http = parseHealthResult(
|
||||
{ id: "2", name: "web", type: "http", target: "h" },
|
||||
"200 0.045",
|
||||
);
|
||||
expect(http).toMatchObject({ ok: true, latencyMs: 45 });
|
||||
const bad = parseHealthResult(
|
||||
{ id: "3", name: "web", type: "http", target: "h" },
|
||||
"500 0.01",
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logs", () => {
|
||||
it("clamps line counts", () => {
|
||||
expect(clampLines(50)).toBe(50);
|
||||
expect(clampLines(99999)).toBe(2000);
|
||||
expect(clampLines("abc")).toBe(200);
|
||||
expect(clampLines(0)).toBe(1);
|
||||
});
|
||||
it("builds a quoted tail command without suppressing stderr", () => {
|
||||
expect(buildTailCommand("/var/log/syslog", 100)).toBe(
|
||||
"tail -n 100 '/var/log/syslog'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPackageName } from "./validation.js";
|
||||
import { detectPlatform, type PackageManager } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface UpgradablePackage {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
return "apt list --upgradable 2>/dev/null | tail -n +2";
|
||||
case "dnf":
|
||||
return "dnf -q check-update 2>/dev/null || true";
|
||||
case "yum":
|
||||
return "yum -q check-update 2>/dev/null || true";
|
||||
case "pacman":
|
||||
return "pacman -Qu 2>/dev/null || true";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpgradable(
|
||||
pkg: PackageManager,
|
||||
output: string,
|
||||
): UpgradablePackage[] {
|
||||
const out: UpgradablePackage[] = [];
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (pkg === "apt") {
|
||||
for (const line of lines) {
|
||||
// name/suite newver arch [upgradable from: oldver]
|
||||
const m = line.match(
|
||||
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
|
||||
);
|
||||
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
|
||||
}
|
||||
} else if (pkg === "dnf" || pkg === "yum") {
|
||||
for (const line of lines) {
|
||||
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
|
||||
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
|
||||
}
|
||||
} else if (pkg === "pacman") {
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
|
||||
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PackageAction = "upgrade-all" | "install" | "upgrade";
|
||||
|
||||
export function buildPackageActionCommand(
|
||||
pkg: PackageManager,
|
||||
action: PackageAction,
|
||||
name?: string,
|
||||
): string | null {
|
||||
const target = name ? ` ${name}` : "";
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
if (action === "upgrade-all")
|
||||
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
|
||||
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
|
||||
case "dnf":
|
||||
return action === "upgrade-all"
|
||||
? "dnf -y upgrade"
|
||||
: `dnf -y install${target}`;
|
||||
case "yum":
|
||||
return action === "upgrade-all"
|
||||
? "yum -y update"
|
||||
: `yum -y install${target}`;
|
||||
case "pacman":
|
||||
return action === "upgrade-all"
|
||||
? "pacman -Syu --noconfirm"
|
||||
: `pacman -S --noconfirm${target}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPackageRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/packages/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "packages_list", async (client) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildListUpgradableCommand(platform.pkg);
|
||||
if (!cmd) return { pkg: platform.pkg, upgradable: [] };
|
||||
const { stdout } = await execCommand(client, cmd, 60000);
|
||||
return {
|
||||
pkg: platform.pkg,
|
||||
upgradable: parseUpgradable(platform.pkg, stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/packages/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"packages_action",
|
||||
async (client, host, req) => {
|
||||
const { action, pkg: name } = req.body as {
|
||||
action?: PackageAction;
|
||||
pkg?: string;
|
||||
};
|
||||
if (
|
||||
action !== "upgrade-all" &&
|
||||
action !== "install" &&
|
||||
action !== "upgrade"
|
||||
) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
if (action !== "upgrade-all" && !isValidPackageName(name)) {
|
||||
throw new ManagerInputError("Invalid package name");
|
||||
}
|
||||
const platform = await detectPlatform(client);
|
||||
const cmd = buildPackageActionCommand(platform.pkg, action, name);
|
||||
if (!cmd) throw new ManagerInputError("No supported package manager");
|
||||
// Package operations can be slow; allow up to 10 minutes.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
timeoutMs: 600000,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout || result.stderr).slice(-8000),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
|
||||
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: PackageManager;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single probe that reports which tooling is available. Each line is
|
||||
* "key=value" so the parser is trivial and order-independent.
|
||||
*/
|
||||
export const PLATFORM_PROBE_COMMAND = [
|
||||
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
|
||||
"echo docker=$(command -v docker >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
|
||||
].join("; ");
|
||||
|
||||
export function parsePlatformProbe(output: string): PlatformInfo {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
||||
}
|
||||
const on = (k: string) => map.get(k) === "1";
|
||||
|
||||
// Prefer dnf over yum when both exist (dnf is the modern front-end).
|
||||
let pkg: PackageManager = null;
|
||||
if (on("apt")) pkg = "apt";
|
||||
else if (on("dnf")) pkg = "dnf";
|
||||
else if (on("yum")) pkg = "yum";
|
||||
else if (on("pacman")) pkg = "pacman";
|
||||
|
||||
const os = map.get("os");
|
||||
return {
|
||||
hasSystemd: on("systemd"),
|
||||
pkg,
|
||||
hasCertbot: on("certbot"),
|
||||
hasAcmeSh: on("acmesh"),
|
||||
hasDocker: on("docker"),
|
||||
osPrettyName: os && os.length > 0 ? os : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
|
||||
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
|
||||
return parsePlatformProbe(stdout);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidPid, isValidSignal, type Signal } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
rss: number;
|
||||
stat: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
const LIST_PROCESSES_CMD =
|
||||
"ps -eo pid,ppid,user:20,pcpu,pmem,rss,stat,comm,args --sort=-pcpu --no-headers 2>/dev/null | head -n 300";
|
||||
|
||||
/** Parse `ps -eo pid,ppid,user,pcpu,pmem,rss,stat,comm,args` output. */
|
||||
export function parseProcessList(output: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(
|
||||
/^(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/,
|
||||
);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
ppid: Number(m[2]),
|
||||
user: m[3],
|
||||
cpu: Number(m[4]),
|
||||
mem: Number(m[5]),
|
||||
rss: Number(m[6]),
|
||||
stat: m[7],
|
||||
command: m[8],
|
||||
args: m[9],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildKillCommand(pid: number, signal: Signal): string {
|
||||
return `kill -${signal} ${pid}`;
|
||||
}
|
||||
|
||||
export function registerProcessRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}:
|
||||
* get:
|
||||
* summary: List processes (rich, sortable, filterable client-side)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: Process list. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/processes/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "processes_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_PROCESSES_CMD, 20000);
|
||||
return { processes: parseProcessList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/processes/{id}/signal:
|
||||
* post:
|
||||
* summary: Send a signal to a process (TERM/KILL/HUP/INT)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* pid: { type: integer }
|
||||
* signal: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Signal result. }
|
||||
* 400: { description: Invalid pid or signal. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/processes/:id/signal",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"processes_signal",
|
||||
async (client, host, req) => {
|
||||
const { pid, signal } = req.body as {
|
||||
pid?: number;
|
||||
signal?: string;
|
||||
};
|
||||
if (!isValidPid(pid)) throw new ManagerInputError("Invalid pid");
|
||||
if (!isValidSignal(signal))
|
||||
throw new ManagerInputError("Invalid signal");
|
||||
const cmd = buildKillCommand(Number(pid), signal);
|
||||
// Try unprivileged first; elevate only if the process isn't owned.
|
||||
const result = await execElevated(client, cmd, host.sudoPassword);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
usedSudo: result.usedSudo,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Client } from "ssh2";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { statsLogger } from "../../utils/logger.js";
|
||||
import { ElevationError } from "./exec-elevated.js";
|
||||
import type { ManagerHost, RunOnHost } from "./types.js";
|
||||
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(message = "No access to this host") {
|
||||
super(message);
|
||||
this.name = "AccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagerInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ManagerInputError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a manager handler: parses hostId, runs `fn` on a pooled connection at the
|
||||
* given access level, and maps known errors to clean HTTP responses.
|
||||
*/
|
||||
export function managerHandler(
|
||||
runOnHost: RunOnHost,
|
||||
level: "read" | "execute",
|
||||
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) =>
|
||||
fn(client, host, req),
|
||||
);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagerInputError) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof AccessDeniedError) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
if (error instanceof ElevationError) {
|
||||
return res.status(403).json({ error: error.message, code: error.code });
|
||||
}
|
||||
statsLogger.error(`Manager operation failed: ${operation}`, {
|
||||
operation,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : "Operation failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidSystemdUnit,
|
||||
isValidServiceAction,
|
||||
type ServiceAction,
|
||||
} from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemdService {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const LIST_SERVICES_CMD =
|
||||
"systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null";
|
||||
|
||||
/** Parse `systemctl list-units --plain` output into structured rows. */
|
||||
export function parseServiceList(output: string): SystemdService[] {
|
||||
const services: SystemdService[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
// Columns: UNIT LOAD ACTIVE SUB DESCRIPTION (description may contain spaces)
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 4) continue;
|
||||
const [unit, load, active, sub, ...rest] = parts;
|
||||
if (!unit.endsWith(".service")) continue;
|
||||
services.push({
|
||||
unit,
|
||||
load,
|
||||
active,
|
||||
sub,
|
||||
description: rest.join(" "),
|
||||
});
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
export function buildServiceActionCommand(
|
||||
unit: string,
|
||||
action: ServiceAction,
|
||||
): string {
|
||||
return `systemctl ${action} ${unit}`;
|
||||
}
|
||||
|
||||
export function registerServiceRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}:
|
||||
* get:
|
||||
* summary: List systemd services
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* responses:
|
||||
* 200: { description: List of services. }
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/services/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "services_list", async (client) => {
|
||||
const { stdout } = await execCommand(client, LIST_SERVICES_CMD, 20000);
|
||||
return { services: parseServiceList(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/services/{id}/action:
|
||||
* post:
|
||||
* summary: Start/stop/restart/enable/disable a systemd service
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* unit: { type: string }
|
||||
* action: { type: string }
|
||||
* responses:
|
||||
* 200: { description: Action result. }
|
||||
* 400: { description: Invalid unit or action. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/services/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"services_action",
|
||||
async (client, host, req) => {
|
||||
const { unit, action } = req.body as {
|
||||
unit?: string;
|
||||
action?: string;
|
||||
};
|
||||
if (!isValidSystemdUnit(unit)) {
|
||||
throw new ManagerInputError("Invalid unit name");
|
||||
}
|
||||
if (!isValidServiceAction(action)) {
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
buildServiceActionCommand(unit, action),
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
// ─── Top by memory ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MemProcessRow {
|
||||
pid: number;
|
||||
user: string;
|
||||
mem: number;
|
||||
rss: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const TOP_MEM_CMD =
|
||||
"ps -eo pid,user:20,pmem,rss,comm --sort=-pmem --no-headers 2>/dev/null | head -n 20";
|
||||
|
||||
export function parseTopMemory(output: string): MemProcessRow[] {
|
||||
const rows: MemProcessRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\d+)\s+(\S+)\s+([\d.]+)\s+(\d+)\s+(.*)$/);
|
||||
if (!m) continue;
|
||||
rows.push({
|
||||
pid: Number(m[1]),
|
||||
user: m[2],
|
||||
mem: Number(m[3]),
|
||||
rss: Number(m[4]),
|
||||
command: m[5],
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Systemd timers ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimerRow {
|
||||
next: string;
|
||||
left: string;
|
||||
last: string;
|
||||
unit: string;
|
||||
activates: string;
|
||||
}
|
||||
|
||||
const TIMERS_CMD =
|
||||
"systemctl list-timers --all --no-legend --no-pager 2>/dev/null";
|
||||
|
||||
export function parseTimers(output: string): TimerRow[] {
|
||||
const rows: TimerRow[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("NEXT")) continue;
|
||||
// NEXT(3) LEFT(2) LAST(3) PASSED(2) UNIT ACTIVATES -> columns vary; grab
|
||||
// the trailing UNIT + ACTIVATES which always end the line.
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 2) continue;
|
||||
const activates = parts[parts.length - 1];
|
||||
const unit = parts[parts.length - 2];
|
||||
if (!unit.endsWith(".timer")) continue;
|
||||
rows.push({
|
||||
next: parts.slice(0, 3).join(" "),
|
||||
left: "",
|
||||
last: "",
|
||||
unit,
|
||||
activates,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Disk breakdown (per-mount) ─────────────────────────────────────────────
|
||||
|
||||
export interface MountUsage {
|
||||
filesystem: string;
|
||||
sizeKb: number;
|
||||
usedKb: number;
|
||||
availKb: number;
|
||||
usePct: number;
|
||||
mount: string;
|
||||
}
|
||||
|
||||
const DF_CMD = "df -Pk 2>/dev/null | tail -n +2";
|
||||
|
||||
export function parseDfMounts(output: string): MountUsage[] {
|
||||
const mounts: MountUsage[] = [];
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(/^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)%\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
const fs = m[1];
|
||||
// Skip pseudo/virtual filesystems that clutter the view.
|
||||
if (/^(tmpfs|devtmpfs|overlay|udev|none|shm)$/.test(fs)) continue;
|
||||
mounts.push({
|
||||
filesystem: fs,
|
||||
sizeKb: Number(m[2]),
|
||||
usedKb: Number(m[3]),
|
||||
availKb: Number(m[4]),
|
||||
usePct: Number(m[5]),
|
||||
mount: m[6],
|
||||
});
|
||||
}
|
||||
return mounts;
|
||||
}
|
||||
|
||||
export function registerSimpleReadRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/top-memory/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "top_memory", async (client) => {
|
||||
const { stdout } = await execCommand(client, TOP_MEM_CMD, 15000);
|
||||
return { processes: parseTopMemory(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/timers/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "systemd_timers", async (client) => {
|
||||
const { stdout } = await execCommand(client, TIMERS_CMD, 15000);
|
||||
return { timers: parseTimers(stdout) };
|
||||
}),
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/host-metrics/managers/disk-breakdown/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "disk_breakdown", async (client) => {
|
||||
const { stdout } = await execCommand(client, DF_CMD, 15000);
|
||||
return { mounts: parseDfMounts(stdout) };
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated, shellSingleQuote } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import {
|
||||
isValidDomain,
|
||||
isValidDnsProvider,
|
||||
isAllowedPath,
|
||||
} from "./validation.js";
|
||||
import { detectPlatform } from "./platform.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export type AcmeClient = "certbot" | "acme.sh";
|
||||
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
|
||||
|
||||
export interface CertInfo {
|
||||
client: AcmeClient | "other";
|
||||
name: string;
|
||||
domains: string[];
|
||||
expiry: string | null;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
|
||||
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
|
||||
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
|
||||
|
||||
/** Parse `certbot certificates` output. */
|
||||
export function parseCertbotCertificates(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
let current: CertInfo | null = null;
|
||||
for (const raw of output.split("\n")) {
|
||||
const line = raw.trim();
|
||||
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
|
||||
if (nameM) {
|
||||
if (current) certs.push(current);
|
||||
current = {
|
||||
client: "certbot",
|
||||
name: nameM[1],
|
||||
domains: [],
|
||||
expiry: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const domM = line.match(/^Domains:\s+(.+)$/);
|
||||
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
|
||||
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
|
||||
if (expM) current.expiry = expM[1];
|
||||
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
|
||||
if (pathM) current.path = pathM[1];
|
||||
}
|
||||
if (current) certs.push(current);
|
||||
return certs;
|
||||
}
|
||||
|
||||
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
|
||||
export function parseAcmeShList(output: string): CertInfo[] {
|
||||
const certs: CertInfo[] = [];
|
||||
const lines = output.split("\n").filter((l) => l.trim());
|
||||
if (lines.length < 2) return certs;
|
||||
for (const line of lines.slice(1)) {
|
||||
const cols = line
|
||||
.split(/\s{2,}|\t/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
if (cols.length < 1) continue;
|
||||
certs.push({
|
||||
client: "acme.sh",
|
||||
name: cols[0],
|
||||
domains: [cols[0]],
|
||||
expiry: cols[cols.length - 1] || null,
|
||||
});
|
||||
}
|
||||
return certs;
|
||||
}
|
||||
|
||||
/**
|
||||
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
|
||||
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
|
||||
*/
|
||||
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
|
||||
export function isValidCertName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
|
||||
}
|
||||
|
||||
export interface IssueRequest {
|
||||
client: AcmeClient;
|
||||
domains: string[];
|
||||
challenge: ChallengeType;
|
||||
webroot?: string;
|
||||
dnsProvider?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the certificate issuance command for the chosen client/challenge.
|
||||
* DNS-01 provider credentials are expected to already be present in the
|
||||
* environment / provider config file; we never put secrets in argv.
|
||||
*/
|
||||
export function buildIssueCommand(req: IssueRequest): string {
|
||||
const domains = req.domains;
|
||||
if (req.client === "certbot") {
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
|
||||
}
|
||||
// acme.sh
|
||||
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
||||
if (req.challenge === "dns") {
|
||||
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
|
||||
}
|
||||
if (req.challenge === "http-webroot") {
|
||||
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
|
||||
req.webroot as string,
|
||||
)} ${dFlags}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
|
||||
}
|
||||
|
||||
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot renew${dryRun ? " --dry-run" : ""}`;
|
||||
}
|
||||
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
|
||||
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
|
||||
* it from management. `name` is the certbot cert name or the acme.sh domain.
|
||||
*/
|
||||
export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
||||
if (client === "certbot") {
|
||||
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
|
||||
name,
|
||||
)} --delete-after-revoke`;
|
||||
}
|
||||
const d = shellSingleQuote(name);
|
||||
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
||||
}
|
||||
|
||||
export function registerSslRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/ssl/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "ssl_list", async (client, host) => {
|
||||
const platform = await detectPlatform(client);
|
||||
const certs: CertInfo[] = [];
|
||||
if (platform.hasCertbot) {
|
||||
const r = await execElevated(
|
||||
client,
|
||||
CERTBOT_LIST_CMD,
|
||||
host.sudoPassword,
|
||||
).catch(() => null);
|
||||
if (r) certs.push(...parseCertbotCertificates(r.stdout));
|
||||
}
|
||||
if (platform.hasAcmeSh) {
|
||||
const { stdout } = await execCommand(
|
||||
client,
|
||||
ACMESH_LIST_CMD,
|
||||
15000,
|
||||
).catch(() => ({ stdout: "" }) as { stdout: string });
|
||||
certs.push(...parseAcmeShList(stdout));
|
||||
}
|
||||
return {
|
||||
clients: {
|
||||
certbot: platform.hasCertbot,
|
||||
acmeSh: platform.hasAcmeSh,
|
||||
},
|
||||
certs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/issue",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"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,
|
||||
"execute",
|
||||
"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),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/ssl/{id}/revoke:
|
||||
* post:
|
||||
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
|
||||
* tags: [Host Metrics]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: integer }
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* client: { type: string, enum: [certbot, acme.sh] }
|
||||
* name: { type: string, description: certbot cert name or acme.sh domain }
|
||||
* responses:
|
||||
* 200: { description: Revoke result. }
|
||||
* 400: { description: Invalid client or certificate name. }
|
||||
* 403: { description: Elevation required or denied. }
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/ssl/:id/revoke",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"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),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import { isValidTailscaleAction } from "./validation.js";
|
||||
|
||||
export interface TailscalePeer {
|
||||
hostname: string;
|
||||
tailscaleIPs: string[];
|
||||
online: boolean;
|
||||
isExitNode: boolean;
|
||||
}
|
||||
|
||||
export interface TailscaleData {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
tailscaleIPs: string[];
|
||||
hostname: string | null;
|
||||
peers: TailscalePeer[];
|
||||
exitNodeInUse: boolean;
|
||||
}
|
||||
|
||||
const PROBE_CMD = [
|
||||
"command -v tailscale >/dev/null 2>&1 && echo ts_installed=1 || echo ts_installed=0",
|
||||
"tailscale status --json 2>/dev/null",
|
||||
].join("; ");
|
||||
|
||||
export function parseTailscaleData(output: string): TailscaleData {
|
||||
const notInstalled: TailscaleData = {
|
||||
installed: false,
|
||||
running: false,
|
||||
tailscaleIPs: [],
|
||||
hostname: null,
|
||||
peers: [],
|
||||
exitNodeInUse: false,
|
||||
};
|
||||
|
||||
if (output.includes("ts_installed=0")) return notInstalled;
|
||||
|
||||
// Strip the installation probe line to get the raw JSON
|
||||
const lines = output.split("\n");
|
||||
const jsonLines = lines.filter(
|
||||
(l) => !l.startsWith("ts_installed=") && l.trim() !== "",
|
||||
);
|
||||
const jsonStr = jsonLines.join("\n");
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr) as {
|
||||
BackendState?: string;
|
||||
Self?: { HostName?: string; TailscaleIPs?: string[] };
|
||||
Peer?: Record<
|
||||
string,
|
||||
{
|
||||
HostName?: string;
|
||||
TailscaleIPs?: string[];
|
||||
Online?: boolean;
|
||||
ExitNode?: boolean;
|
||||
}
|
||||
>;
|
||||
CurrentExitNode?: string;
|
||||
};
|
||||
|
||||
const peers: TailscalePeer[] = Object.values(parsed.Peer ?? {}).map(
|
||||
(p) => ({
|
||||
hostname: p.HostName ?? "",
|
||||
tailscaleIPs: p.TailscaleIPs ?? [],
|
||||
online: p.Online ?? false,
|
||||
isExitNode: p.ExitNode ?? false,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
running: parsed.BackendState === "Running",
|
||||
tailscaleIPs: parsed.Self?.TailscaleIPs ?? [],
|
||||
hostname: parsed.Self?.HostName ?? null,
|
||||
peers,
|
||||
exitNodeInUse:
|
||||
typeof parsed.CurrentExitNode === "string" &&
|
||||
parsed.CurrentExitNode !== "",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: true,
|
||||
running: false,
|
||||
tailscaleIPs: [],
|
||||
hostname: null,
|
||||
peers: [],
|
||||
exitNodeInUse: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTailscaleRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/tailscale/{id}:
|
||||
* get:
|
||||
* summary: Get Tailscale status and IPs
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tailscale installation status, running state, IPs, and peer count.
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/tailscale/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "tailscale_read", async (client) => {
|
||||
const { stdout } = await execCommand(client, PROBE_CMD, 15000);
|
||||
return parseTailscaleData(stdout);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/tailscale/{id}/action:
|
||||
* post:
|
||||
* summary: Connect or disconnect Tailscale
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [up, down]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Action result.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/tailscale/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"tailscale_action",
|
||||
async (client, host, req) => {
|
||||
const { action } = req.body as { action: unknown };
|
||||
if (!isValidTailscaleAction(action)) {
|
||||
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
`tailscale ${action}`,
|
||||
host.sudoPassword,
|
||||
{ forceSudo: false, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout + result.stderr).trim(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
/** Minimal host shape managers need (includes the decrypted sudo password). */
|
||||
export interface ManagerHost {
|
||||
id: number;
|
||||
userId: 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.
|
||||
*/
|
||||
export type RunOnHost = <T>(
|
||||
hostId: number,
|
||||
userId: string,
|
||||
level: "read" | "execute",
|
||||
fn: (client: Client, host: ManagerHost) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
|
||||
export interface ManagerRoutesDeps {
|
||||
validateHostId: RequestHandler;
|
||||
runOnHost: RunOnHost;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Express } from "express";
|
||||
import { execCommand } from "../widgets/common-utils.js";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
||||
import { isValidUsername, isValidGroupName } from "./validation.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
|
||||
export interface SystemUser {
|
||||
name: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
shell: string;
|
||||
}
|
||||
|
||||
export interface SystemGroup {
|
||||
name: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
}
|
||||
|
||||
// Human users only (uid >= 1000, excluding nobody at 65534).
|
||||
const READ_USERS_CMD = "getent passwd 2>/dev/null";
|
||||
const READ_GROUPS_CMD = "getent group 2>/dev/null";
|
||||
const READ_SUDOERS_CMD = "getent group sudo wheel 2>/dev/null";
|
||||
|
||||
export function parsePasswd(output: string): SystemUser[] {
|
||||
const users: SystemUser[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 7) continue;
|
||||
const uid = Number(parts[2]);
|
||||
if (!Number.isFinite(uid)) continue;
|
||||
if (uid < 1000 || uid === 65534) continue;
|
||||
users.push({
|
||||
name: parts[0],
|
||||
uid,
|
||||
gid: Number(parts[3]),
|
||||
home: parts[5],
|
||||
shell: parts[6],
|
||||
});
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
export function parseGroups(output: string): SystemGroup[] {
|
||||
const groups: SystemGroup[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
groups.push({
|
||||
name: parts[0],
|
||||
gid: Number(parts[2]),
|
||||
members: parts[3].split(",").filter(Boolean),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function parseSudoers(output: string): string[] {
|
||||
const members = new Set<string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split(":");
|
||||
if (parts.length < 4) continue;
|
||||
parts[3]
|
||||
.split(",")
|
||||
.filter(Boolean)
|
||||
.forEach((m) => members.add(m));
|
||||
}
|
||||
return [...members];
|
||||
}
|
||||
|
||||
export type UserAction = "create" | "delete" | "addToGroup" | "removeFromGroup";
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
app.get(
|
||||
"/host-metrics/managers/users/:id",
|
||||
validateHostId,
|
||||
managerHandler(runOnHost, "read", "users_list", async (client) => {
|
||||
const [passwd, groups, sudoers] = await Promise.all([
|
||||
execCommand(client, READ_USERS_CMD, 15000),
|
||||
execCommand(client, READ_GROUPS_CMD, 15000),
|
||||
execCommand(client, READ_SUDOERS_CMD, 15000),
|
||||
]);
|
||||
return {
|
||||
users: parsePasswd(passwd.stdout),
|
||||
groups: parseGroups(groups.stdout),
|
||||
sudoers: parseSudoers(sudoers.stdout),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/host-metrics/managers/users/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"users_action",
|
||||
async (client, host, req) => {
|
||||
const { action, username, group } = req.body as {
|
||||
action?: UserAction;
|
||||
username?: string;
|
||||
group?: string;
|
||||
};
|
||||
if (!isValidUsername(username))
|
||||
throw new ManagerInputError("Invalid username");
|
||||
|
||||
// Never modify/delete the user we're connected as, or root.
|
||||
const who = (await execCommand(client, "id -un", 8000)).stdout.trim();
|
||||
if (username === who || username === "root") {
|
||||
throw new ManagerInputError(
|
||||
"Refusing to modify the connected user or root",
|
||||
);
|
||||
}
|
||||
|
||||
let cmd: string;
|
||||
switch (action) {
|
||||
case "create":
|
||||
cmd = `useradd -m ${username}`;
|
||||
break;
|
||||
case "delete":
|
||||
cmd = `userdel -r ${username}`;
|
||||
break;
|
||||
case "addToGroup":
|
||||
case "removeFromGroup":
|
||||
if (!isValidGroupName(group))
|
||||
throw new ManagerInputError("Invalid group");
|
||||
cmd =
|
||||
action === "addToGroup"
|
||||
? `usermod -aG ${group} ${username}`
|
||||
: `gpasswd -d ${username} ${group}`;
|
||||
break;
|
||||
default:
|
||||
throw new ManagerInputError("Invalid action");
|
||||
}
|
||||
|
||||
const result = await execElevated(client, cmd, host.sudoPassword, {
|
||||
forceSudo: true,
|
||||
});
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: result.stdout || result.stderr,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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}/`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Express } from "express";
|
||||
import { execElevated } from "./exec-elevated.js";
|
||||
import { managerHandler } from "./route-helpers.js";
|
||||
import { ManagerInputError } from "./route-helpers.js";
|
||||
import type { ManagerRoutesDeps } from "./types.js";
|
||||
import {
|
||||
isValidWireGuardInterface,
|
||||
isValidWireGuardAction,
|
||||
} from "./validation.js";
|
||||
|
||||
export interface WireGuardPeer {
|
||||
publicKey: string;
|
||||
endpoint: string | null;
|
||||
allowedIPs: string[];
|
||||
latestHandshake: number | null;
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
}
|
||||
|
||||
export interface WireGuardInterface {
|
||||
name: string;
|
||||
publicKey: string | null;
|
||||
listenPort: number | null;
|
||||
up: boolean;
|
||||
peers: WireGuardPeer[];
|
||||
}
|
||||
|
||||
export interface WireGuardData {
|
||||
installed: boolean;
|
||||
interfaces: WireGuardInterface[];
|
||||
}
|
||||
|
||||
const PROBE_CMD = [
|
||||
"command -v wg >/dev/null 2>&1 && echo wg_installed=1 || echo wg_installed=0",
|
||||
"ip link show type wireguard 2>/dev/null",
|
||||
"echo __DUMP__",
|
||||
"wg show all dump 2>/dev/null",
|
||||
].join("; ");
|
||||
|
||||
export function parseWireGuardData(output: string): WireGuardData {
|
||||
if (output.includes("wg_installed=0")) {
|
||||
return { installed: false, interfaces: [] };
|
||||
}
|
||||
|
||||
const dumpIdx = output.indexOf("__DUMP__");
|
||||
const ipLinkPart = dumpIdx >= 0 ? output.slice(0, dumpIdx) : "";
|
||||
const dumpPart =
|
||||
dumpIdx >= 0 ? output.slice(dumpIdx + "__DUMP__".length) : "";
|
||||
|
||||
// Collect up interface names from `ip link show type wireguard`
|
||||
const upSet = new Set<string>();
|
||||
for (const line of ipLinkPart.split("\n")) {
|
||||
const m = line.match(/^\d+:\s+([A-Za-z0-9_-]+):/);
|
||||
if (m) upSet.add(m[1]);
|
||||
}
|
||||
|
||||
const ifaceMap = new Map<string, WireGuardInterface>();
|
||||
|
||||
for (const raw of dumpPart.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const cols = line.split("\t");
|
||||
|
||||
if (cols.length === 5) {
|
||||
// Interface row: iface private_key public_key listen_port fwmark
|
||||
const [name, , publicKey, listenPortStr] = cols;
|
||||
if (!name) continue;
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: publicKey && publicKey !== "(none)" ? publicKey : null,
|
||||
listenPort:
|
||||
listenPortStr && listenPortStr !== "(none)"
|
||||
? Number(listenPortStr)
|
||||
: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
} else if (cols.length === 9) {
|
||||
// Peer row: iface public_key preshared_key endpoint allowed_ips latest_handshake rx_bytes tx_bytes persistent_keepalive
|
||||
const [
|
||||
name,
|
||||
publicKey,
|
||||
,
|
||||
endpoint,
|
||||
allowedIPsStr,
|
||||
handshakeStr,
|
||||
rxStr,
|
||||
txStr,
|
||||
] = cols;
|
||||
if (!name) continue;
|
||||
|
||||
if (!ifaceMap.has(name)) {
|
||||
ifaceMap.set(name, {
|
||||
name,
|
||||
publicKey: null,
|
||||
listenPort: null,
|
||||
up: upSet.has(name),
|
||||
peers: [],
|
||||
});
|
||||
}
|
||||
|
||||
const handshake = Number(handshakeStr);
|
||||
ifaceMap.get(name)!.peers.push({
|
||||
publicKey: publicKey ?? "",
|
||||
endpoint: endpoint && endpoint !== "(none)" ? endpoint : null,
|
||||
allowedIPs:
|
||||
allowedIPsStr && allowedIPsStr !== "(none)"
|
||||
? allowedIPsStr.split(",").map((s) => s.trim())
|
||||
: [],
|
||||
latestHandshake: handshake > 0 ? handshake : null,
|
||||
rxBytes: Number(rxStr) || 0,
|
||||
txBytes: Number(txStr) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { installed: true, interfaces: Array.from(ifaceMap.values()) };
|
||||
}
|
||||
|
||||
export function registerWireGuardRoutes(
|
||||
app: Express,
|
||||
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}:
|
||||
* get:
|
||||
* summary: Get WireGuard interfaces and peers
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: WireGuard installation status, interfaces, and peer details.
|
||||
*/
|
||||
app.get(
|
||||
"/host-metrics/managers/wireguard/:id",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"read",
|
||||
"wireguard_read",
|
||||
async (client, host) => {
|
||||
const result = await execElevated(
|
||||
client,
|
||||
PROBE_CMD,
|
||||
host.sudoPassword,
|
||||
{
|
||||
forceSudo: false,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
);
|
||||
return parseWireGuardData(result.stdout);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host-metrics/managers/wireguard/{id}/action:
|
||||
* post:
|
||||
* summary: Bring a WireGuard interface up or down
|
||||
* tags:
|
||||
* - Host Metrics
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* interface:
|
||||
* type: string
|
||||
* action:
|
||||
* type: string
|
||||
* enum: [up, down]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Action result.
|
||||
*/
|
||||
app.post(
|
||||
"/host-metrics/managers/wireguard/:id/action",
|
||||
validateHostId,
|
||||
managerHandler(
|
||||
runOnHost,
|
||||
"execute",
|
||||
"wireguard_action",
|
||||
async (client, host, req) => {
|
||||
const { interface: iface, action } = req.body as {
|
||||
interface: unknown;
|
||||
action: unknown;
|
||||
};
|
||||
if (!isValidWireGuardInterface(iface)) {
|
||||
throw new ManagerInputError("Invalid WireGuard interface name");
|
||||
}
|
||||
if (!isValidWireGuardAction(action)) {
|
||||
throw new ManagerInputError("Invalid action, must be 'up' or 'down'");
|
||||
}
|
||||
const result = await execElevated(
|
||||
client,
|
||||
`wg-quick ${action} ${iface}`,
|
||||
host.sudoPassword,
|
||||
{ forceSudo: true, timeoutMs: 30000 },
|
||||
);
|
||||
return {
|
||||
success: result.code === 0,
|
||||
output: (result.stdout + result.stderr).trim(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user