import { mkdtemp, readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), })); vi.mock("node:child_process", () => ({ execFile: execFileMock, })); import { buildFail2BanOverrideContent, getSystemSecuritySettings, resetSecuritySettingsCacheForTests, updateSystemSecuritySettings, } from "@/lib/server/modules/system/security-service"; function resolveExecFileCallback(args: unknown[]) { const maybeCallback = args.at(-1); if (typeof maybeCallback !== "function") { throw new Error("Expected execFile callback"); } return maybeCallback as (error: Error | null, stdout?: string, stderr?: string) => void; } describe("security-service", () => { beforeEach(async () => { execFileMock.mockReset(); resetSecuritySettingsCacheForTests(); const root = await mkdtemp(path.join(os.tmpdir(), "homeio-security-")); process.env.HOMEIO_FAIL2BAN_OVERRIDE_PATH = path.join(root, "jail.d", "homeio.local"); }); it("reads the host once for repeated Settings loads, and again after a change", async () => { execFileMock.mockImplementation((_command: string, _args: string[], ...rest: unknown[]) => { resolveExecFileCallback(rest)(null, "", ""); }); const ufwStatusReads = () => execFileMock.mock.calls.filter(([command, args]) => command === "ufw" && args[0] === "status").length; await getSystemSecuritySettings(); await getSystemSecuritySettings(); expect(ufwStatusReads()).toBe(1); await updateSystemSecuritySettings({ firewallEnabled: true, firewallIncomingPolicy: "deny", firewallOutgoingPolicy: "allow", fail2banEnabled: false, fail2banMaxRetries: 5, fail2banBanDurationSeconds: 3_600, }).catch(() => {}); await getSystemSecuritySettings(); expect(ufwStatusReads()).toBe(2); }); it("reads UFW and Fail2Ban state from the host", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "ufw") { callback( null, "Status: active\nDefault: deny (incoming), allow (outgoing), disabled (routed)\n", "", ); return; } if (command === "systemctl") { callback(null, "enabled\n", ""); return; } callback(null, "", ""); }); const settings = await getSystemSecuritySettings(); expect(settings).toEqual({ firewallEnabled: true, firewallIncomingPolicy: "deny", firewallOutgoingPolicy: "allow", fail2banEnabled: true, fail2banMaxRetries: 5, fail2banBanDurationSeconds: 3600, }); }); it("treats missing Fail2Ban service as unavailable but non-fatal state", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "ufw") { callback( null, "Status: active\nDefault: deny (incoming), allow (outgoing), disabled (routed)\n", "", ); return; } if (command === "systemctl") { const error = new Error("Command failed: systemctl is-enabled fail2ban") as Error & { stdout?: string; stderr?: string; }; error.stdout = "not-found\n"; error.stderr = "Unit fail2ban.service could not be found.\n"; callback(error, "not-found\n", "Unit fail2ban.service could not be found.\n"); return; } callback(null, "", ""); }); const settings = await getSystemSecuritySettings(); expect(settings.fail2banEnabled).toBe(false); expect(settings.fail2banMaxRetries).toBe(5); expect(settings.fail2banBanDurationSeconds).toBe(3600); }); it("returns defaults when UFW is not installed", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "ufw") { const error = Object.assign(new Error("spawn ufw ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } if (command === "systemctl") { callback(null, "enabled\n", ""); return; } callback(null, "", ""); }); const settings = await getSystemSecuritySettings(); expect(settings.firewallEnabled).toBe(false); expect(settings.firewallIncomingPolicy).toBe("deny"); expect(settings.firewallOutgoingPolicy).toBe("allow"); expect(settings.fail2banEnabled).toBe(true); }); it("returns defaults when systemctl is not available (non-systemd host)", async () => { execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { const callback = resolveExecFileCallback(rest); if (command === "ufw") { callback( null, "Status: active\nDefault: deny (incoming), allow (outgoing), disabled (routed)\n", "", ); return; } if (command === "systemctl") { const error = Object.assign(new Error("spawn systemctl ENOENT"), { code: "ENOENT" }); callback(error as Error, "", ""); return; } callback(null, "", ""); }); const settings = await getSystemSecuritySettings(); expect(settings.firewallEnabled).toBe(true); expect(settings.fail2banEnabled).toBe(false); }); it("writes managed Fail2Ban override config correctly", async () => { const content = buildFail2BanOverrideContent({ maxRetries: 7, banDurationSeconds: 7200, }); expect(content).toContain("[DEFAULT]"); expect(content).toContain("maxretry = 7"); expect(content).toContain("bantime = 7200"); }); it("applies UFW and Fail2Ban commands in the correct order", async () => { const calls: Array<{ command: string; args: string[] }> = []; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { calls.push({ command, args }); const callback = resolveExecFileCallback(rest); if (command === "ufw" && args[0] === "status") { callback( null, "Status: active\nDefault: deny (incoming), allow (outgoing), disabled (routed)\n", "", ); return; } if (command === "systemctl" && args[0] === "is-enabled") { callback(null, "enabled\n", ""); return; } callback(null, "", ""); }); await updateSystemSecuritySettings({ firewallEnabled: true, firewallIncomingPolicy: "reject", firewallOutgoingPolicy: "deny", fail2banEnabled: true, fail2banMaxRetries: 7, fail2banBanDurationSeconds: 7200, }); expect(calls).toEqual( expect.arrayContaining([ { command: "ufw", args: ["default", "reject", "incoming"] }, { command: "ufw", args: ["default", "deny", "outgoing"] }, { command: "ufw", args: ["--force", "enable"] }, { command: "systemctl", args: ["enable", "--now", "fail2ban"] }, { command: "systemctl", args: ["restart", "fail2ban"] }, ]), ); const overrideContent = await readFile( process.env.HOMEIO_FAIL2BAN_OVERRIDE_PATH!, "utf8", ); expect(overrideContent).toContain("maxretry = 7"); expect(overrideContent).toContain("bantime = 7200"); }); it("disables Fail2Ban and UFW with the correct commands", async () => { const calls: Array<{ command: string; args: string[] }> = []; execFileMock.mockImplementation((command: string, args: string[], ...rest: unknown[]) => { calls.push({ command, args }); const callback = resolveExecFileCallback(rest); if (command === "ufw" && args[0] === "status") { callback(null, "Status: inactive\n", ""); return; } if (command === "systemctl" && args[0] === "is-enabled") { const error = new Error("disabled") as Error & { stdout?: string }; error.stdout = "disabled\n"; callback(error, "disabled\n", ""); return; } callback(null, "", ""); }); await updateSystemSecuritySettings({ firewallEnabled: false, firewallIncomingPolicy: "deny", firewallOutgoingPolicy: "allow", fail2banEnabled: false, fail2banMaxRetries: 5, fail2banBanDurationSeconds: 3600, }); expect(calls).toEqual( expect.arrayContaining([ { command: "ufw", args: ["--force", "disable"] }, { command: "systemctl", args: ["disable", "--now", "fail2ban"] }, ]), ); }); });