Set-OPS-Public/scripts/frontiere_pki.py

593 lines
29 KiB
Python
Raw Normal View History

#!/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/<nom>`
# (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 = <ancien>...' 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())