593 lines
29 KiB
Python
593 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())
|