IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

security-service.ts

Buka raw
import "server-only";

import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { memoizeAsync } from "@/lib/server/cache/memoize-async";
import {
  SYSTEM_SECURITY_BAN_DURATION_MAX,
  SYSTEM_SECURITY_BAN_DURATION_MIN,
  SYSTEM_SECURITY_MAX_RETRIES_MAX,
  SYSTEM_SECURITY_MAX_RETRIES_MIN,
  SYSTEM_SECURITY_POLICY_OPTIONS,
  type SystemSecurityPolicy,
  type SystemSecuritySettings,
} from "@/lib/shared/contracts/system";

const SECURITY_POLICY_SET = new Set<SystemSecurityPolicy>(SYSTEM_SECURITY_POLICY_OPTIONS);
const DEFAULT_FIREWALL_INCOMING_POLICY: SystemSecurityPolicy = "deny";
const DEFAULT_FIREWALL_OUTGOING_POLICY: SystemSecurityPolicy = "allow";
const DEFAULT_FAIL2BAN_MAX_RETRIES = 5;
const DEFAULT_FAIL2BAN_BAN_DURATION_SECONDS = 3_600;
const SECURITY_CACHE_TTL_MS = 30_000;

function resolveFail2BanOverridePath() {
  return process.env.HOMEIO_FAIL2BAN_OVERRIDE_PATH ?? "/etc/fail2ban/jail.d/homeio.local";
}

function normalizeSecurityPolicy(value: string, fieldName: string): SystemSecurityPolicy {
  const normalized = value.trim().toLowerCase() as SystemSecurityPolicy;
  if (!SECURITY_POLICY_SET.has(normalized)) {
    throw new Error(`Invalid ${fieldName}. Choose allow, deny, or reject.`);
  }
  return normalized;
}

function normalizeBoundedInteger(
  fieldName: string,
  value: number,
  min: number,
  max: number,
) {
  if (!Number.isInteger(value) || value < min || value > max) {
    throw new Error(`${fieldName} must be an integer between ${min} and ${max}.`);
  }
  return value;
}

function toErrorMessage(error: unknown) {
  if (error instanceof Error) return error.message;
  return String(error);
}

function getExecFailureDetails(error: unknown) {
  if (!error || typeof error !== "object") {
    return { stdout: "", stderr: "", message: String(error ?? "") };
  }

  const execError = error as Error & { stdout?: string; stderr?: string };
  return {
    stdout: execError.stdout ?? "",
    stderr: execError.stderr ?? "",
    message: execError.message,
  };
}

function isToolUnavailable(error: unknown) {
  const { stdout, stderr, message } = getExecFailureDetails(error);
  const haystack = `${message}\n${stdout}\n${stderr}`.toLowerCase();
  return (
    haystack.includes("enoent") ||
    haystack.includes("not found") ||
    haystack.includes("not-found") ||
    haystack.includes("could not be found") ||
    haystack.includes("no such file") ||
    haystack.includes("unit fail2ban.service could not be found")
  );
}

async function readCommandOutput(command: string, args: string[]) {
  const { stdout } = await execFileAsync(command, args);
  return stdout.trim();
}

function execFileAsync(command: string, args: string[]) {
  return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
    execFile(command, args, (error, stdout = "", stderr = "") => {
      if (error) {
        const execError = error as Error & { stdout?: string; stderr?: string };
        execError.stdout = stdout;
        execError.stderr = stderr;
        reject(execError);
        return;
      }

      resolve({ stdout, stderr });
    });
  });
}

async function readUfwStatus() {
  let stdout = "";

  try {
    stdout = await readCommandOutput("ufw", ["status", "verbose"]);
  } catch (error) {
    if (isToolUnavailable(error)) {
      return null;
    }
    throw error;
  }

  const enabled = /^status:\s+active$/im.test(stdout);
  const defaultLine = stdout.match(/^default:\s+(.+)$/im)?.[1]?.toLowerCase() ?? "";
  const incomingPolicy =
    (defaultLine.match(/(allow|deny|reject)\s+\(incoming\)/)?.[1] as
      | SystemSecurityPolicy
      | undefined) ?? DEFAULT_FIREWALL_INCOMING_POLICY;
  const outgoingPolicy =
    (defaultLine.match(/(allow|deny|reject)\s+\(outgoing\)/)?.[1] as
      | SystemSecurityPolicy
      | undefined) ?? DEFAULT_FIREWALL_OUTGOING_POLICY;

  return {
    enabled,
    incomingPolicy,
    outgoingPolicy,
  };
}

async function readFail2BanEnabled() {
  try {
    const state = await readCommandOutput("systemctl", ["is-enabled", "fail2ban"]);
    return state === "enabled";
  } catch (error) {
    const { stdout } = getExecFailureDetails(error);
    const normalizedStdout = stdout.trim().toLowerCase();
    if (["disabled", "masked", "indirect", "static", "not-found"].includes(normalizedStdout)) {
      return false;
    }

    if (isToolUnavailable(error)) {
      return false; // systemctl not available (e.g. Alpine / non-systemd host)
    }

    throw error;
  }
}

function parseFail2BanOverride(content: string) {
  const maxRetries = Number.parseInt(
    content.match(/^\s*maxretry\s*=\s*(\d+)\s*$/im)?.[1] ??
      String(DEFAULT_FAIL2BAN_MAX_RETRIES),
    10,
  );
  const banDuration = Number.parseInt(
    content.match(/^\s*bantime\s*=\s*(\d+)\s*$/im)?.[1] ??
      String(DEFAULT_FAIL2BAN_BAN_DURATION_SECONDS),
    10,
  );

  return {
    maxRetries: Number.isFinite(maxRetries) ? maxRetries : DEFAULT_FAIL2BAN_MAX_RETRIES,
    banDuration:
      Number.isFinite(banDuration) ? banDuration : DEFAULT_FAIL2BAN_BAN_DURATION_SECONDS,
  };
}

async function readFail2BanSettings() {
  const enabled = await readFail2BanEnabled();
  const overridePath = resolveFail2BanOverridePath();

  try {
    const content = await readFile(overridePath, "utf8");
    const parsed = parseFail2BanOverride(content);
    return {
      enabled,
      maxRetries: parsed.maxRetries,
      banDuration: parsed.banDuration,
    };
  } catch (error) {
    const errorCode = (error as NodeJS.ErrnoException | undefined)?.code;
    if (errorCode === "ENOENT") {
      return {
        enabled,
        maxRetries: DEFAULT_FAIL2BAN_MAX_RETRIES,
        banDuration: DEFAULT_FAIL2BAN_BAN_DURATION_SECONDS,
      };
    }
    throw error;
  }
}

export function buildFail2BanOverrideContent(input: {
  maxRetries: number;
  banDurationSeconds: number;
}) {
  return [
    "[DEFAULT]",
    `maxretry = ${input.maxRetries}`,
    `bantime = ${input.banDurationSeconds}`,
    "",
  ].join("\n");
}

async function writeFail2BanOverrideFile(input: {
  maxRetries: number;
  banDurationSeconds: number;
}) {
  const overridePath = resolveFail2BanOverridePath();
  await mkdir(path.dirname(overridePath), { recursive: true });
  await writeFile(overridePath, buildFail2BanOverrideContent(input), "utf8");
}

async function applyFail2BanSettings(input: {
  enabled: boolean;
  maxRetries: number;
  banDurationSeconds: number;
}) {
  try {
    await writeFail2BanOverrideFile(input);

    if (input.enabled) {
      await execFileAsync("systemctl", ["enable", "--now", "fail2ban"]);
      await execFileAsync("systemctl", ["restart", "fail2ban"]);
      return;
    }

    await execFileAsync("systemctl", ["disable", "--now", "fail2ban"]);
  } catch (error) {
    if (isToolUnavailable(error)) {
      throw new Error("Fail2Ban is not installed or unavailable on this host.");
    }
    throw error;
  }
}

async function applyFirewallSettings(input: {
  enabled: boolean;
  incomingPolicy: SystemSecurityPolicy;
  outgoingPolicy: SystemSecurityPolicy;
}) {
  try {
    await execFileAsync("ufw", ["default", input.incomingPolicy, "incoming"]);
    await execFileAsync("ufw", ["default", input.outgoingPolicy, "outgoing"]);
    await execFileAsync("ufw", ["--force", input.enabled ? "enable" : "disable"]);
  } catch (error) {
    if (isToolUnavailable(error)) {
      throw new Error("UFW is not installed or unavailable on this host.");
    }
    throw error;
  }
}

function normalizeSecuritySettings(input: SystemSecuritySettings): SystemSecuritySettings {
  return {
    firewallEnabled: Boolean(input.firewallEnabled),
    firewallIncomingPolicy: normalizeSecurityPolicy(
      input.firewallIncomingPolicy,
      "incoming policy",
    ),
    firewallOutgoingPolicy: normalizeSecurityPolicy(
      input.firewallOutgoingPolicy,
      "outgoing policy",
    ),
    fail2banEnabled: Boolean(input.fail2banEnabled),
    fail2banMaxRetries: normalizeBoundedInteger(
      "Fail2Ban max retries",
      input.fail2banMaxRetries,
      SYSTEM_SECURITY_MAX_RETRIES_MIN,
      SYSTEM_SECURITY_MAX_RETRIES_MAX,
    ),
    fail2banBanDurationSeconds: normalizeBoundedInteger(
      "Fail2Ban ban duration",
      input.fail2banBanDurationSeconds,
      SYSTEM_SECURITY_BAN_DURATION_MIN,
      SYSTEM_SECURITY_BAN_DURATION_MAX,
    ),
  };
}

async function readSystemSecuritySettings(): Promise<SystemSecuritySettings> {
  const [firewall, fail2ban] = await Promise.all([readUfwStatus(), readFail2BanSettings()]);

  return {
    firewallEnabled: firewall?.enabled ?? false,
    firewallIncomingPolicy: firewall?.incomingPolicy ?? DEFAULT_FIREWALL_INCOMING_POLICY,
    firewallOutgoingPolicy: firewall?.outgoingPolicy ?? DEFAULT_FIREWALL_OUTGOING_POLICY,
    fail2banEnabled: fail2ban.enabled,
    fail2banMaxRetries: fail2ban.maxRetries,
    fail2banBanDurationSeconds: fail2ban.banDuration,
  };
}

// Settings reads these on every open, and each read spawns ufw, iptables and
// systemctl. They change through updateSystemSecuritySettings, which drops the
// cache, or at the shell, which the TTL catches up with.
const cachedSecuritySettings = memoizeAsync(readSystemSecuritySettings, SECURITY_CACHE_TTL_MS);

export async function getSystemSecuritySettings(): Promise<SystemSecuritySettings> {
  return { ...(await cachedSecuritySettings()) };
}

export function resetSecuritySettingsCacheForTests() {
  cachedSecuritySettings.invalidate();
}

export async function updateSystemSecuritySettings(input: SystemSecuritySettings) {
  const next = normalizeSecuritySettings(input);

  try {
    await applyFirewallSettings({
      enabled: next.firewallEnabled,
      incomingPolicy: next.firewallIncomingPolicy,
      outgoingPolicy: next.firewallOutgoingPolicy,
    });
    await applyFail2BanSettings({
      enabled: next.fail2banEnabled,
      maxRetries: next.fail2banMaxRetries,
      banDurationSeconds: next.fail2banBanDurationSeconds,
    });
  } catch (error) {
    throw new Error(toErrorMessage(error));
  } finally {
    cachedSecuritySettings.invalidate();
  }

  return getSystemSecuritySettings();
}
byte
10.182
baris
329
Jenis
ts
Commit terakhir
16 Oktober 2026