#!/usr/bin/env python3
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""coffrectl — le Coffre de la box, pour root (#1367 P1).

Les phrases, codes et valeurs ne passent JAMAIS par la ligne de commande
(visible dans `ps` et l'historique) : invite masquée sur un terminal, sinon
entrée standard.

  coffrectl etat
  coffrectl initialiser                    # facultatif : la connexion d'un admin crée le Coffre (#1855)
  coffrectl ouvrir [--secours]
  coffrectl sceller
  coffrectl secrets [COMPARTIMENT]
  coffrectl poser COMPARTIMENT NOM         # valeur : invite ou stdin
  coffrectl lire COMPARTIMENT NOM
  coffrectl retirer COMPARTIMENT NOM
  coffrectl serrure-ajouter [LIBELLE]
  coffrectl serrure-retirer ID
  coffrectl codes                          # régénère les 5 codes de secours
  coffrectl journal [N] [--verifier]

  coffrectl depot etat                     # clé de signature du dépôt apt (P2)
  coffrectl depot proteger                 # phrase aléatoire, rangée au Coffre
  coffrectl depot proteger --demarrage     # phrase aléatoire au niveau 0 : sans humain, déverrouillée au boot
  coffrectl depot deverrouiller            # redonne la phrase de niveau 0 à gpg-agent (démarrage, reprise)
  coffrectl depot session [--minutes N]    # signer pendant N min (15 ; 60 au plus)
  coffrectl depot fin                      # l'agent oublie la phrase tout de suite

  coffrectl niveau0 etat                   # secrets de démarrage (P3, systemd-creds)
  coffrectl niveau0 migrer NOM CHEMIN UNITE.service   # chiffre et déclare ; garde le clair
  coffrectl niveau0 retirer-clair NOM      # détruit le clair, si l'unité tourne avec la crédence
  coffrectl niveau0 lire NOM               # root : la valeur, sur la sortie standard

  coffrectl recouvrement etat              # recouvrement par le maillage (P8)
  coffrectl recouvrement preparer DÉTENTEUR DÉTENTEUR [DÉTENTEUR…]
                                           # box pair (sbx-openpgp pairs) ou « papier » ;
                                           # deux détenteurs quelconques rouvriront le Coffre
  coffrectl recouvrement annuler
  coffrectl recouvrement rendre ORIGINE    # SUR UN PAIR : renvoie à ORIGINE la part qu'elle nous a confiée
  coffrectl recouvrement ouvrir            # parts rendues par les pairs + parts papier saisies

  coffrectl nextcloud etat [CONTENEUR]     # secrets d'instance (P7) : copie au Coffre à jour ?
  coffrectl nextcloud sauvegarder [CONTENEUR]   # copie de recouvrement dans le compartiment box
"""
import getpass
import http.client
import json
import os
import socket
import subprocess
import sys

SOCKET = os.environ.get("SECUBOX_COFFRE_SOCKET_RACINE", "/run/secubox-coffre/racine.sock")
sys.path.insert(0, os.environ.get("SECUBOX_COFFRE_LIB", "/usr/lib/secubox/vault"))


class _Connexion(http.client.HTTPConnection):
    def __init__(self):
        super().__init__("coffre", timeout=60)

    def connect(self):
        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.sock.settimeout(60)
        self.sock.connect(SOCKET)


def appel(methode: str, chemin: str, corps=None):
    try:
        c = _Connexion()
        c.request(methode, chemin, body=None if corps is None else json.dumps(corps),
                  headers={"Content-Type": "application/json"})
        r = c.getresponse()
        donnees = json.loads(r.read() or b"{}")
    except (FileNotFoundError, ConnectionRefusedError):
        sys.exit("coffrectl : le démon ne répond pas (systemctl status secubox-vault)")
    except PermissionError:
        sys.exit("coffrectl : réservé à root")
    if r.status >= 400:
        sys.exit(f"coffrectl : {donnees.get('detail', r.status)}")
    return donnees


def saisie(invite: str, confirmer: bool = False) -> str:
    if not sys.stdin.isatty():
        return sys.stdin.readline().rstrip("\n")
    v = getpass.getpass(invite)
    if confirmer and getpass.getpass("Confirmer : ") != v:
        sys.exit("coffrectl : les deux saisies diffèrent")
    return v


def affiche_etat(e: dict) -> None:
    if not e.get("initialise"):
        print("Coffre : non initialisé — il naîtra à la prochaine connexion d'un administrateur"
              " (ou, pour une phrase à part : coffrectl initialiser)")
        return
    print(f"Coffre : {'OUVERT' if e.get('ouvert') else 'scellé'}"
          + (f" (rescellement dans {e['reste_s']} s)" if e.get("ouvert") and "reste_s" in e else ""))
    j = e.get("journal", {})
    print(f"Journal : {j.get('lignes', 0)} lignes, " + ("intègre" if j.get("integre") else
          f"CHAÎNE ROMPUE à la ligne {j.get('ligne_fautive')}"))
    for s in e.get("serrures", []):
        print(f"  serrure {s['id']}  {s['genre']:8}  {s['libelle']}")
    for c in e.get("compartiments", []):
        print(f"  compartiment {c['id']}  ({c['nature']}) — {c['secrets']} secret(s)")
    r = e.get("recouvrement")
    if r:
        print(f"  recouvrement : jeu {r['jeu']} — {', '.join(r['detenteurs'])}")


def _empreinte_lisible(fpr: str) -> str:
    """L'empreinte ENTIÈRE, par groupes de quatre : on désigne la clé apt par
    son début (219BA872…) et gpg par sa fin ; seule l'entière évite de croire
    à deux clés."""
    return " ".join(fpr[i:i + 4] for i in range(0, len(fpr), 4))


def depot(args) -> None:
    """Session de signature du dépôt apt (P2)."""
    from coffre import depot as d
    sous = args[0] if args else "etat"
    try:
        fpr = d.empreinte_signature()
        if sous == "etat":
            e = d.etat(fpr)
            if "--porcelaine" in args:
                print(f"protegee={int(e['protegee'])} en_cache={int(e['en_cache'])} empreinte={fpr}")
                return
            from coffre import niveau0
            niv0 = niveau0.chemin_cred(d.nom_secret(fpr)).exists()
            print(f"clé {_empreinte_lisible(fpr)} : " + (("protégée au niveau 0 (déverrouillée au démarrage)" if niv0 else "protégée par le Coffre") if e["protegee"] else "EN CLAIR — coffrectl depot proteger --demarrage")
                  + (" ; session ouverte (l'agent tient la phrase)" if e["en_cache"] else ""))
        elif sous == "proteger" and "--demarrage" in args:
            d.proteger_demarrage(fpr)
            print(f"clé {_empreinte_lisible(fpr)} protégée : phrase aléatoire au niveau 0 (systemd-creds), "
                  "déverrouillée maintenant et à chaque démarrage. Aucune phrase humaine.")
        elif sous == "deverrouiller" and "--depuis-coffre" in args:
            # La phrase est un secret du Coffre (box / depot-<empreinte>) : la connexion d'un
            # administrateur ouvre le Coffre, la minuterie lance cette commande, la clé se déverrouille.
            if not appel("GET", "/etat").get("ouvert"):
                if "--si-ouvert" in args:
                    return                              # scellé : rien à faire, la minuterie réessaiera
                sys.exit("coffrectl depot : le Coffre est scellé — ouvrez-le (connexion d'un administrateur)")

            def _lire(nom):
                try:
                    return appel("GET", f"/secrets/box/{nom}/valeur")["valeur"]
                except SystemExit:
                    return None                         # secret absent : traité plus bas
            try:
                e = d.deverrouiller_depuis_coffre(fpr, _lire)
            except d.ErreurDepot as err:
                if "--si-ouvert" in args and "absent du Coffre" in str(err):
                    return                              # pas encore rangée : silence, pas de bruit chaque minute
                raise
            if not e.get("deja"):
                print(f"clé {_empreinte_lisible(fpr)} déverrouillée depuis le Coffre (en mémoire jusqu'au redémarrage)")
        elif sous == "deverrouiller":
            if "--si-present" in args:                  # démarrage : rien à faire sans crédence
                from coffre import niveau0
                if not niveau0.chemin_cred(d.nom_secret(fpr)).exists():
                    return
            d.deverrouiller(fpr)
            print(f"clé {_empreinte_lisible(fpr)} déverrouillée pour la signature")
        elif sous == "proteger":
            d.proteger(fpr, lambda nom, valeur: appel("POST", "/secrets",
                                                      {"compartiment": "box", "nom": nom, "valeur": valeur}))
            print(f"clé {_empreinte_lisible(fpr)} protégée : sa phrase n'existe qu'au Coffre. Signer : coffrectl depot session")
        elif sous == "session":
            minutes = 15
            if "--minutes" in args:
                minutes = int(args[args.index("--minutes") + 1])
            phrase = appel("GET", f"/secrets/box/{d.nom_secret(fpr)}/valeur")["valeur"]
            d.session(fpr, phrase, minutes)
            del phrase
            print(f"session de signature ouverte pour {minutes} min (clé {_empreinte_lisible(fpr)})")
        elif sous == "fin":
            d.oublier(fpr)
            print("session de signature close")
        else:
            sys.exit("coffrectl depot : etat | proteger [--demarrage] | deverrouiller [--si-present | --depuis-coffre [--si-ouvert]] | session [--minutes N] | fin")
    except d.ErreurDepot as e:
        sys.exit(f"coffrectl depot : {e}")


def niveau0(args) -> None:
    """Secrets de démarrage (P3) : systemd-creds, sans le Coffre ouvert."""
    from coffre import niveau0 as n
    sous = args[0] if args else "etat"
    try:
        if sous == "etat":
            lignes = n.etat()
            if not lignes:
                print("aucun secret de niveau 0 — coffrectl niveau0 migrer NOM CHEMIN UNITE.service")
            for e in lignes:
                unites = ", ".join(f"{u['unite']} ({'chargée' if u['porte'] else 'à redémarrer'})" for u in e["unites"])
                print(f"{e['nom']:28} clair {'PRÉSENT' if e['clair_present'] else 'retiré'} — {unites}")
        elif sous == "migrer" and len(args) == 4:
            n.migrer(args[1], args[2], args[3])
            print(f"{args[1]} chiffré et déclaré à {args[3]} — redémarrer l'unité, puis : coffrectl niveau0 retirer-clair {args[1]}")
        elif sous == "retirer-clair" and len(args) == 2:
            print("détruit :", n.retirer_clair(args[1]))
        elif sous == "lire" and len(args) == 2:
            sys.stdout.write(n.dechiffrer(args[1]).decode("utf-8", "replace"))
        else:
            sys.exit("coffrectl niveau0 : etat | migrer NOM CHEMIN UNITE.service | retirer-clair NOM | lire NOM")
    except n.ErreurNiveau0 as e:
        sys.exit(f"coffrectl niveau0 : {e}")


# ── recouvrement par le maillage (P8) ──────────────────────────────────────────
OBJET_PART = "Coffre : part de recouvrement"
OBJET_RENDUE = "Coffre : part rendue"
SBX_OPENPGP = os.environ.get("SECUBOX_SBX_OPENPGP", "sbx-openpgp")


def _openpgp(*args: str, entree: str = None) -> subprocess.CompletedProcess:
    try:
        return subprocess.run([SBX_OPENPGP, *args], input=entree, capture_output=True, text=True, timeout=60)
    except FileNotFoundError:
        sys.exit("coffrectl : sbx-openpgp absent — le recouvrement passe par la messagerie OpenPGP des box")


def _pairs() -> dict:
    r = _openpgp("_pairs-json")
    if r.returncode != 0:
        sys.exit("coffrectl : annuaire des box liées illisible (" + (r.stderr.strip() or "?") + ")")
    return {p["boxname"]: p for p in json.loads(r.stdout or "[]")}


def _boite() -> list:
    r = _openpgp("_boite-json")
    if r.returncode != 0:
        sys.exit("coffrectl : boîte OpenPGP illisible (" + (r.stderr.strip() or "?") + ")")
    return json.loads(r.stdout or "[]")


def _message(ident: str) -> dict:
    r = _openpgp("_boite-json", ident)
    return json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {}


def corps_part(origine: str, jeu: str, part: str) -> str:
    return f"coffre-recouvrement/1\norigine: {origine}\njeu: {jeu}\npart: {part}\n"


def lit_corps(texte: str) -> dict:
    lignes = (texte or "").splitlines()
    if not lignes or lignes[0].strip() != "coffre-recouvrement/1":
        return {}
    d = {}
    for l in lignes[1:]:
        k, _, v = l.partition(":")
        if k.strip() in ("origine", "jeu", "part"):
            d[k.strip()] = v.strip()
    return d if {"origine", "jeu", "part"} <= set(d) else {}


def _soi(pairs: dict) -> str:
    return next((n for n, p in pairs.items() if p.get("soi")), socket.gethostname().split(".")[0])


def recouvrement(args) -> None:
    sous = args[0] if args else "etat"
    if sous == "etat":
        r = appel("GET", "/recouvrement")["recouvrement"]
        print("aucun jeu de recouvrement" if not r else
              f"jeu {r['jeu']} — détenteurs : {', '.join(r['detenteurs'])} (deux quelconques rouvrent le Coffre)")
    elif sous == "preparer" and len(args) >= 3:
        detenteurs = args[1:]
        pairs = _pairs()
        soi = _soi(pairs)
        for d in detenteurs:
            if not d.startswith("papier") and (d not in pairs or pairs[d].get("soi")):
                sys.exit(f"coffrectl : « {d} » n'est ni « papier » ni une box liée (sbx-openpgp pairs)")
        jeu = appel("POST", "/recouvrement/preparer", {"detenteurs": detenteurs})
        papier = []
        for d, part in zip(jeu["detenteurs"], jeu["parts"]):
            if d.startswith("papier"):
                papier.append((d, part))
                continue
            r = _openpgp("envoyer", d, OBJET_PART, "-", entree=corps_part(soi, jeu["jeu"], part))
            if r.returncode != 0:
                appel("DELETE", "/recouvrement")
                sys.exit(f"coffrectl : {d} n'a pas reçu sa part ({(r.stderr or '').strip()}) — jeu annulé")
            print(f"part {jeu['detenteurs'].index(d)} confiée à {d} (signée, chiffrée pour sa clé liée)")
        for d, part in papier:
            print(f"PART PAPIER ({d}) — à recopier et garder HORS LIGNE, ne s'affichera plus :\n    {part}")
        print(f"jeu {jeu['jeu']} prêt : deux détenteurs quelconques parmi {len(detenteurs)} rouvrent le Coffre.")
    elif sous == "annuler":
        appel("DELETE", "/recouvrement")
        print("jeu de recouvrement annulé")
    elif sous == "rendre" and len(args) == 2:
        origine = args[1]
        pairs = _pairs()
        if origine not in pairs or pairs[origine].get("soi"):
            sys.exit(f"coffrectl : « {origine} » n'est pas une box liée")
        trouve = None
        for m in _boite():                              # du plus récent au plus ancien
            if m.get("boxname") != origine and m.get("de") != pairs[origine]["did"]:
                continue
            d = _message(m["id"])
            if d.get("objet") == OBJET_PART and lit_corps(d.get("contenu", "")):
                trouve = lit_corps(d["contenu"])
                break
        if not trouve:
            sys.exit(f"coffrectl : aucune part confiée par {origine} dans la boîte OpenPGP")
        r = _openpgp("envoyer", origine, OBJET_RENDUE, "-",
                     entree=corps_part(_soi(pairs), trouve["jeu"], trouve["part"]))
        if r.returncode != 0:
            sys.exit(f"coffrectl : {origine} n'a pas reçu la part ({(r.stderr or '').strip()})")
        print(f"part du jeu {trouve['jeu']} rendue à {origine}")
    elif sous == "ouvrir":
        r = appel("GET", "/recouvrement")["recouvrement"]
        if not r:
            sys.exit("coffrectl : aucun jeu de recouvrement sur cette box")
        parts, rendues = [], []
        for m in _boite():
            d = _message(m["id"])
            c = lit_corps(d.get("contenu", "")) if d.get("objet") == OBJET_RENDUE else {}
            if c.get("jeu") == r["jeu"] and c["part"] not in parts:
                parts.append(c["part"])
                rendues.append(m["id"])
                print(f"part rendue par {m.get('boxname') or m.get('de')}")
        while len(parts) < 2:
            p = saisie("Part papier (jeu.indice.XXXX-…) : ").strip()
            if not p:
                sys.exit("coffrectl : deux parts sont nécessaires")
            parts.append(p)
        affiche_etat(appel("POST", "/recouvrement/ouvrir", {"parts": parts}))
        for ident in rendues:                           # elles ont servi : on ne les garde pas
            _openpgp("_boite-retirer", ident)
        print("Coffre rouvert par le maillage. Ce jeu ne vaut plus : posez une nouvelle phrase "
              "(coffrectl serrure-ajouter), puis refaites un jeu (coffrectl recouvrement preparer …).")
    else:
        sys.exit("coffrectl recouvrement : etat | preparer DÉTENTEUR DÉTENTEUR… | annuler | rendre ORIGINE | ouvrir")


# ── Nextcloud : secrets d'instance (P7) ───────────────────────────────────────
# config.php porte secret, passwordsalt, instanceid, le mot de passe de la base
# et celui du relais SMTP. PHP les lit dans le conteneur au démarrage : ils ne
# peuvent pas attendre un humain, ni passer au niveau 0 de l'hôte sans finir
# en clair dans la configuration LXC. Le Coffre en garde une COPIE DE
# RECOUVREMENT (conteneur reconstruit, config.php perdu) — elle protège de la
# perte, pas du vol. Les valeurs vont du conteneur au Coffre par des tubes ;
# jamais par la ligne de commande, jamais à l'écran.
NEXTCLOUD_CLES = ("instanceid", "secret", "passwordsalt", "dbpassword", "mail_smtppassword")
NEXTCLOUD_OCC = os.environ.get("SECUBOX_NEXTCLOUD_OCC", "/var/www/nextcloud/occ")


def nom_nextcloud(cle: str) -> str:
    return "nextcloud-" + cle.replace("_", "-")


class LectureImpossible(RuntimeError):
    pass


def _occ_valeur(conteneur: str, cle: str):
    """La valeur, None si la clé est absente de config.php (occ : code 1,
    rien sur la sortie) ; LectureImpossible sinon — après un second essai."""
    for _ in range(2):
        r = subprocess.run(["lxc-attach", "-n", conteneur, "--", "runuser", "-u", "www-data", "--",
                            "php", NEXTCLOUD_OCC, "config:system:get", cle],
                           capture_output=True, text=True, timeout=60)
        if r.returncode == 0 and r.stdout.strip():
            return r.stdout.rstrip("\n")
        if r.returncode == 1 and not r.stdout.strip() and not r.stderr.strip():
            return None
    raise LectureImpossible(f"occ code {r.returncode} : {(r.stderr.strip().splitlines() or ['?'])[-1][:120]}")


def nextcloud(args) -> None:
    sous = args[0] if args else "etat"
    conteneur = args[1] if len(args) > 1 else "nextcloud"
    if sous not in ("etat", "sauvegarder"):
        sys.exit("coffrectl nextcloud : etat | sauvegarder [CONTENEUR]")
    gardes = {s["nom"] for s in appel("GET", "/secrets?compartiment=box")["secrets"]}
    for cle in NEXTCLOUD_CLES:
        nom = nom_nextcloud(cle)
        try:
            vivante = _occ_valeur(conteneur, cle)
        except LectureImpossible as e:
            print(f"{cle:18} LECTURE IMPOSSIBLE ({e})")
            continue
        if vivante is None:
            print(f"{cle:18} absent de config.php")
            continue
        if sous == "sauvegarder":
            v = appel("POST", "/secrets", {"compartiment": "box", "nom": nom, "valeur": vivante})["version"]
            print(f"{cle:18} copié au Coffre (box/{nom}, version {v})")
        elif nom not in gardes:
            print(f"{cle:18} PAS de copie au Coffre")
        else:
            gardee = appel("GET", f"/secrets/box/{nom}/valeur")["valeur"]
            print(f"{cle:18} copie au Coffre {'à jour' if gardee == vivante else 'DIFFÉRENTE de config.php'}")
        del vivante


def principal(argv) -> None:
    if not argv or argv[0] in ("-h", "--help", "aide"):
        print(__doc__)
        return
    cmd, args = argv[0], argv[1:]
    if cmd == "etat":
        affiche_etat(appel("GET", "/etat"))
    elif cmd == "initialiser":
        phrase = saisie("Phrase du Coffre (12 caractères au moins) : ", confirmer=True)
        codes = appel("POST", "/initialiser", {"phrase": phrase})["codes"]
        print("Coffre initialisé et ouvert. CODES DE SECOURS — à imprimer, ne s'afficheront plus :")
        for c in codes:
            print("   ", c)
    elif cmd == "ouvrir":
        genre = "secours" if "--secours" in args else "phrase"
        secret = saisie("Code de secours : " if genre == "secours" else "Phrase : ")
        affiche_etat(appel("POST", "/ouvrir", {"secret": secret, "genre": genre}))
    elif cmd == "sceller":
        affiche_etat(appel("POST", "/sceller"))
    elif cmd == "secrets":
        q = f"?compartiment={args[0]}" if args else ""
        for s in appel("GET", "/secrets" + q)["secrets"]:
            print(f"{s['compartiment']}/{s['nom']}  v{s['version']}")
    elif cmd == "poser" and len(args) == 2:
        v = appel("POST", "/secrets", {"compartiment": args[0], "nom": args[1],
                                       "valeur": saisie("Valeur : ")})["version"]
        print(f"{args[0]}/{args[1]} : version {v}")
    elif cmd == "lire" and len(args) == 2:
        sys.stdout.write(appel("GET", f"/secrets/{args[0]}/{args[1]}/valeur")["valeur"] + "\n")
    elif cmd == "retirer" and len(args) == 2:
        appel("DELETE", f"/secrets/{args[0]}/{args[1]}")
        print("retiré")
    elif cmd == "serrure-ajouter":
        phrase = saisie("Nouvelle phrase : ", confirmer=True)
        print("serrure", appel("POST", "/serrures/phrase",
                               {"phrase": phrase, "libelle": " ".join(args)})["id"])
    elif cmd == "serrure-retirer" and len(args) == 1:
        appel("DELETE", f"/serrures/{args[0]}")
        print("retirée")
    elif cmd == "codes":
        print("Nouveaux codes de secours (les anciens ne valent plus) :")
        for c in appel("POST", "/serrures/secours")["codes"]:
            print("   ", c)
    elif cmd == "journal":
        n = next((int(a) for a in args if a.isdigit()), 30)
        j = appel("GET", f"/journal?n={n}")
        for e in j["entrees"]:
            print(e["t"], e["evt"], json.dumps(e["details"], ensure_ascii=False))
        if "--verifier" in args or not j["integre"]:
            print("chaîne :", "intègre" if j["integre"] else f"ROMPUE à la ligne {j['ligne_fautive']}")
    elif cmd == "depot":
        depot(args)
    elif cmd == "niveau0":
        niveau0(args)
    elif cmd == "recouvrement":
        recouvrement(args)
    elif cmd == "nextcloud":
        nextcloud(args)
    else:
        sys.exit(f"coffrectl : commande inconnue ou incomplète « {' '.join(argv)} » — coffrectl aide")


if __name__ == "__main__":
    principal(sys.argv[1:])
