mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,440 @@
|
||||
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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user