#!/usr/bin/env python3
"""
WHP locked-state unlock daemon.

Standalone HTTPS server that handles the LUKS /docker unlock flow.
Runs independently of Apache so unlock works regardless of Apache state
(decoupled by design — if Apache fails for any reason, this still works).

Listens on https://0.0.0.0:8444 (configurable). Reuses Apache's
self-signed cert at /etc/pki/tls/{certs,private}/localhost.{crt,key}
so admins see the same cert warning they already trust.

Auth: PAM via python3-pam.
Unlock: subprocess to /root/whp/scripts/whp-luks-unlock-helper.sh.

Runs as root (binds high port without needing root, but calls the
unlock helper which needs to mount + start services). Locked-down via
systemd unit (configs/whp-locked-unlock.service).
"""

import http.server
import json
import os
import re
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.parse
from html import escape

LISTEN_HOST     = "0.0.0.0"
LISTEN_PORT     = 8444
# SSL cert search order, first match wins:
#   1. /etc/certs/<fqdn>.pem  — combined cert+chain+key, populated by
#      haproxy-manager's certbot renewals (real LE cert in prod)
#   2. /etc/pki/tls/certs/localhost.crt + /etc/pki/tls/private/localhost.key
#      — self-signed fallback created by setup.sh
SSL_CERT_FALLBACK = "/etc/pki/tls/certs/localhost.crt"
SSL_KEY_FALLBACK  = "/etc/pki/tls/private/localhost.key"
SSL_REAL_DIR      = "/etc/certs"
HELPER            = "/root/whp/scripts/whp-luks-unlock-helper.sh"
LIB_SHELL         = "/root/whp/scripts/lib-luks-state.sh"
RATE_LIMIT_DIR    = "/var/lib/whp/locked-unlock-rate-limit"
RL_MAX_ATTEMPTS   = 5
RL_WINDOW_SEC     = 300


def resolve_ssl_paths() -> tuple:
    """Pick the best available SSL cert. Returns (certfile, keyfile_or_None).
    When keyfile is None, the private key is expected to be embedded in certfile."""
    import socket as _sk
    fqdn = _sk.getfqdn()
    candidate = os.path.join(SSL_REAL_DIR, f"{fqdn}.pem")
    if os.path.isfile(candidate):
        return (candidate, None)  # combined PEM, key embedded
    if os.path.isfile(SSL_CERT_FALLBACK) and os.path.isfile(SSL_KEY_FALLBACK):
        return (SSL_CERT_FALLBACK, SSL_KEY_FALLBACK)
    return ("", "")

try:
    import pam
except ImportError:
    pam = None

# Set by main() once we know which cert source we ended up using.
# True = real cert from /etc/certs/<fqdn>.pem; False = self-signed fallback.
USE_REAL_CERT = False


def panel_url_for_request(handler) -> str:
    """The URL the admin should be redirected to after a successful unlock.
    Real cert → https://<fqdn>:8443/ (no cert warning, same name as the cert).
    Self-signed → http://<host-from-header>:8080/ (avoid another cert prompt;
    panel is reachable on plain HTTP on the LAN side)."""
    if USE_REAL_CERT:
        return f"https://{socket.getfqdn()}:8443/"
    host = handler.headers.get("Host", "").split(":")[0]
    if not host:
        host = handler.server.server_address[0]
    return f"http://{host}:8080/"


def schedule_exit(delay_sec: float = 2.0) -> None:
    """Auto-stop the daemon after a successful unlock so it isn't sitting
    on :8444 24/7 for no reason. systemd unit has Restart=no — daemon
    will be re-started on the next reboot (WantedBy=multi-user.target)."""
    def _bye():
        time.sleep(delay_sec)
        os._exit(0)
    threading.Thread(target=_bye, daemon=True).start()


def pam_authenticate(username: str, password: str) -> bool:
    if pam is None:
        return False
    return pam.pam().authenticate(username, password)


def luks_state() -> dict:
    """Mirror of luks_state_summary() — calls the shell helpers via subprocess."""
    snippet = (
        f". {LIB_SHELL}; "
        "luks_is_enabled && echo enabled || echo disabled; "
        "luks_is_unlocked && echo unlocked || echo locked; "
        'echo "REASON=$(luks_locked_reason)"; '
        'echo "DEVICE=${LUKS_BACKING_DEVICE:-}"; '
        'echo "MAPPER=${LUKS_MAPPER_NAME:-}"; '
        'echo "MOUNT=${LUKS_MOUNT_POINT:-}"'
    )
    try:
        result = subprocess.run(
            ["bash", "-c", snippet],
            capture_output=True, text=True, timeout=5,
        )
    except subprocess.TimeoutExpired:
        return {"enabled": False, "unlocked": False, "reason": "state lookup timed out",
                "backing_device": "", "mapper": "", "mount_point": ""}

    lines = result.stdout.strip().split("\n")
    state = {
        "enabled": len(lines) > 0 and lines[0] == "enabled",
        "unlocked": len(lines) > 1 and lines[1] == "unlocked",
        "reason": "",
        "backing_device": "",
        "mapper": "",
        "mount_point": "",
    }
    for line in lines[2:]:
        if line.startswith("REASON="):
            state["reason"] = line[len("REASON="):]
        elif line.startswith("DEVICE="):
            state["backing_device"] = line[len("DEVICE="):]
        elif line.startswith("MAPPER="):
            state["mapper"] = line[len("MAPPER="):]
        elif line.startswith("MOUNT="):
            state["mount_point"] = line[len("MOUNT="):]
    return state


def _rate_limit_path(client_ip: str) -> str:
    key = re.sub(r"[^0-9a-fA-F.:]", "_", client_ip)
    return os.path.join(RATE_LIMIT_DIR, key)


def _read_recent(client_ip: str, now: int) -> list:
    path = _rate_limit_path(client_ip)
    if not os.path.isfile(path):
        return []
    try:
        with open(path) as f:
            return [int(ts) for ts in f.read().split() if ts and int(ts) > now - RL_WINDOW_SEC]
    except (ValueError, OSError):
        return []


def rate_limit_check(client_ip: str) -> bool:
    return len(_read_recent(client_ip, int(time.time()))) >= RL_MAX_ATTEMPTS


def rate_limit_record(client_ip: str) -> None:
    os.makedirs(RATE_LIMIT_DIR, mode=0o700, exist_ok=True)
    now = int(time.time())
    recent = _read_recent(client_ip, now)
    recent.append(now)
    path = _rate_limit_path(client_ip)
    with open(path, "w") as f:
        f.write("\n".join(str(t) for t in recent))
    os.chmod(path, 0o600)


def rate_limit_clear(client_ip: str) -> None:
    try:
        os.unlink(_rate_limit_path(client_ip))
    except OSError:
        pass


def invoke_unlock_helper(passphrase: str) -> tuple:
    """Returns (exit_code, parsed_json_dict, stderr_text)."""
    try:
        result = subprocess.run(
            [HELPER],
            input=passphrase, capture_output=True, text=True, timeout=60,
        )
    except subprocess.TimeoutExpired:
        return (99, {"status": "timeout", "message": "Helper timed out"}, "")
    try:
        parsed = json.loads(result.stdout) if result.stdout.strip() else {}
    except json.JSONDecodeError:
        parsed = {"status": "parse_error", "message": "Helper returned malformed JSON"}
    return (result.returncode, parsed, result.stderr)


PAGE_CSS = """
* { box-sizing: border-box; }
body { margin: 0; padding: 1rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
       Helvetica, Arial, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh;
       display: grid; place-items: center; }
.card { background: #16213e; padding: 2rem; border-radius: 0.5rem; width: 100%;
        max-width: 480px; box-shadow: 0 10px 40px rgba(0,0,0,0.4); border: 1px solid #2a3458; }
h1 { margin: 0 0 0.5rem; font-size: 1.5rem; }
.subtitle { color: #889; margin: 0 0 1.5rem; font-size: 0.9rem; }
.status { background: #2a1f1f; border-left: 4px solid #e74c3c; padding: 0.75rem 1rem;
          margin-bottom: 1.5rem; border-radius: 0.25rem; font-size: 0.9rem; }
.status .label { font-weight: 600; color: #e74c3c; }
form > div { margin-bottom: 1rem; }
label { display: block; font-size: 0.85rem; margin-bottom: 0.25rem; color: #aaa; }
input[type=text], input[type=password] { width: 100%; padding: 0.5rem 0.75rem; font-size: 1rem;
        background: #0e1828; color: #eee; border: 1px solid #2a3458; border-radius: 0.25rem;
        font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
input:focus { outline: none; border-color: #4a6caf; }
button { width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600; background: #4a6caf;
         color: white; border: none; border-radius: 0.25rem; cursor: pointer; margin-top: 0.5rem; }
button:hover:not(:disabled) { background: #5a7cbf; }
button:disabled, input:disabled { opacity: 0.5; cursor: not-allowed; }
.error { background: #3a1f24; border-left: 4px solid #e74c3c; padding: 0.5rem 0.75rem;
         margin-bottom: 1rem; border-radius: 0.25rem; font-size: 0.9rem; }
.success { background: #1f3a24; border-left: 4px solid #2ecc71; padding: 0.5rem 0.75rem;
           margin-bottom: 1rem; border-radius: 0.25rem; font-size: 0.9rem; }
.rate-limited { background: #3a2f1f; border-left: 4px solid #f39c12; padding: 0.5rem 0.75rem;
                margin-bottom: 1rem; border-radius: 0.25rem; font-size: 0.9rem; }
.rate-limited code { background: #0e1828; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.85em; }
.meta { font-size: 0.75rem; color: #667; margin-top: 1.5rem; text-align: center; }
.meta code { background: #0e1828; padding: 0.1em 0.3em; border-radius: 3px; }
.show-toggle { display: inline-block; font-size: 0.8rem; color: #4a6caf; cursor: pointer;
               margin-left: 0.5rem; user-select: none; }
.show-toggle:hover { text-decoration: underline; }
"""


def render_page(state: dict, *, error: str = "", success: bool = False,
                rate_limited: bool = False, posted_username: str = "root") -> str:
    if success:
        inner = (
            '<div class="success"><strong>Unlocked!</strong> /docker is mounted and '
            'downstream services are starting.</div>'
            '<p>You can now reach the regular WHP panel.</p>'
        )
    else:
        parts = []
        if rate_limited:
            parts.append(
                '<div class="rate-limited"><strong>Too many failed attempts.</strong> '
                "Wait a few minutes, or unlock via SSH: "
                '<code>ssh root@&lt;server&gt; /root/whp/scripts/whp-unlock-data-drive</code></div>'
            )
        if error:
            parts.append(f'<div class="error">{escape(error)}</div>')
        disabled = " disabled" if rate_limited else ""
        username_v = escape(posted_username)
        toggle_js = (
            "var f=document.getElementById('passphrase'); "
            "f.type = f.type==='password' ? 'text' : 'password';"
        )
        parts.append(
            '<form method="post" autocomplete="off">'
            '<div><label for="username">Administrator user</label>'
            f'<input type="text" id="username" name="username" value="{username_v}" required{disabled}></div>'
            '<div><label for="password">Administrator password</label>'
            f'<input type="password" id="password" name="password" required{disabled}></div>'
            '<div><label for="passphrase">LUKS data-drive passphrase '
            f'<span class="show-toggle" onclick="{toggle_js}">(show/hide)</span></label>'
            f'<input type="password" id="passphrase" name="passphrase" required{disabled}></div>'
            f'<button type="submit"{disabled}>Unlock /docker</button>'
            '</form>'
        )
        inner = "".join(parts)

    hostname = escape(socket.gethostname())
    device = escape(state.get("backing_device", ""))
    reason = escape(state.get("reason", "unknown"))
    return (
        '<!DOCTYPE html>\n<html lang="en"><head>'
        '<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
        "<title>WHP — Server Locked</title>"
        f"<style>{PAGE_CSS}</style>"
        '</head><body><div class="card">'
        "<h1>WHP /docker is locked</h1>"
        '<p class="subtitle">The encrypted data volume needs to be unlocked before the panel can serve customers.</p>'
        f'<div class="status"><span class="label">LOCKED</span> &nbsp; {reason}</div>'
        f"{inner}"
        f'<p class="meta">Server: <code>{hostname}</code> &nbsp;·&nbsp; Device: <code>{device}</code></p>'
        "</div></body></html>"
    )


class UnlockHandler(http.server.BaseHTTPRequestHandler):
    server_version = "WHPLockedUnlock/1.0"
    sys_version = ""  # don't leak Python version

    def _send(self, status: int, body: str, content_type: str = "text/html; charset=utf-8") -> None:
        body_bytes = body.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body_bytes)))
        self.send_header("X-Frame-Options", "DENY")
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "same-origin")
        self.send_header(
            "Content-Security-Policy",
            "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
        )
        self.end_headers()
        self.wfile.write(body_bytes)

    def _client_ip(self) -> str:
        return self.client_address[0]

    def do_GET(self) -> None:
        state = luks_state()
        if not state["enabled"]:
            self._send(404, "<h1>LUKS not configured on this server.</h1>")
            return
        if state["unlocked"]:
            # Already unlocked — redirect straight to the regular panel.
            target = panel_url_for_request(self)
            self.send_response(302)
            self.send_header("Location", target)
            self.end_headers()
            return
        self._send(200, render_page(state))

    def do_POST(self) -> None:
        state = luks_state()
        if not state["enabled"]:
            self._send(404, "LUKS not configured", content_type="text/plain")
            return
        if state["unlocked"]:
            self._send(200, render_page(state, success=True))
            return

        client_ip = self._client_ip()
        if rate_limit_check(client_ip):
            self._send(429, render_page(state, rate_limited=True))
            return

        length = int(self.headers.get("Content-Length", "0"))
        if length <= 0 or length > 4096:
            self._send(400, "Bad request", content_type="text/plain")
            return
        raw = self.rfile.read(length).decode("utf-8", errors="replace")
        parsed = urllib.parse.parse_qs(raw, keep_blank_values=False)
        username = (parsed.get("username") or [""])[0].strip()
        password = (parsed.get("password") or [""])[0]
        passphrase = (parsed.get("passphrase") or [""])[0]

        if not username or not password or not passphrase:
            self._send(400, render_page(state, error="All fields are required.",
                                        posted_username=username or "root"))
            return
        if not re.match(r"^[a-z_][a-z0-9_-]*$", username, re.IGNORECASE) or len(username) > 32:
            self._send(400, render_page(state, error="Invalid username.", posted_username=username))
            return
        if pam is None:
            self._send(500, render_page(state, error="python3-pam not installed on server."))
            return

        if not pam_authenticate(username, password):
            rate_limit_record(client_ip)
            self.log_message("PAM auth failed for %s from %s", username, client_ip)
            self._send(401, render_page(state, error="Authentication failed.",
                                        posted_username=username))
            return

        rc, result, stderr = invoke_unlock_helper(passphrase)
        passphrase = None  # drop reference (Python strings immutable; this is best-effort)

        if rc == 0:
            rate_limit_clear(client_ip)
            self.log_message("SUCCESS: unlocked by %s from %s", username, client_ip)
            # 302 to the regular panel. After 2s, daemon exits cleanly so
            # it's not occupying :8444 unnecessarily; systemd re-starts
            # it on the next boot when locked again.
            target = panel_url_for_request(self)
            self.send_response(302)
            self.send_header("Location", target)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(
                (
                    f"<html><body><h1>Unlocked.</h1><p>Redirecting to "
                    f'<a href="{escape(target)}">{escape(target)}</a> '
                    f"&mdash; allow ~30 seconds for services to start.</p>"
                    f"</body></html>"
                ).encode("utf-8")
            )
            self.log_message("Auto-stopping daemon after successful unlock")
            schedule_exit(delay_sec=2.0)
            return

        status = result.get("status", "unknown")
        msg = result.get("message", "Unlock failed.")
        if status == "wrong_passphrase":
            rate_limit_record(client_ip)
        self.log_message("Helper rc=%d status=%s stderr=%r", rc, status, stderr[:200])
        self._send(400, render_page(state, error=msg, posted_username=username))

    def log_message(self, format, *args) -> None:
        # journald captures stderr; format mirrors BaseHTTPRequestHandler's default
        sys.stderr.write(
            "%s - - [%s] %s\n"
            % (self.address_string(), self.log_date_time_string(), format % args)
        )


def main() -> None:
    for path in (HELPER, LIB_SHELL):
        if not os.path.isfile(path):
            print(f"ERROR: required file missing: {path}", file=sys.stderr)
            sys.exit(1)

    certfile, keyfile = resolve_ssl_paths()
    if not certfile:
        print(
            f"ERROR: no SSL cert found. Looked for {SSL_REAL_DIR}/<fqdn>.pem "
            f"and {SSL_CERT_FALLBACK} + {SSL_KEY_FALLBACK}.",
            file=sys.stderr,
        )
        sys.exit(1)

    os.makedirs(RATE_LIMIT_DIR, mode=0o700, exist_ok=True)

    global USE_REAL_CERT
    USE_REAL_CERT = (keyfile is None)

    httpd = http.server.HTTPServer((LISTEN_HOST, LISTEN_PORT), UnlockHandler)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    if keyfile is None:
        ctx.load_cert_chain(certfile)
    else:
        ctx.load_cert_chain(certfile, keyfile)
    httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
    cert_type = "real cert from /etc/certs/" if USE_REAL_CERT else "self-signed fallback"
    print(
        f"WHP locked-unlock daemon listening on https://{LISTEN_HOST}:{LISTEN_PORT} "
        f"({cert_type}: {certfile})",
        file=sys.stderr,
    )
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("shutting down", file=sys.stderr)
        httpd.server_close()


if __name__ == "__main__":
    main()
