Metriques et journaux (CHANGELOG 142). Le site pose les quatre interrupteurs TLS que chaque locataire pose dans ses group_vars : node_exporter et Loki servaient en HTTP sur les neuf VM, le certificat etait la, inutilise. test_parite_tls_site.py compare desormais le site aux locataires. Les hyperviseurs poussent leurs journaux en HTTPS verifie, avec la racine du site comme simple fichier. L'exportateur PostgreSQL sert en HTTPS partout, sur l'interrupteur de PostgreSQL lui-meme, que Prometheus lit pour choisir son schema. La frontiere (CHANGELOG 143). Une autorite subordonnee signee par la racine du site, bornee (pathlen 0, noms sous le domaine et adresses du site, contrainte critique), cle nee sur la boite (scripts/frontiere_pki.py). Syslog vers Loki en TLS mutuel (seule la frontiere peut ecrire), exportateur en HTTPS, interface web sur le certificat de services ; les clients d'API verifient la frontiere contre la racine du site. La lecture du magasin passe par config.xml : l'API de recherche renvoie les cles privees. Deploye et mesure au site, chez les deux locataires et sur la frontiere ; 95 preuves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
592 lines
29 KiB
Python
592 lines
29 KiB
Python
#!/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())
|