#!/usr/bin/env python3
"""ctg-ssh-ca-admin: operate the owner's personal SSH CAs (crystalcave only; needs ~/.hermes/secrets/ssh-ca).

  init                                   create host CA + user CA + policy + empty KRL (refuses if they exist)
  fingerprints                           CA fingerprints
  roll-host TARGET --principals a,b,...  fetch TARGET's ed25519 host key over ssh, sign it, install the cert
                                         there with `ctg-ssh-ca install-host-cert` (TARGET = [user@]host[:port])
  issue-host --pub FILE --id ID --principals ... [--out FILE]
  issue-user --pub FILE --id ID [--principals a,b] [--out FILE]
  roll-user TARGET --device NAME [--principals a,b] [--key id_ed25519]   generate-if-missing on the
                                         device, sign, install ~/.ssh/<key>-cert.pub
  pending | approve FP [--principals ...]  enrollment queue (devices running `ctg-ssh-ca enroll`)
  revoke FP|ID [--note TEXT]             refuse renewal and add to the KRL
  list                                   issued certificates still valid
  push-renew [--force]                   renew certs of hosts not on the tailnet (policy.json push_hosts)
  backup                                 passphrase-encrypted copy of the CA dir for the restic backups
"""
import argparse
import base64
import glob
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time

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


def sh(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, **kw)


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


def cmd_init(a):
    os.makedirs(ca.CA_DIR, mode=0o700, exist_ok=True)
    os.chmod(ca.CA_DIR, 0o700)
    for which, comment in (("host", "ctg personal SSH host CA"), ("user", "ctg personal SSH user CA")):
        if os.path.exists(ca.p(which + "_ca")):
            sys.exit("%s CA exists; refusing" % which)
        subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", comment + " (2026-11)",
                        "-f", ca.p(which + "_ca")], check=True)
        os.chmod(ca.p(which + "_ca"), 0o600)
    if not os.path.exists(ca.p("policy.json")):
        json.dump({"owner_login": a.owner_login, "user_principals": ["canutethegreat", "rbf"]},
                  open(ca.p("policy.json"), "w"), indent=1)
    ca.build_krl()
    cmd_fingerprints(a)


def cmd_fingerprints(a):
    for which in ("host", "user"):
        print(which, sh(["ssh-keygen", "-lf", ca.p(which + "_ca.pub")]).stdout.strip())


def cmd_issue_host(a):
    line = ca.sign("host", open(a.pub).read().strip(), a.id, a.principals.split(","), ca.HOST_VALID)
    open(a.out, "w").write(line + "\n") if a.out else print(line)


def cmd_issue_user(a):
    pr = a.principals.split(",") if a.principals else pol()["user_principals"]
    line = ca.sign("user", open(a.pub).read().strip(), a.id, pr, ca.USER_VALID)
    open(a.out, "w").write(line + "\n") if a.out else print(line)


def split_target(t):
    port = None
    if ":" in t:
        t, port = t.rsplit(":", 1)
    return t, port


def rsh(t, remote, inp=None):
    t, port = split_target(t)
    c = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15"] + shlex.split(os.environ.get("CTG_SSH_OPTS", ""))
    if port:
        c += ["-p", port]
    return sh(c + [t, remote], input=inp)


def sudo_for(t):
    return "" if t.split("@")[0] == "root" else "sudo -n "


def cmd_roll_host(a):
    t = a.target
    pub = rsh(t, "cat /etc/ssh/ssh_host_ed25519_key.pub")
    if pub.returncode or not pub.stdout.startswith("ssh-ed25519 "):
        sys.exit("cannot read the host key of %s: %s" % (t, pub.stderr.strip()[:200]))
    key = " ".join(pub.stdout.split()[:2])
    pr = a.principals.split(",")
    line = ca.sign("host", key, a.id or pr[0], pr, ca.HOST_VALID)
    r = rsh(t, sudo_for(t) + "env PATH=$PATH:/usr/local/bin:/usr/local/sbin ctg-ssh-ca install-host-cert -", inp=line + "\n")
    print(r.stdout.strip(), r.stderr.strip()[:300])
    if r.returncode:
        sys.exit("install failed on %s" % t)


KEYSH = "k=%s; case $k in /*) ;; *) k=$HOME/.ssh/$k;; esac; "


def cmd_roll_user(a):
    t = a.target
    q = shlex.quote(a.key)
    r = rsh(t, (KEYSH % q) + "[ -f $k ] || { umask 077; mkdir -p $HOME/.ssh; "
            "ssh-keygen -q -t ed25519 -N '' -C \"$(id -un)@$(hostname -s) ctg\" -f $k; }; cat $k.pub")
    if r.returncode or not r.stdout.startswith("ssh-ed25519 "):
        sys.exit("cannot get the user key of %s: %s" % (t, r.stderr.strip()[:200]))
    pub = " ".join(r.stdout.split()[:2])
    pr = a.principals.split(",") if a.principals else pol()["user_principals"]
    line = ca.sign("user", pub, "user-" + a.device, pr, ca.USER_VALID)
    r = rsh(t, (KEYSH % q) + "cat > $k-cert.pub.new && chmod 644 $k-cert.pub.new && mv $k-cert.pub.new $k-cert.pub "
            "&& ssh-keygen -L -f $k-cert.pub | grep -E 'Key ID|Valid:'", inp=line + "\n")
    print(r.stdout.strip(), r.stderr.strip()[:300])
    if r.returncode:
        sys.exit("install failed")


def qm_exec(cfg, remote, inp=None):
    """Run a command inside a Proxmox guest through the hypervisor (guest agent): hosts that have no ssh key login
    and no tailnet route to the signer (captainjack). cfg = {"via": "user@azkaban", "vmid": 102}."""
    c = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15"] + shlex.split(os.environ.get("CTG_SSH_OPTS", ""))
    c += [cfg["via"], "sudo qm guest exec %d --timeout 60 %s -- /bin/sh -c %s" % (
        cfg["vmid"], "--pass-stdin 1" if inp is not None else "", shlex.quote(remote))]
    r = sh(c, input=inp)
    try:
        j = json.loads(r.stdout)
        return j.get("exitcode", 1), j.get("out-data", ""), j.get("err-data", "")
    except Exception:
        return 1, "", r.stderr


def push_krl(name, cfg):
    """Install the current revocation list on a push host (it cannot fetch it) and mark the host push-managed, so its
    own pull timer stops trying the unreachable signer. Always runs, also when the certificate is not due: the
    host's `renew-all` fails when the KRL has not been refreshed for 3 days."""
    krl = base64.b64encode(open(ca.p("krl"), "rb").read()).decode()
    inst = "base64 -d | env PATH=$PATH:/usr/local/bin ctg-ssh-ca install-krl -"
    if cfg.get("vmid"):
        rc, out, err = qm_exec(cfg, inst, inp=krl + "\n")
        msg = out or err
    else:
        r = rsh(cfg["target"], sudo_for(cfg["target"]) + inst, inp=krl + "\n")
        rc, msg = r.returncode, r.stdout + r.stderr
    print("%s: krl %s %s" % (name, "ok" if not rc else "FAILED", msg.strip()[:120]))
    return bool(rc)


def cmd_push_renew(a):
    """Re-sign + install the host certificate of every host in policy.json "push_hosts" that is not reachable by the
    signer (not on the tailnet) when fewer than 90 days are left (or --force). Run daily by ctg-ssh-ca-push.timer."""
    latest = {}
    for e in ca.ledger():
        if e["kind"] == "host":
            latest[e["key_id"]] = e
    bad = 0
    for name, cfg in sorted(pol().get("push_hosts", {}).items()):
        bad += push_krl(name, cfg)
        e = latest.get(cfg["id"])
        if not e and cfg.get("principals"):  # first issuance: principals come from the policy entry
            e = {"valid_before": 0, "fp": None, "principals": cfg["principals"]}
        if not e:
            print("%s: no certificate issued yet (add \"principals\" to its push_hosts entry)" % name)
            continue
        if not a.force and e["valid_before"] - time.time() > ca.RENEW_BEFORE:
            print("%s: not due (till %s)" % (name, time.strftime("%F", time.localtime(e["valid_before"]))))
            continue
        if cfg.get("vmid"):
            rc, out, err = qm_exec(cfg, "cat /etc/ssh/ssh_host_ed25519_key.pub")
            pubtxt = out
        else:
            r = rsh(cfg["target"], "cat /etc/ssh/ssh_host_ed25519_key.pub")
            rc, pubtxt = r.returncode, r.stdout
        if rc or not pubtxt.startswith("ssh-ed25519 "):
            print("%s: cannot read host key" % name)
            bad += 1
            continue
        key = " ".join(pubtxt.split()[:2])
        if e["fp"] and ca.fp_of_publine(key) != e["fp"]:
            print("%s: host key changed since the certificate was issued: re-issue by hand (roll-host)" % name)
            bad += 1
            continue
        line = ca.sign("host", key, cfg["id"], e["principals"], ca.HOST_VALID)
        inst = "env PATH=$PATH:/usr/local/bin ctg-ssh-ca install-host-cert -"
        if cfg.get("vmid"):
            rc, out, err = qm_exec(cfg, inst, inp=line + "\n")
            msg = out or err
        else:
            r = rsh(cfg["target"], sudo_for(cfg["target"]) + inst, inp=line + "\n")
            rc, msg = r.returncode, r.stdout + r.stderr
        print("%s: %s %s" % (name, "renewed" if not rc else "FAILED", msg.strip()[:200]))
        bad += bool(rc)
    sys.exit(1 if bad else 0)


def cmd_pending(a):
    for f in sorted(glob.glob(ca.p("pending", "*.json"))):
        j = json.load(open(f))
        print(j["fp"], j["device"], j["node"], time.strftime("%F %T", time.localtime(j["ts"])))


def cmd_approve(a):
    for f in glob.glob(ca.p("pending", "*.json")):
        j = json.load(open(f))
        if j["fp"] == a.fp:
            pr = a.principals.split(",") if a.principals else pol()["user_principals"]
            ca.sign("user", j["pub"], "user-" + j["device"], pr, ca.USER_VALID)
            os.rename(f, f + ".done")
            print("approved", j["fp"], j["device"], "principals", ",".join(pr))
            return
    sys.exit("no such pending key")


def cmd_revoke(a):
    fps = {e["fp"] for e in ca.ledger() if a.what in (e["fp"], e["key_id"])}
    if a.what.startswith("SHA256:"):
        fps.add(a.what)
    if not fps:
        sys.exit("nothing in the ledger matches %s" % a.what)
    with open(ca.p("revoked.txt"), "a") as f:
        for fp in sorted(fps):
            f.write("%s  # %s %s\n" % (fp, time.strftime("%F"), a.note))
    n = ca.build_krl()
    print("revoked %s; KRL holds %d keys. Servers fetch it within a day (or run: ctg-ssh-ca renew-all)."
          % (",".join(sorted(fps)), n))


def cmd_backup(a):
    """Passphrase-encrypted copy of the CA directory for the restic/B2 backups (crystalcave backs up ~/.hermes/state)."""
    pf = os.path.expanduser("~/.hermes/secrets/ssh-ca-offsite.passphrase")
    outd = os.path.expanduser("~/.hermes/state/ssh-ca/offsite")
    os.makedirs(outd, exist_ok=True)
    out = os.path.join(outd, "ssh-ca-%s.tar.gpg" % time.strftime("%Y%m%d"))
    tar = subprocess.Popen(["tar", "-C", os.path.dirname(ca.CA_DIR), "-cf", "-", os.path.basename(ca.CA_DIR)],
                           stdout=subprocess.PIPE)
    gh = tempfile.mkdtemp(dir=os.path.expanduser("~/.hermes/cache/scratch"))
    os.chmod(gh, 0o700)
    g = subprocess.run(["gpg", "--homedir", gh, "--batch", "--yes", "--pinentry-mode", "loopback", "--passphrase-file", pf,
                        "--symmetric", "--cipher-algo", "AES256", "-o", out], stdin=tar.stdout, capture_output=True)
    tar.wait()
    if g.returncode or tar.returncode:
        sys.exit("backup failed")
    os.chmod(out, 0o600)
    print(out)


def cmd_list(a):
    now = time.time()
    seen = {}
    for e in ca.ledger():
        seen[(e["kind"], e["key_id"], e["fp"])] = e
    for (k, i, fp), e in sorted(seen.items()):
        if e["valid_before"] > now:
            print("%-5s %-34s %s till %s %s" % (k, i, fp, time.strftime("%F", time.localtime(e["valid_before"])),
                                                ",".join(e["principals"])))


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sp = ap.add_subparsers(dest="c", required=True)
    s = sp.add_parser("init"); s.add_argument("--owner-login", default="canutethegreat@gmail.com"); s.set_defaults(f=cmd_init)
    sp.add_parser("fingerprints").set_defaults(f=cmd_fingerprints)
    s = sp.add_parser("roll-host"); s.add_argument("target"); s.add_argument("--principals", required=True); s.add_argument("--id"); s.set_defaults(f=cmd_roll_host)
    s = sp.add_parser("issue-host"); s.add_argument("--pub", required=True); s.add_argument("--id", required=True); s.add_argument("--principals", required=True); s.add_argument("--out"); s.set_defaults(f=cmd_issue_host)
    s = sp.add_parser("issue-user"); s.add_argument("--pub", required=True); s.add_argument("--id", required=True); s.add_argument("--principals"); s.add_argument("--out"); s.set_defaults(f=cmd_issue_user)
    s = sp.add_parser("roll-user"); s.add_argument("target"); s.add_argument("--device", required=True); s.add_argument("--principals"); s.add_argument("--key", default="id_ed25519"); s.set_defaults(f=cmd_roll_user)
    sp.add_parser("pending").set_defaults(f=cmd_pending)
    s = sp.add_parser("approve"); s.add_argument("fp"); s.add_argument("--principals"); s.set_defaults(f=cmd_approve)
    s = sp.add_parser("revoke"); s.add_argument("what"); s.add_argument("--note", default=""); s.set_defaults(f=cmd_revoke)
    sp.add_parser("list").set_defaults(f=cmd_list)
    sp.add_parser("backup").set_defaults(f=cmd_backup)
    s = sp.add_parser("push-renew"); s.add_argument("--force", action="store_true"); s.set_defaults(f=cmd_push_renew)
    a = ap.parse_args()
    a.f(a)


if __name__ == "__main__":
    main()
