#!/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
  backup                                 passphrase-encrypted copy of the CA dir for the restic backups
"""
import argparse
import glob
import json
import os
import shlex
import subprocess
import sys
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"]
    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) + "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 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)
    g = subprocess.run(["gpg", "--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)
    a = ap.parse_args()
    a.f(a)


if __name__ == "__main__":
    main()
