#!/opt/firewall-appliance/backend/.venv/bin/python
"""Root-only ACME certificate helper (Let's Encrypt via Cloudflare DNS-01).

Two modes:

  request         customer-owned-domain mode: the admin supplies their own
                  zone and Cloudflare API token in the appliance settings.
                  The API never handles the token: this helper reads the
                  stored settings directly, writes a 0600 credentials file
                  for the certbot DNS plugin, requests one certificate
                  covering the appliance and block-page hostnames, deploys
                  it for the TLS block service, registers the names in
                  local DNS, and restarts what needs restarting.

  vendor-request  vendor-zone mode: the certificate covers the appliance's
                  unique public hostname (nf-<id>.netfortress.tech) that
                  the licence server claims for it. DNS-01 challenges are
                  delegated to the licence server - the vendor Cloudflare
                  token never touches the appliance. No customer domain,
                  no Cloudflare account, no inbound ports.

Nothing is exposed to the internet — DNS-01 only.
"""
from __future__ import annotations

import json
import os
import pwd
import shutil
import subprocess
import sys
import time
from pathlib import Path

ENVIRONMENT = Path("/etc/firewall-appliance/environment")
CERT_DIR = Path("/var/lib/firewall-appliance/certs")
WORK_DIR = Path("/var/lib/firewall-appliance/certbot-work")
LOGS_DIR = Path("/var/lib/firewall-appliance/certbot-logs")
CRED_FILE = Path("/tmp/.firewall-acme-cloudflare.ini")
DEPLOY_CERT = CERT_DIR / "public.crt"
DEPLOY_KEY = CERT_DIR / "public.key"
TLS_CONFIG = Path("/var/lib/firewall-appliance/tls-block/config.json")


def _ensure_tls_state_writable() -> None:
    """firewall-acme runs as root; firewall-tls-block runs as the service
    user and must be able to mint its appliance CA inside the shared state
    dir. Create the dir and config with service ownership so the tls-block
    service never crash-loops on ca.key after a root-run certificate
    request (fresh installs hit exactly that)."""
    user = pwd.getpwnam("firewall-api")
    TLS_CONFIG.parent.mkdir(parents=True, exist_ok=True)
    os.chown(TLS_CONFIG.parent, user.pw_uid, user.pw_gid)
    if TLS_CONFIG.exists():
        os.chown(TLS_CONFIG, user.pw_uid, user.pw_gid)


LICENSING_STATE = Path("/var/lib/firewall-appliance/appliance.db.licensing/state.json")
HOOK_DIR = WORK_DIR / "hooks"
VENDOR_CERT_NAME = "firewall-appliance-vendor"
VENDOR_EMAIL = "acme@netfortress.tech"


def fail(message: str, code: int = 1) -> None:
    print(json.dumps({"ok": False, "message": message}))
    raise SystemExit(code)


def load_settings() -> dict:
    env = {}
    for line in ENVIRONMENT.read_text(encoding="utf-8").splitlines():
        if "=" in line and not line.strip().startswith("#"):
            key, _, value = line.partition("=")
            env[key.strip()] = value.strip()
    sys.path.insert(0, "/opt/firewall-appliance/backend")
    os.environ.update(env)
    from app.database import Database
    from app.models import AcmeSettings

    database = Database()
    raw = database.setting("acme_settings")
    settings = AcmeSettings.model_validate_json(raw) if raw else AcmeSettings()
    return settings.model_dump()


def run(command: list[str], timeout: int = 240) -> subprocess.CompletedProcess:
    return subprocess.run(command, text=True, capture_output=True, timeout=timeout, check=False)


def request() -> None:
    settings = load_settings()
    if not settings["enabled"]:
        fail("ACME is not enabled in the appliance settings")
    for field in ("email", "zone", "appliance_fqdn", "block_fqdn", "api_token"):
        if not settings[field]:
            fail(f"ACME setting '{field}' is empty")
    if shutil.which("certbot") is None:
        fail("certbot is not installed")
    CRED_FILE.parent.mkdir(parents=True, exist_ok=True)
    CRED_FILE.write_text(f"dns_cloudflare_api_token = {settings['api_token']}\n")
    CRED_FILE.chmod(0o600)
    WORK_DIR.mkdir(parents=True, exist_ok=True)
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    try:
        completed = run([
            "certbot", "certonly", "--non-interactive", "--agree-tos", "--keep-until-expiring",
            "--config-dir", "/etc/letsencrypt", "--work-dir", str(WORK_DIR), "--logs-dir", str(LOGS_DIR),
            "--dns-cloudflare", f"--dns-cloudflare-credentials={CRED_FILE}",
            f"--dns-cloudflare-propagation-seconds=30",
            "--cert-name", "firewall-appliance",
            "-d", settings["appliance_fqdn"], "-d", settings["block_fqdn"],
            "-m", settings["email"], "--no-eff-email",
        ])
    finally:
        CRED_FILE.unlink(missing_ok=True)
    if completed.returncode:
        fail(f"certbot failed: {(completed.stderr or completed.stdout).strip()[-400:]}")
    live = Path("/etc/letsencrypt/live/firewall-appliance")
    CERT_DIR.mkdir(parents=True, exist_ok=True)
    shutil.copy2(live / "fullchain.pem", DEPLOY_CERT)
    shutil.copy2(live / "privkey.pem", DEPLOY_KEY)
    DEPLOY_CERT.chmod(0o644)
    DEPLOY_KEY.chmod(0o640)

    os.chown(DEPLOY_KEY, -1, pwd.getpwnam("firewall-api").pw_gid)
    _ensure_tls_state_writable()
    TLS_CONFIG.write_text(json.dumps({
        "appliance_fqdn": settings["appliance_fqdn"],
        "block_fqdn": settings["block_fqdn"],
        "certificate": str(DEPLOY_CERT),
        "private_key": str(DEPLOY_KEY),
    }, sort_keys=True))
    _ensure_tls_state_writable()
    return {"ok": True, "certificate": str(DEPLOY_CERT), "names": [settings["appliance_fqdn"], settings["block_fqdn"]], "renewed_at": time.time()}


def _appliance_database():
    env = {}
    for line in ENVIRONMENT.read_text(encoding="utf-8").splitlines():
        if "=" in line and not line.strip().startswith("#"):
            key, _, value = line.partition("=")
            env[key.strip()] = value.strip()
    os.environ.update(env)
    sys.path.insert(0, "/opt/firewall-appliance/backend")
    from app.database import Database
    return Database()


def appliance_lan_address(database) -> str:
    """The appliance's LAN address so local DNS answers its public
    hostnames with a LAN-reachable IP (split horizon)."""
    try:
        cfg = json.loads(database.setting("iptables_settings") or "{}")
        lan = (cfg or {}).get("lan_interface")
        if lan:
            output = subprocess.run(["ip", "-4", "-o", "addr", "show", "dev", lan],
                                    text=True, capture_output=True, timeout=10,
                                    check=False).stdout
            for token in output.split():
                if "/" in token:
                    return token.split("/", 1)[0]
    except Exception:
        pass
    return "127.0.0.1"


def vendor_request() -> dict:
    """Vendor-zone mode: certificate for the unique public hostname claimed
    from the licence server. DNS-01 is delegated to the licence server."""
    database = _appliance_database()
    hostname = database.setting("public_hostname")
    if not hostname:
        fail("No unique public hostname claimed yet - the licence refresh "
             "claims it automatically once the appliance is activated")
    if not LICENSING_STATE.is_file():
        fail("Licence state not found - activate the appliance first")
    state = json.loads(LICENSING_STATE.read_text(encoding="utf-8"))
    license_key = state.get("license_key")
    install_id = state.get("installation_id")
    base = (os.environ.get("FIREWALL_LICENSE_SERVER_URL") or "").rsplit("/api/activate", 1)[0]
    if not license_key or not install_id or not base:
        fail("Licence state or FIREWALL_LICENSE_SERVER_URL is incomplete")
    if shutil.which("certbot") is None:
        fail("certbot is not installed")

    names = [hostname, "blocked." + hostname]

    HOOK_DIR.mkdir(parents=True, exist_ok=True)
    auth = HOOK_DIR / "vendor-auth.sh"
    cleanup = HOOK_DIR / "vendor-cleanup.sh"
    auth.write_text(
        "#!/bin/sh\n"
        "# Generated by firewall-acme vendor mode - the DNS-01 TXT record is\n"
        "# delegated to the NetFortress licence server (vendor Cloudflare zone).\n"
        "payload='{\"license_key\": \"" + license_key + "\", \"installation_id\": \"" + install_id + "\", "
        "\"value\": \"'\"$CERTBOT_VALIDATION\"'\", \"domain\": \"'\"$CERTBOT_DOMAIN\"'\"}'\n"
        "response=$(curl -s -X POST \"" + base + "/api/appliance/acme/challenge\" "
        "-H 'Content-Type: application/json' -d \"$payload\")\n"
        "if ! printf '%s' \"$response\" | grep -q '\"status\":\"ok\"'; then\n"
        "  echo \"licence server rejected the DNS challenge: $response\" >&2\n"
        "  exit 1\n"
        "fi\n"
        "# DNS propagation wait (manual hooks have no propagation option)\n"
        "sleep 30\n"
    )
    cleanup.write_text(
        "#!/bin/sh\n"
        "# Best-effort TXT cleanup via the licence server.\n"
        "payload='{\"license_key\": \"" + license_key + "\", \"installation_id\": \"" + install_id + "\", "
        "\"domain\": \"'\"$CERTBOT_DOMAIN\"'\"}'\n"
        "curl -s -X POST \"" + base + "/api/appliance/acme/challenge/clear\" "
        "-H 'Content-Type: application/json' -d \"$payload\" >/dev/null 2>&1\n"
        "exit 0\n"
    )
    for hook in (auth, cleanup):
        hook.chmod(0o700)

    WORK_DIR.mkdir(parents=True, exist_ok=True)
    LOGS_DIR.mkdir(parents=True, exist_ok=True)
    completed = run([
        "certbot", "certonly", "--non-interactive", "--agree-tos", "--keep-until-expiring",
        "--config-dir", "/etc/letsencrypt", "--work-dir", str(WORK_DIR), "--logs-dir", str(LOGS_DIR),
        "--manual", "--preferred-challenges", "dns",
        f"--manual-auth-hook={auth}", f"--manual-cleanup-hook={cleanup}",
        "--cert-name", VENDOR_CERT_NAME,
        "-d", names[0], "-d", names[1],
        "-m", VENDOR_EMAIL, "--no-eff-email",
    ], timeout=420)
    if completed.returncode:
        fail(f"certbot failed: {(completed.stderr or completed.stdout).strip()[-400:]}")

    live = Path("/etc/letsencrypt/live") / VENDOR_CERT_NAME
    CERT_DIR.mkdir(parents=True, exist_ok=True)
    shutil.copy2(live / "fullchain.pem", DEPLOY_CERT)
    shutil.copy2(live / "privkey.pem", DEPLOY_KEY)
    DEPLOY_CERT.chmod(0o644)
    DEPLOY_KEY.chmod(0o640)
    os.chown(DEPLOY_KEY, -1, pwd.getpwnam("firewall-api").pw_gid)

    _ensure_tls_state_writable()
    TLS_CONFIG.write_text(json.dumps({
        "appliance_fqdn": names[0],
        "block_fqdn": names[1],
        "certificate": str(DEPLOY_CERT),
        "private_key": str(DEPLOY_KEY),
    }, sort_keys=True))
    _ensure_tls_state_writable()

    result = {"ok": True, "mode": "vendor", "certificate": str(DEPLOY_CERT),
              "names": names, "renewed_at": time.time()}
    database.set_setting("public_hostname_cert", names[0])
    return result


def register_dns_vendor() -> None:
    """Serve the unique public names locally so LAN clients resolve them
    to this appliance (split horizon) with a publicly-valid certificate.
    3.13.41: re-syncs on every call - records created before the LAN existed
    (or after an address change) are corrected instead of left stale, and the
    block name prefers the dedicated block-page address."""
    database = _appliance_database()
    hostname = database.setting("public_hostname")
    if not hostname:
        return
    dashboard_address = appliance_lan_address(database)
    block_address = dashboard_address
    try:
        import json as _json
        raw = database.setting("dns_settings")
        if raw:
            block_address = _json.loads(raw).get("block_page_ipv4") or dashboard_address
    except Exception:
        pass
    for name, address in ((hostname, dashboard_address), ("blocked." + hostname, block_address)):
        if not address or address == "127.0.0.1":
            continue  # no LAN address known yet - a later cycle re-syncs
        existing = {row["name"].lower(): row for row in database.dns_records()}
        row = existing.get(name.lower())
        if row is None:
            database.create_dns_record({"name": name, "record_type": "A", "value": address})
        elif str(row["value"]) != str(address):
            with database.connect() as connection:
                connection.execute("UPDATE local_dns_records SET value=? WHERE name=?", (str(address), str(row["name"])))
    subprocess.run(["/opt/firewall-appliance/backend/.venv/bin/python", "/opt/firewall-appliance/deploy/policy_refresh.py"],
                   text=True, capture_output=True, timeout=180, check=False)


def register_dns() -> None:
    """Serve the two public names locally so clients resolve them to the appliance."""
    settings = load_settings()
    if not settings["enabled"]:
        return
    env = {}
    for line in ENVIRONMENT.read_text(encoding="utf-8").splitlines():
        if "=" in line and not line.strip().startswith("#"):
            key, _, value = line.partition("=")
            env[key.strip()] = value.strip()
    sys.path.insert(0, "/opt/firewall-appliance/backend")
    os.environ.update(env)
    from app.database import Database

    database = Database()
    address = appliance_lan_address(database)
    if not address or address == "127.0.0.1":
        return
    existing = {row["name"].lower(): row for row in database.dns_records()}
    for name in (settings["appliance_fqdn"], settings["block_fqdn"]):
        if name.lower() in existing:
            continue
        database.create_dns_record({"name": name, "record_type": "A", "value": address})
    subprocess.run(["/opt/firewall-appliance/backend/.venv/bin/python", "/opt/firewall-appliance/deploy/policy_refresh.py"],
                   text=True, capture_output=True, timeout=180, check=False)


def status() -> None:
    from cryptography import x509

    if not DEPLOY_CERT.is_file():
        print(json.dumps({"ok": True, "issued": False}))
        return
    certificate = x509.load_pem_x509_certificate(DEPLOY_CERT.read_bytes())
    try:
        san = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
        names = [entry.value for entry in san]
    except Exception:
        names = []
    print(json.dumps({"ok": True, "issued": True, "expires_at": certificate.not_valid_after_utc.isoformat(), "names": names}))


def main() -> None:
    if os.geteuid() != 0 or len(sys.argv) != 2:
        fail("Usage: firewall-acme request|vendor-request|register-dns|status (root only)", 2)
    action = sys.argv[1]
    if action == "request":
        result = request()
        register_dns()
        subprocess.run(["chown", "-R", "firewall-api:firewall-api", str(TLS_CONFIG.parent), str(CERT_DIR)], check=False)
        subprocess.run(["systemctl", "restart", "firewall-tls-block.service"], check=False)
        result["restarted"] = "firewall-tls-block"
        print(json.dumps(result))
    elif action == "vendor-request":
        result = vendor_request()
        register_dns_vendor()
        subprocess.run(["chown", "-R", "firewall-api:firewall-api", str(TLS_CONFIG.parent), str(CERT_DIR)], check=False)
        subprocess.run(["systemctl", "restart", "firewall-tls-block.service"], check=False)
        result["restarted"] = "firewall-tls-block"
        result["local_dns"] = "registered"
        print(json.dumps(result))
    elif action == "register-dns":
        # 3.13.41: lightweight re-sync used after deployment confirm.
        register_dns_vendor()
        print(json.dumps({"local_dns": "registered"}))
    elif action == "status":
        status()
    else:
        fail("Unsupported action", 2)


if __name__ == "__main__":
    main()
