#!/usr/bin/env python3 """La frontiere certifiee par la PKI du site, avec sa propre autorite subordonnee. python3 scripts/frontiere_pki.py plan # lit, ne change rien CONFIRMER=true python3 scripts/frontiere_pki.py appliquer # pose ce qui manque CONFIRMER=true python3 scripts/frontiere_pki.py regenerer # refait l'autorite a neuf CONFIRMER=true python3 scripts/frontiere_pki.py interface # R3 : l'interface web passe au certificat POURQUOI (2026-10-12). Directive : tout transit chiffre, a l'interne comme a l'externe. La frontiere (OPNsense) etait le seul element du site sans certificat de l'autorite : son interface servait un certificat auto-signe, son exportateur de metriques et son syslog parlaient en clair. Daniel a demande qu'elle soit certifiee par la PKI du site ET qu'elle porte sa propre autorite subordonnee — pour ses services et, plus tard, les certificats des VPN d'administration. CE QUE LE SCRIPT POSE, dans le magasin d'OPNsense (Systeme > Confiance) : 1. la RACINE du site, certificat seul (pour que la chaine se lie et se verifie) ; 2. l'AUTORITE de la frontiere, signee par la racine, bornee : aucune autorite sous elle (pathlen 0), signature de certificats seulement, noms sous le domaine du site et adresses du site — rien d'autre (contrainte de noms critique) ; 3. le certificat de SERVICES (interface, syslog : serveur et client) ; 4. le certificat de l'EXPORTATEUR, a part : il tourne en `nobody`, qui ne doit pas pouvoir lire la cle des services. OU NAIT CHAQUE CLE. Celle de l'autorite nait SUR la frontiere (openssl), dans un dossier de root, et y est importee par l'API LOCALE de la boite : elle ne transite ni par le poste ni par le controleur. La racine signe sur `site-pki-01` ; sa cle n'en sort pas, son mot de passe vient de la voute de l'underlay et passe par l'entree standard. Les cles des certificats sont generees par OPNsense lui-meme. ELIGIBILITE DES NOMS. La contrainte rend invalide tout nom hors du domaine : le certificat ne porte donc ni le nom court, ni le transit, ni la grappe — seulement le FQDN et les pattes du site (`setops_frontiere`, derive par l'inventaire du site). """ from __future__ import annotations import os import shlex import subprocess import sys import time from pathlib import Path RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) import devis_opnsense as devis_mod # noqa: E402 import site_inventaire as si # noqa: E402 from appliquer_opnsense import Frontiere, _voute # noqa: E402 JOUR = 86400 SOUS_AC_VALIDITE_H = 5 * 365 * 24 # 5 ans, dans la validite de la racine CERT_VALIDITE_J = 397 # plafond des navigateurs SOUS_AC_MARGE_J = 365 # en deca : a refaire CERT_MARGE_J = 30 HOTE_PKI = "site-pki-01" REBOND = "ansible@{admin}" def _ssh(cible: list[str], commande: str, entree: str | None = None) -> str: r = subprocess.run(["ssh", "-o", "BatchMode=yes", *cible, commande], input=entree, capture_output=True, text=True, timeout=120) if r.returncode != 0: raise SystemExit(f"echec ssh {cible[-1]} : {(r.stderr or r.stdout).strip()[:400]}") return r.stdout class Contexte: def __init__(self) -> None: inv = si.inventaire() hv = inv["_meta"]["hostvars"] pki = hv.get(HOTE_PKI) or {} self.f = pki.get("setops_frontiere") or {} if not self.f.get("admin") or not self.f.get("adresses"): raise SystemExit(f"`setops_frontiere` absent de {HOTE_PKI} : l'inventaire du site ne la derive pas.") self.domaine = str(pki.get("domaine_interne") or "") self.ip_pki = str(pki.get("ansible_host") or "") self.nom = str(self.f["nom"]) self.fqdn = f"{self.nom}.{self.domaine}" base = devis_mod.depot_hebergeur() self.racine_pem = (base / "ac-racine-site.crt").read_text(encoding="utf-8") intr = devis_mod.intrants_frontiere() self.v = _voute(base) self.api = Frontiere(str(intr["opnsense_api_url"]), self.v["vault_opnsense_api_key"], self.v["vault_opnsense_api_secret"], bool(intr.get("opnsense_api_verifier_certs"))) self.frontiere = [f"ansible@{self.f['admin']}"] self.pki = ["-J", REBOND.format(admin=self.f["admin"]), f"ansible@{self.ip_pki}"] self.desc_racine = "Set-OPS racine du site" self.desc_sous_ac = f"Set-OPS autorite de {self.nom}" self.desc_services = f"Set-OPS {self.nom} services" self.desc_expo = f"Set-OPS {self.nom} exportateur" def lister(self, quoi: str) -> list[dict]: """Les autorites (`ca`) ou certificats (`cert`) du magasin — SANS LEURS CLES. PAS PAR L'API (mesure du 2026-10-12). `/api/trust/{ca,cert}/search` renvoie chaque objet ENTIER, cle privee comprise (`prv`) : lister le magasin depuis le poste y rapatriait la cle de l'autorite. La lecture se fait donc SUR la frontiere, dans `config.xml`, et seuls les champs publics en sortent. """ lecteur = ( "import base64,json,sys,xml.etree.ElementTree as E\n" "c=E.parse('/conf/config.xml').getroot()\n" "out=[]\n" f"for n in c.iter({quoi!r}):\n" " crt=base64.b64decode(n.findtext('crt') or '').decode()\n" " out.append({k:(n.findtext(k) or '') for k in ('refid','descr','caref')}|{'crt':crt,'uuid':n.get('uuid') or ''})\n" "json.dump(out,sys.stdout)\n") import json rows = json.loads(_ssh(self.frontiere, "sudo -n /usr/local/bin/python3 -", entree=lecteur)) for r in rows: r["valid_to"] = _fin_de_validite(r["crt"]) return rows def _fin_de_validite(pem: str) -> str: """Fin de validite (epoch) d'un certificat PEM, lue par openssl sur le poste.""" if "BEGIN CERTIFICATE" not in pem: return "0" r = subprocess.run(["openssl", "x509", "-noout", "-enddate", "-dateopt", "iso_8601"], input=pem, capture_output=True, text=True) fin = r.stdout.strip().split("=", 1)[-1] try: return str(int(time.mktime(time.strptime(fin, "%Y-%m-%d %H:%M:%SZ")) - time.timezone)) except ValueError: return "0" def _valide_jusqua(row: dict) -> float: try: return float(row.get("valid_to") or 0) except ValueError: return 0.0 def _meilleur(rows: list[dict], descr: str) -> dict | None: rows = [r for r in rows if r.get("descr") == descr] return max(rows, key=_valide_jusqua) if rows else None def etat(c: Contexte) -> dict: cas, certs = c.lister("ca"), c.lister("cert") maintenant = time.time() racine = _meilleur(cas, c.desc_racine) sous_ac = _meilleur(cas, c.desc_sous_ac) e = { "racine": racine, "sous_ac": sous_ac, "services": _meilleur(certs, c.desc_services), "exportateur": _meilleur(certs, c.desc_expo), } e["a_faire"] = [] if not racine: e["a_faire"].append("racine") if not sous_ac or _valide_jusqua(sous_ac) < maintenant + SOUS_AC_MARGE_J * JOUR: e["a_faire"].append("sous_ac") for cle in ("services", "exportateur"): r = e[cle] if (not r or _valide_jusqua(r) < maintenant + CERT_MARGE_J * JOUR or (sous_ac and r.get("caref") != sous_ac.get("refid"))): e["a_faire"].append(cle) return e def afficher(c: Contexte, e: dict) -> None: print(f"Frontiere {c.nom} ({c.f['admin']}) — domaine {c.domaine}, adresses {', '.join(c.f['adresses'])}") for cle, desc in (("racine", c.desc_racine), ("sous_ac", c.desc_sous_ac), ("services", c.desc_services), ("exportateur", c.desc_expo)): r = e[cle] if r: fin = time.strftime("%Y-%m-%d", time.localtime(_valide_jusqua(r))) print(f" {'A REFAIRE' if cle in e['a_faire'] else 'ok':9} {desc} (refid {r.get('refid')}, jusqu'au {fin})") else: print(f" {'A POSER':9} {desc}") GABARIT_SOUS_AC = """{{ "subject": {{{{ toJson .Subject }}}}, "keyUsage": ["certSign", "crlSign"], "basicConstraints": {{"isCA": true, "maxPathLen": 0}}, "nameConstraints": {{ "critical": true, "permittedDNSDomains": [{domaine}], "permittedIPRanges": [{supernet}] }} }} """ def poser_racine(c: Contexte) -> str: r = c.api("/api/trust/ca/add", {"ca": {"action": "existing", "descr": c.desc_racine, "crt_payload": c.racine_pem}}) if r.get("result") != "saved": raise SystemExit(f"import de la racine refuse : {r}") print(f" racine importee (certificat seul) : {r.get('uuid')}") return str(r.get("uuid")) def poser_sous_ac(c: Contexte) -> None: import json # 1. LA CLE NAIT SUR LA FRONTIERE. Dossier de root, 0700 ; la demande seule en sort. sujet = f"/O=Set-OPS Internal CA/CN=Set-OPS {c.nom} CA" dossier = _ssh(c.frontiere, "sudo -n sh -c 'umask 077; mktemp -d /root/setops-pki.XXXXXX'").strip() csr = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( f"umask 077; cd {dossier} && openssl ecparam -name prime256v1 -genkey -noout -out sous-ac.key" f" && openssl req -new -key sous-ac.key -subj {shlex.quote(sujet)} -out sous-ac.csr && cat sous-ac.csr")) try: # 2. LA RACINE SIGNE SUR site-pki-01. Le mot de passe passe par l'entree standard. gabarit = GABARIT_SOUS_AC.format(domaine=json.dumps(c.domaine), supernet=json.dumps(c.f["supernet"])) script = ( "set -e; umask 077; D=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT; " f"printf %s {shlex.quote(csr)} > \"$D/s.csr\"; printf %s {shlex.quote(gabarit)} > \"$D/s.tpl\"; " "cat > \"$D/mdp\"; " f"step certificate sign --template \"$D/s.tpl\" --not-after {SOUS_AC_VALIDITE_H}h " "--password-file \"$D/mdp\" \"$D/s.csr\" /etc/step-ca/certs/root_ca.crt /etc/step-ca/secrets/root_ca_key") crt = _ssh(c.pki, "sudo -n bash -c " + shlex.quote(script), entree=c.v["vault_step_ca_password"]) if "BEGIN CERTIFICATE" not in crt: raise SystemExit("la racine n'a rien rendu de signe") # 3. VERIFIER AVANT D'IMPORTER : chaine vers la racine du site, et cle correspondante. verif = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( f"cd {dossier} && cat > sous-ac.crt && printf %s {shlex.quote(c.racine_pem)} > racine.crt" " && openssl verify -CAfile racine.crt sous-ac.crt" " && [ \"$(openssl x509 -in sous-ac.crt -noout -pubkey)\" = \"$(openssl pkey -in sous-ac.key -pubout)\" ]" " && echo CLE-OK"), entree=crt) if "sous-ac.crt: OK" not in verif or "CLE-OK" not in verif: raise SystemExit(f"le certificat signe ne se verifie pas : {verif.strip()[:200]}") print(" autorite signee par la racine, chaine et cle verifiees sur la frontiere") # 4. IMPORT PAR L'API LOCALE de la boite : la cle est lue sur place par curl. conf = f'user = "{c.v["vault_opnsense_api_key"]}:{c.v["vault_opnsense_api_secret"]}"\n' rep = _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote( f"cd {dossier} && curl -sk -K - https://127.0.0.1/api/trust/ca/add" " --data-urlencode 'ca[action]=existing'" f" --data-urlencode {shlex.quote('ca[descr]=' + c.desc_sous_ac)}" " --data-urlencode 'ca[crt_payload]@sous-ac.crt'" " --data-urlencode 'ca[prv_payload]@sous-ac.key'"), entree=conf) if '"saved"' not in rep: raise SystemExit(f"import de l'autorite refuse : {rep.strip()[:300]}") print(" autorite importee dans le magasin d'OPNsense (cle generee et restee sur la boite)") finally: _ssh(c.frontiere, f"sudo -n rm -P -rf {shlex.quote(dossier)}") def poser_certificat(c: Contexte, descr: str, sous_ac: dict, type_cert: str) -> None: corps = {"cert": { "action": "internal", "descr": descr, "caref": sous_ac["refid"], "key_type": "prime256v1", "digest": "sha256", "lifetime": str(CERT_VALIDITE_J), "cert_type": type_cert, "private_key_location": "firewall", "commonname": c.fqdn, "organization": "Set-OPS", "country": "CA", "altnames_dns": c.fqdn, "altnames_ip": "\n".join(c.f["adresses"]), }} r = c.api("/api/trust/cert/add", corps) if r.get("result") != "saved": raise SystemExit(f"emission de « {descr} » refusee : {r}") print(f" emis : {descr} ({type_cert})") def racine_dans_le_systeme(c: Contexte) -> bool: """La racine est-elle dans le magasin du SYSTEME (FreeBSD), pas seulement dans celui d'OPNsense ? syslog-ng verifie son destinataire contre `/etc/ssl/certs`. OPNsense y recopie ses autorites racines au demarrage ou au rechargement global — pas a l'import. Mesure du 2026-10-12 : racine importee, absente de `/etc/ssl/certs`. """ # LE SYSTEME LUI-MEME JUGE, pas une recherche de texte : le nom de l'autorite est dans le # base64, invisible a `grep` — premiere ecriture de cette fonction, fausse pour ca. sortie = _ssh(c.frontiere, "openssl verify -CApath /etc/ssl/certs /dev/stdin 2>&1; true", entree=c.racine_pem) return sortie.strip().endswith(": OK") def recopier_racine(c: Contexte) -> None: _ssh(c.frontiere, "sudo -n /usr/local/sbin/configctl system trust configure") if not racine_dans_le_systeme(c): raise SystemExit("la racine n'entre pas dans le magasin du systeme apres `trust configure`.") print(" racine recopiee dans le magasin du systeme (configctl system trust configure)") def fichier_autorite(c: Contexte) -> Path: """Le certificat PUBLIC de l'autorite de la frontiere, a cote de `ac-racine-site.crt`. L'inventaire du site le lit pour dire au recepteur syslog QUI peut lui ecrire : les certificats emis par cette autorite, et eux seuls. """ return devis_mod.depot_hebergeur() / f"ac-frontiere-{c.nom}.crt" def publier_autorite(c: Contexte, sous_ac: dict) -> bool: f = fichier_autorite(c) pem = sous_ac["crt"].strip() + "\n" if f.is_file() and f.read_text(encoding="utf-8") == pem: return False f.write_text(pem, encoding="utf-8") print(f" publie : {f} (a committer dans le depot du site)") return True # --- USAGE : LE SYSLOG VERS LE LOKI DU SITE (F2) ------------------------------------- def _loki_syslog(c: Contexte) -> tuple[str, str]: inv = si.inventaire() for h, v in inv["_meta"]["hostvars"].items(): ecoute = str(v.get("client_journal_syslog_ecoute") or "") if ecoute and v.get("client_journal_syslog_tls"): ip, port = ecoute.rsplit(":", 1) return ip, port return "", "" def destinations_syslog(c: Contexte) -> list[dict]: ip, port = _loki_syslog(c) r = c.api("/api/syslog/settings/searchDestinations", {"rowCount": 100, "current": 1}) return [d for d in (r.get("rows") or []) if d.get("hostname") == ip and str(d.get("port")) == port] def poignee_tls_vers_loki(c: Contexte, services: dict) -> str: """Une VRAIE poignee de main, depuis la frontiere, avec SON certificat de services. LA GARDE AVANT DE BASCULER (2026-10-12). Une destination syslog morte a deja eteint TOUTE la journalisation de la frontiere pendant dix jours. On ne passe en `tls4` que si le recepteur accepte ce certificat et que la frontiere verifie le sien contre la racine du site — la meme verification que syslog-ng fera. Rend la conclusion d'openssl. """ ip, port = _loki_syslog(c) script = ( "set -e; umask 077; D=$(mktemp -d /root/setops-essai.XXXXXX); trap 'rm -P -rf \"$D\"' EXIT; " "/usr/local/bin/python3 - \"$D\" <<'PY'\n" "import base64,sys,xml.etree.ElementTree as E\n" "c=E.parse('/conf/config.xml').getroot()\n" "for n in c.iter('cert'):\n" f" if n.findtext('refid')=={services['refid']!r}:\n" " open(sys.argv[1]+'/c.pem','wb').write(base64.b64decode(n.findtext('crt')))\n" " open(sys.argv[1]+'/k.pem','wb').write(base64.b64decode(n.findtext('prv')))\n" "PY\n" f"echo | openssl s_client -connect {ip}:{port} -cert \"$D/c.pem\" -key \"$D/k.pem\" " f"-CApath /etc/ssl/certs -verify_ip {ip} -verify_return_error -brief 2>&1 | head -20") return _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script)) def basculer_syslog(c: Contexte, services: dict) -> None: dest = destinations_syslog(c) if not dest: print(" syslog : aucune destination vers le Loki du site — rien a basculer") return a_faire = [d for d in dest if d.get("transport") != "tls4" or d.get("certificate") != services["refid"]] if not a_faire: return essai = poignee_tls_vers_loki(c, services) if "Verification: OK" not in essai or "Protocol version: TLS" not in essai: raise SystemExit("syslog : le recepteur n'accepte pas encore la poignee TLS — destination laissee " f"telle quelle (deployer d'abord client_journal au site).\n{essai.strip()[:300]}") for d in a_faire: r = c.api(f"/api/syslog/settings/setDestination/{d['uuid']}", {"destination": {"transport": "tls4", "certificate": services["refid"]}}) if r.get("result") != "saved": raise SystemExit(f"syslog : destination refusee : {r}") r = c.api("/api/syslog/service/reconfigure", {}) if r.get("status") not in ("ok", "OK"): raise SystemExit(f"syslog : rechargement refuse : {r}") print(f" syslog : {len(a_faire)} destination(s) en tls4, certificat de services (poignee verifiee)") # --- USAGE : L'EXPORTATEUR DE METRIQUES EN TLS (F4) ----------------------------------- EXPO_DIR = "/usr/local/etc/node_exporter" # OPNSENSE REECRIT `/etc/rc.conf.d/node_exporter` a chaque sauvegarde du greffon, et son # greffon n'offre aucune option TLS. FreeBSD lit ENSUITE `/usr/local/etc/rc.conf.d/` # (rc.subr : `for _d in /etc ${local_startup}`), qu'OPNsense ne touche pas : on y ajoute # l'option aux arguments du greffon, au lieu de les remplacer. Mesure du 2026-10-12. EXPO_RC = "/usr/local/etc/rc.conf.d/node_exporter" EXPO_RC_CONTENU = ( "# Gere par Set-OPS (scripts/frontiere_pki.py). Lu APRES /etc/rc.conf.d/node_exporter,\n" "# qu'OPNsense reecrit : ajoute le TLS aux arguments du greffon sans les remplacer.\n" f'node_exporter_args="${{node_exporter_args}} --web.config.file={EXPO_DIR}/web-config.yml"\n') EXPO_WEB = ( "# Gere par Set-OPS (scripts/frontiere_pki.py). TLS de l'exportateur (exporter-toolkit).\n" "tls_server_config:\n" f" cert_file: {EXPO_DIR}/tls/exportateur.crt\n" f" key_file: {EXPO_DIR}/tls/exportateur.key\n") def _ecoute_exportateur(c: Contexte) -> str: sortie = _ssh(c.frontiere, "grep -h '^node_exporter_listen_address' /etc/rc.conf.d/node_exporter; true") return sortie.split("=", 1)[-1].strip().strip('"') if "=" in sortie else "" def poignee_exportateur(c: Contexte) -> str: """Le point de vue de Prometheus : HTTPS sur l'adresse d'ecoute, verifie par la racine.""" ecoute = _ecoute_exportateur(c) if not ecoute: return "aucune adresse d'ecoute" ip = ecoute.rsplit(":", 1)[0] return _ssh(c.frontiere, f"echo | openssl s_client -connect {shlex.quote(ecoute)} -CApath /etc/ssl/certs " f"-verify_ip {shlex.quote(ip)} -verify_return_error -brief 2>&1 | head -20; true") def exportateur_en_tls(c: Contexte) -> bool: return "Verification: OK" in poignee_exportateur(c) def poser_exportateur(c: Contexte, expo: dict, sous_ac: dict) -> None: """Ecrit certificat (+ autorite de la frontiere) et cle de l'exportateur, depuis config.xml, SUR la boite. La cle de l'exportateur — et elle seule — est lisible par `nobody`.""" script = ( "set -e; umask 022; " f"mkdir -p {EXPO_DIR}/tls /usr/local/etc/rc.conf.d; " "/usr/local/bin/python3 - <<'PY'\n" "import base64,os,xml.etree.ElementTree as E\n" "c=E.parse('/conf/config.xml').getroot()\n" "def trouve(tag,refid):\n" " return next(n for n in c.iter(tag) if n.findtext('refid')==refid)\n" f"f=trouve('cert',{expo['refid']!r}); a=trouve('ca',{sous_ac['refid']!r})\n" "crt=base64.b64decode(f.findtext('crt'))+base64.b64decode(a.findtext('crt'))\n" f"d={EXPO_DIR + '/tls'!r}\n" "open(d+'/exportateur.crt','wb').write(crt); os.chmod(d+'/exportateur.crt',0o644)\n" "k=d+'/exportateur.key'; fd=os.open(k,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o640)\n" "os.write(fd,base64.b64decode(f.findtext('prv'))); os.close(fd); os.chown(k,0,65534); os.chmod(k,0o640)\n" "PY\n" f"printf %s {shlex.quote(EXPO_WEB)} > {EXPO_DIR}/web-config.yml; " f"printf %s {shlex.quote(EXPO_RC_CONTENU)} > {EXPO_RC}; " "/usr/local/etc/rc.d/node_exporter restart >/dev/null 2>&1; sleep 2") _ssh(c.frontiere, "sudo -n sh -c " + shlex.quote(script)) essai = poignee_exportateur(c) if "Verification: OK" not in essai: raise SystemExit(f"exportateur : la poignee HTTPS ne se verifie pas apres la pose.\n{essai.strip()[:300]}") print(f" exportateur : HTTPS sur {_ecoute_exportateur(c)}, certificat verifie par la racine du site") # --- USAGE : L'INTERFACE WEB (F3) — CLASSE R3 ------------------------------------------ # # Aucune API : la page d'administration ecrit `system/webgui/ssl-certref` puis redemarre # lighttpd, qui porte AUSSI l'API. Un certificat que lighttpd refuserait couperait l'un et # l'autre. Le chemin de secours est SSH (sshd ne depend pas de lighttpd) : # sudo php -r '...ssl-certref = ...' puis configctl webgui restart # `write_config` garde la version precedente dans /conf/backup. Geste separe d'`appliquer`, # avec sa propre confirmation (AGENTS.md, R3). def certref_interface(c: Contexte) -> str: return _ssh(c.frontiere, "sudo -n /usr/local/bin/python3 -c \"import xml.etree.ElementTree as E;" "print(E.parse('/conf/config.xml').getroot().findtext('system/webgui/ssl-certref') or '')\"").strip() def poser_certref_interface(c: Contexte, refid: str) -> None: php = ("require_once('config.inc'); require_once('util.inc'); global $config; " f"$config['system']['webgui']['ssl-certref'] = '{refid}'; " "write_config('Set-OPS : certificat de l interface web');") _ssh(c.frontiere, "sudo -n /usr/local/bin/php -r " + shlex.quote(php) + " && sudo -n /usr/local/sbin/configctl webgui restart >/dev/null; sleep 4") def interface_verifiee(c: Contexte) -> str: """Depuis le POSTE : la poignee HTTPS de l'interface, verifiee contre la racine du site.""" racine = devis_mod.depot_hebergeur() / "ac-racine-site.crt" r = subprocess.run(["openssl", "s_client", "-connect", f"{c.f['admin']}:443", "-CAfile", str(racine), "-verify_ip", c.f["admin"], "-verify_return_error", "-brief"], input="", capture_output=True, text=True, timeout=20) return r.stdout + r.stderr def basculer_interface(c: Contexte) -> None: e = etat(c) if not e["services"]: raise SystemExit("aucun certificat de services : lancer d'abord `appliquer`.") avant = certref_interface(c) if avant == e["services"]["refid"]: print(" interface : deja servie par le certificat de services") return print(f" interface : ssl-certref {avant} -> {e['services']['refid']} (retour arriere : {avant})") poser_certref_interface(c, e["services"]["refid"]) essai = interface_verifiee(c) if "Verification: OK" not in essai: print(" interface : la verification ECHOUE — retour arriere immediat") poser_certref_interface(c, avant) raise SystemExit(f"interface remise sur {avant}.\n{essai.strip()[:300]}") print(f" interface : HTTPS sur {c.f['admin']}:443 verifie contre la racine du site") def references(c: Contexte, refid: str) -> int: """Combien de fois `refid` apparait dans config.xml HORS de sa propre definition.""" n = _ssh(c.frontiere, f"sudo -n grep -c {shlex.quote(refid)} /conf/config.xml; true").strip() return max(int(n or 0) - 1, 0) def regenerer(c: Contexte) -> None: """Retire l'autorite de la frontiere et ses certificats, pour les refaire a neuf. QUAND : une cle a pu etre exposee. Premier usage, le 2026-10-12 : la liste par l'API avait rapatrie les cles sur le poste avant que `lister` ne lise sur la frontiere. REFUSE si l'un d'eux est employe ailleurs (interface, syslog...) : le retirer couperait ce service — c'est alors un geste a part, avec son retour arriere. """ e = etat(c) certs = [e[k] for k in ("services", "exportateur") if e[k]] employes = [r["descr"] for r in certs if references(c, r["refid"]) > 0] if employes: raise SystemExit(f"refus : employe(s) ailleurs dans config.xml — {', '.join(employes)}") def retirer(quoi: str, r: dict) -> None: rep = c.api(f"/api/trust/{quoi}/del/{r['uuid']}", {}) if rep.get("result") != "deleted": raise SystemExit(f"retrait de « {r['descr']} » refuse : {rep}") print(f" retire : {r['descr']}") for r in certs: retirer("cert", r) # L'AUTORITE ENSUITE, et seulement si plus rien ne la nomme : ses propres certificats # viennent de partir, toute autre reference serait un usage qu'on ne connait pas. if e["sous_ac"]: if references(c, e["sous_ac"]["refid"]) > 0: raise SystemExit("refus : l'autorite est encore nommee dans config.xml apres le retrait de ses certificats") retirer("ca", e["sous_ac"]) def main() -> int: action = sys.argv[1] if len(sys.argv) > 1 else "plan" if action not in ("plan", "appliquer", "regenerer", "interface"): raise SystemExit(__doc__) c = Contexte() if action == "interface": print(f"Interface de {c.nom} : ssl-certref = {certref_interface(c)}") if os.environ.get("CONFIRMER") != "true": print("Geste R3 (l'interface ET l'API passent par lighttpd) : CONFIRMER=true pour basculer.") return 0 basculer_interface(c) return 0 if action == "regenerer": if os.environ.get("CONFIRMER") != "true": raise SystemExit("regenerer retire l'autorite de la frontiere : CONFIRMER=true.") regenerer(c) action = "appliquer" e = etat(c) if e["racine"] and not racine_dans_le_systeme(c): e["a_faire"].append("racine_systeme") if e["sous_ac"] and (not fichier_autorite(c).is_file() or fichier_autorite(c).read_text(encoding="utf-8").strip() != e["sous_ac"]["crt"].strip()): e["a_faire"].append("publier") afficher(c, e) if "racine_systeme" in e["a_faire"]: print(" A FAIRE racine recopiee dans le magasin du systeme (syslog-ng en depend)") if "publier" in e["a_faire"]: print(f" A FAIRE publier l'autorite dans {fichier_autorite(c).name} (le recepteur syslog la lit)") if e["exportateur"]: ok = exportateur_en_tls(c) print(f" {'ok' if ok else 'A FAIRE':9} exportateur en HTTPS ({_ecoute_exportateur(c)}), verifie par la racine") if not ok: e["a_faire"].append("exportateur_tls") if e["services"]: for d in destinations_syslog(c): ok = d.get("transport") == "tls4" and d.get("certificate") == e["services"]["refid"] print(f" {'ok' if ok else 'A FAIRE':9} syslog vers {d.get('hostname')}:{d.get('port')} " f"en {d.get('transport')} — attendu tls4 + certificat de services") if not ok: e["a_faire"].append("syslog") if not e["a_faire"]: print("Rien a faire.") if action == "plan" or not e["a_faire"]: return 0 # L'ETAT EST RELU PLUS BAS (apres chaque pose) : ce qui ne se relit pas — la bascule du # syslog — se retient ici. Premiere ecriture : relu, oublie, « Fait. » sans rien faire. syslog_a_faire = "syslog" in e["a_faire"] expo_a_faire = "exportateur_tls" in e["a_faire"] or "exportateur" in e["a_faire"] if os.environ.get("CONFIRMER") != "true": print("\nCONFIRMER=true pour poser ce qui manque.") return 0 print() if "racine" in e["a_faire"]: poser_racine(c) if "sous_ac" in e["a_faire"]: poser_sous_ac(c) if not racine_dans_le_systeme(c): recopier_racine(c) e = etat(c) if not e["sous_ac"] or e["sous_ac"].get("caref") != (e["racine"] or {}).get("refid"): raise SystemExit("l'autorite n'est pas rattachee a la racine dans le magasin — arret.") if "services" in e["a_faire"]: poser_certificat(c, c.desc_services, e["sous_ac"], "combined_server_client") if "exportateur" in e["a_faire"]: poser_certificat(c, c.desc_expo, e["sous_ac"], "server_cert") publier_autorite(c, etat(c)["sous_ac"]) if syslog_a_faire: basculer_syslog(c, etat(c)["services"]) if expo_a_faire: e2 = etat(c) poser_exportateur(c, e2["exportateur"], e2["sous_ac"]) print() afficher(c, etat(c)) print("Fait.") return 0 if __name__ == "__main__": raise SystemExit(main())