mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-17 05:43:36 +00:00
93 lines
3.1 KiB
TypeScript
93 lines
3.1 KiB
TypeScript
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, "connect", "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,
|
|
"connect",
|
|
"logs_tail",
|
|
async (client, host, req) => {
|
|
const path = req.query.path as string | undefined;
|
|
const unit = req.query.unit as string | undefined;
|
|
const lines = clampLines(req.query.lines);
|
|
|
|
let cmd: string;
|
|
if (unit) {
|
|
if (!isValidSystemdUnit(unit))
|
|
throw new ManagerInputError("Invalid unit");
|
|
cmd = buildJournalCommand(unit, lines);
|
|
} else if (path) {
|
|
if (!isAllowedPath(path, LOG_PATH_ALLOWLIST)) {
|
|
throw new ManagerInputError("Path not allowed");
|
|
}
|
|
cmd = buildTailCommand(path, lines);
|
|
} else {
|
|
throw new ManagerInputError("Provide a path or unit");
|
|
}
|
|
|
|
// Try unprivileged; many logs need root (auth.log, etc.).
|
|
const result = await execElevated(client, cmd, host.sudoPassword);
|
|
return { content: result.stdout, lines };
|
|
},
|
|
),
|
|
);
|
|
}
|