mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
buildSudoCommand,
|
|
shellSingleQuote,
|
|
} from "../../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
|
import { parsePlatformProbe } from "../../../../../src/backend/hosts/metrics-shared/platform.js";
|
|
import {
|
|
isValidSystemdUnit,
|
|
isValidPid,
|
|
isValidPort,
|
|
isValidPackageName,
|
|
isValidUsername,
|
|
isValidDomain,
|
|
isValidDnsProvider,
|
|
isValidSignal,
|
|
isValidServiceAction,
|
|
isAllowedPath,
|
|
} from "../../../../../src/backend/hosts/metrics-shared/validation.js";
|
|
import {
|
|
parseServiceList,
|
|
buildServiceActionCommand,
|
|
} from "../../../src/backend/managers/services.js";
|
|
import {
|
|
parseProcessList,
|
|
buildKillCommand,
|
|
} from "../../../src/backend/managers/processes.js";
|
|
import {
|
|
parseDfMounts,
|
|
parseTopMemory,
|
|
} from "../../../src/backend/managers/simple-reads.js";
|
|
import {
|
|
parseCrontab,
|
|
serializeCrontab,
|
|
isValidCronSchedule,
|
|
buildApplyCrontabCommand,
|
|
} from "../../../src/backend/managers/cron.js";
|
|
import {
|
|
buildPackageActionCommand,
|
|
parseUpgradable,
|
|
buildListUpgradableCommand,
|
|
} from "../../../../../src/backend/hosts/metrics-shared/package-commands.js";
|
|
import {
|
|
buildIssueCommand,
|
|
buildRenewCommand,
|
|
buildRevokeCommand,
|
|
isValidCertName,
|
|
parseCertbotCertificates,
|
|
} from "../../../src/backend/managers/ssl.js";
|
|
import {
|
|
buildIptablesRuleCommand,
|
|
buildNftRuleCommand,
|
|
} from "../../../src/backend/managers/firewall.js";
|
|
import {
|
|
parsePasswd,
|
|
parseSudoers,
|
|
} from "../../../src/backend/managers/users.js";
|
|
import {
|
|
buildHealthCheckCommand,
|
|
parseHealthResult,
|
|
} from "../../../src/backend/managers/health.js";
|
|
import {
|
|
buildTailCommand,
|
|
clampLines,
|
|
} from "../../../src/backend/managers/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'",
|
|
);
|
|
});
|
|
});
|