#!/usr/bin/env python3
"""ctg-ssh-ca-signer: the owner's personal SSH CA renewal service (runs on crystalcave only).

Listens ONLY on crystalcave's tailnet address (plain HTTP inside the WireGuard tunnel; the caller's
identity is the tailscale node, taken from `tailscale whois`, not from anything in the request).
  POST /renew   kind=host|user cert=<cert line> ts=<unix> sig=<SSHSIG>   -> new certificate (200),
                204 when more than RENEW_BEFORE is left and force is not set
  POST /enroll  pub=<ed25519 pub line> device=<name>                     -> queued for approval
  GET  /fetch?fp=SHA256:...    certificate approved for that user key (200) or 404
  GET  /krl                    key revocation list for sshd RevokedKeys
  GET  /repo/...               private apt repo with the fleet package (names, no secrets)
"""
import base64
import http.server
import json
import os
import socketserver
import subprocess
import sys
import tempfile
import time
import urllib.parse

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import ctg_ssh_ca as ca  # noqa: E402

PORT = int(os.environ.get("CTG_SSH_CA_PORT", "8443"))
REPO = os.environ.get("CTG_SSH_CA_REPO", os.path.expanduser("~/.hermes/state/ssh-ca/repo"))


def log(msg):
    sys.stderr.write(msg + "\n")
    sys.stderr.flush()


def policy():
    try:
        return json.load(open(ca.p("policy.json")))
    except FileNotFoundError:
        return {}


def whois(ip):
    r = subprocess.run(["tailscale", "whois", "--json", ip], capture_output=True, text=True, timeout=10)
    if r.returncode:
        return None
    j = json.loads(r.stdout)
    node = j.get("Node") or {}
    return {"name": (node.get("Name") or "").rstrip(".").lower(), "tags": node.get("Tags") or [],
            "login": (j.get("UserProfile") or {}).get("LoginName", "")}


class H(http.server.BaseHTTPRequestHandler):
    server_version = "ctg-ssh-ca"

    def log_message(self, fmt, *a):
        log("%s %s" % (self.client_address[0], fmt % a))

    def reply(self, code, body=b"", ctype="text/plain"):
        if isinstance(body, str):
            body = body.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def deny(self, why, code=403):
        log("DENY %s: %s" % (self.client_address[0], why))
        self.reply(code, why + "\n")

    # ---- GET
    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        if u.path == "/healthz":
            return self.reply(200, "ok\n")
        if u.path == "/krl":
            try:
                return self.reply(200, open(ca.p("krl"), "rb").read(), "application/octet-stream")
            except FileNotFoundError:
                return self.reply(404, "no krl\n")
        if u.path == "/fetch":
            fp = urllib.parse.parse_qs(u.query).get("fp", [""])[0]
            best = None
            for e in ca.ledger():
                if e["kind"] == "user" and e["fp"] == fp and e.get("cert"):
                    best = e
            if best and best["fp"] not in ca.revoked_fps():
                return self.reply(200, best["cert"] + "\n")
            return self.reply(404, "not approved\n")
        if u.path.startswith("/repo/"):
            rel = os.path.normpath(u.path[len("/repo/"):])
            f = os.path.join(REPO, rel)
            if rel.startswith("..") or not os.path.isfile(f):
                return self.reply(404, "no\n")
            return self.reply(200, open(f, "rb").read(), "application/octet-stream")
        self.reply(404, "no\n")

    # ---- POST
    def do_POST(self):
        n = int(self.headers.get("Content-Length") or 0)
        if n > 20000:
            return self.deny("too big", 413)
        form = {k: v[0] for k, v in urllib.parse.parse_qs(self.rfile.read(n).decode(errors="replace")).items()}
        try:
            if self.path == "/renew":
                return self.renew(form)
            if self.path == "/enroll":
                return self.enroll(form)
        except Exception as e:  # never leak a trace to the caller
            log("ERROR %s: %r" % (self.path, e))
            return self.reply(500, "error\n")
        self.reply(404, "no\n")

    def renew(self, f):
        peer = self.client_address[0]
        kind = f.get("kind", "")
        if kind not in ("host", "user"):
            return self.deny("bad kind")
        try:
            ts = int(f.get("ts", "0"))
            c = ca.parse_cert(f.get("cert", ""))
        except Exception:
            return self.deny("bad request", 400)
        if abs(time.time() - ts) > 300:
            return self.deny("stale request")
        if c.type != (2 if kind == "host" else 1) or ca.verify_ca_signature(c) != kind:
            return self.deny("certificate not issued by this CA for this kind")
        if not ca.ledger_has(c):
            return self.deny("certificate not in the ledger")
        if c.valid_before < time.time():
            return self.deny("certificate expired: ask the operator to re-issue")
        if c.fp in ca.revoked_fps():
            return self.deny("key revoked")
        # proof of possession of the certified key
        msg = "%s %s %s %s" % (ca.NS, kind, ts, c.fp)
        with tempfile.TemporaryDirectory() as d:
            sigf = os.path.join(d, "sig")
            open(sigf, "w").write(f.get("sig", "").strip() + "\n")
            r = subprocess.run(["ssh-keygen", "-Y", "check-novalidate", "-n", ca.NS, "-s", sigf],
                               input=msg.encode(), capture_output=True)
        out = (r.stdout + r.stderr).decode(errors="replace")
        if r.returncode or c.fp not in out:
            return self.deny("signature is not by the certified key")
        # who is calling: a node of the owner's tailnet (hosts: the node the certificate names)
        w = whois(peer)
        pol = policy()
        if not w or w["tags"]:
            return self.deny("caller is not an untagged tailnet node")
        if kind == "host":
            short = w["name"].split(".")[0]
            if w["name"] not in c.principals and short not in c.principals:
                return self.deny("tailnet node %s is not a principal of this certificate" % w["name"])
        elif pol.get("owner_login") and w["login"] != pol["owner_login"]:
            return self.deny("caller node is not the owner's")
        left = c.valid_before - time.time()
        if left > ca.RENEW_BEFORE and f.get("force") != "1":
            return self.reply(204)
        line = ca.sign(kind, c.publine, c.key_id, c.principals, ca.HOST_VALID if kind == "host" else ca.USER_VALID)
        log("RENEW %s %s %s from %s principals=%s" % (kind, c.key_id, c.fp, w["name"], ",".join(c.principals)))
        self.reply(200, line + "\n")

    def enroll(self, f):
        w = whois(self.client_address[0])
        pol = policy()
        if not w or w["tags"] or (pol.get("owner_login") and w["login"] != pol["owner_login"]):
            return self.deny("caller is not the owner's untagged node")
        pub = f.get("pub", "").strip()
        if not pub.startswith("ssh-ed25519 "):
            return self.deny("only ed25519 keys", 400)
        fp = ca.fp_of_publine(pub)
        device = "".join(ch for ch in f.get("device", w["name"]) if ch.isalnum() or ch in "-_.")[:60] or "device"
        os.makedirs(ca.p("pending"), exist_ok=True)
        fn = ca.p("pending", fp.split(":", 1)[1].replace("/", "_")[:20] + ".json")
        json.dump({"pub": " ".join(pub.split()[:2]), "fp": fp, "device": device, "node": w["name"],
                   "ts": int(time.time())}, open(fn, "w"))
        log("ENROLL pending %s device=%s node=%s" % (fp, device, w["name"]))
        self.reply(202, "pending approval of %s (crystalcave: ctg-ssh-ca-admin approve %s)\n" % (fp, fp))


class S(socketserver.ThreadingMixIn, http.server.HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def main():
    addr = os.environ.get("CTG_SSH_CA_BIND")
    if not addr:
        addr = subprocess.run(["tailscale", "ip", "-4"], capture_output=True, text=True).stdout.split()[0]
    srv = None
    for _ in range(60):  # tailscale0 may come up after us
        try:
            srv = S((addr, PORT), H)
            break
        except OSError as e:
            log("bind %s:%d: %s, retrying" % (addr, PORT, e))
            time.sleep(5)
    if srv is None:
        sys.exit(1)
    log("ctg-ssh-ca-signer listening on %s:%d" % (addr, PORT))
    srv.serve_forever()


if __name__ == "__main__":
    main()
