From e7d704fdcce545afcc2d5c432dc7f535fe97d743 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Wed, 14 Oct 2026 11:36:19 -0400 Subject: [PATCH] inter-sites : l'administration du site pair, par le lien entre les sites Une cle administration sur un lien de opnsense_liens_sites : - entrante : les reseaux d'administration du pair (lus dans son plan) entrent par l'interface du lien, en SSH et HTTPS, vers la patte de gestion de la frontiere seulement ; - sortante : la gestion et le tunnel des admins sortent vers le rebond du pair, et rien d'autre. Routes derivees, gardees avec celles du lien. vpn_admin.py route le rebond de chaque pair. frontiere-plan ne declare plus absentes du noyau les routes d'hote, que FreeBSD affiche sans masque. Sans la cle, le devis des deux sites est identique a l'ancien. Pas encore applique. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 30 ++++++++++++ scripts/appliquer_opnsense.py | 19 +++++++- scripts/devis_opnsense.py | 89 ++++++++++++++++++++++++++++++++++- scripts/vpn_admin.py | 23 +++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2676e9f..8a7b6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 2026-10-14 (152) — L'administration du site de TechnoLibre, par le lien entre les sites + +**95 preuves.** `make verifier` passe (site de Chezlepro), `devis_opnsense.py --verifier` est +CONFORME aux deux sites. Témoin : sans la clé `administration`, le devis des deux sites est +identique au caractère près à celui de l'ancien code. `make frontiere-plan` n'annonce que des +créations : 6 à `requin`, 8 à `bifrost-1`, aucun retrait. **Pas encore appliqué** aux frontières. + +**Décision de Daniel (2026-10-14)** : administrer le site de TechnoLibre depuis le site de +Chezlepro par le lien existant (`requin` opt10 ↔ `bifrost-1` opt11), sans VPN d'administration +de plus. Elle remplace l'administration par le relais (2026-09-29), dont le chemin n'existait pas : +SSH vers `requin` par le relais expire à l'échange de bannière. + +**Une clé `administration` sur un lien de `opnsense_liens_sites`.** +- `entrante` (site administré) : les réseaux d'administration du pair (sa gestion et son tunnel + des admins, lus dans SON plan) entrent par l'interface du lien, en SSH et HTTPS, vers la patte + de gestion de cette frontière seulement ; routes de retour vers le prochain saut. +- `sortante` (site qui administre) : la gestion et le tunnel des admins sortent vers la patte de + gestion du pair (le rebond), en SSH et HTTPS, et rien d'autre ; route d'hôte vers le prochain + saut. Les règles du tunnel portent sur le groupe `wireguard`. +- La garde des routes du lien admet ces alias (`SETOPS_ADMPAIR_*`, `SETOPS_REBOND_*`). +- Les pairs WireGuard admettaient déjà ces adresses : il ne manquait que le filtre et les routes. +- `vpn_admin.py` ajoute le rebond de chaque pair aux réseaux que le tunnel `admins` route. + +**Une fausse alerte corrigée.** `frontiere-plan` déclarait « absentes du noyau » les routes +d'hôte (`10.17.19.11/32`…) : FreeBSD les affiche sans masque. La lecture compare les deux +formes ; une route vraiment absente reste signalée (témoin sur une table fictive). + +**Reste** : appliquer `requin` puis `bifrost-1` ; la route `10.31.0.1/32` au poste ; l'essai de +bout en bout (SSH vers `requin`, `maurice` par le rebond, l'API en HTTPS). + ## 2026-10-14 (151) — Le second serveur de noms, désigné : `dns1.technolibre.ca` **95 preuves.** `make verifier` passe (site de Chezlepro), `make test` passe. Témoin de bout en diff --git a/scripts/appliquer_opnsense.py b/scripts/appliquer_opnsense.py index a414c46..7dd0074 100644 --- a/scripts/appliquer_opnsense.py +++ b/scripts/appliquer_opnsense.py @@ -23,6 +23,7 @@ Usage : from __future__ import annotations import base64 +import ipaddress import json import os import ssl @@ -355,8 +356,22 @@ def routes_du_noyau(api: Frontiere) -> set[str] | None: lignes = rep if isinstance(rep, list) else (rep.get("rows") or rep.get("items")) if not lignes: continue - return {str(r.get("destination") or "").strip() for r in lignes - if isinstance(r, dict)} + # UNE ROUTE D'HOTE S'ECRIT SANS MASQUE (mesure du 2026-10-14). FreeBSD affiche + # `10.17.19.11` (drapeau `H`), le devis demande `10.17.19.11/32` : les routes /32 du lien + # entre les sites etaient dites « absentes du noyau » alors qu'il les portait. On garde + # la forme lue ET sa forme masquee. + out: set[str] = set() + for r in lignes: + if not isinstance(r, dict): + continue + d = str(r.get("destination") or "").strip() + out.add(d) + try: + a = ipaddress.ip_address(d) + out.add(f"{d}/{a.max_prefixlen}") + except ValueError: + pass + return out return None diff --git a/scripts/devis_opnsense.py b/scripts/devis_opnsense.py index 920227c..a824c89 100644 --- a/scripts/devis_opnsense.py +++ b/scripts/devis_opnsense.py @@ -1587,6 +1587,87 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict: "description": f"Primaire DNS d'un locataire de {_pair}, par le " f"tunnel (prime sur la route de reprise)"}) + # L'ADMINISTRATION PAR LE LIEN (2026-10-14, decision de Daniel). Le site de TechnoLibre + # n'a pas de tunnel d'administration a lui : depuis le site de Chezlepro, on entre par + # le lien, jusqu'a la FRONTIERE du pair seulement — SSH (le rebond, d'ou tout le reste + # comme sur place) et HTTPS (son API, pour le devis). Declare des deux cotes : + # - `entrante` : les reseaux d'administration du pair (gestion + tunnel des admins, + # lus dans SON plan) entrent par le tunnel jusqu'a notre patte de gestion ; + # - `sortante` : les notres sortent vers la patte de gestion du pair. + # Le chiffrement admettait deja ces adresses (pairs WireGuard, releve du 2026-10-14) ; + # manquaient le filtre et les routes, les routes WireGuard etant desactivees. + def _gestion_frontiere(_carte: dict) -> str: + return next((str(h["ip"]) for h in underlay_mod.hotes(_carte) + if str(h.get("role")) == "frontiere" + and str(h.get("reseau")) == "management" and h.get("ip")), "") + + def _reseaux_admin(_carte: dict) -> list[str]: + _vpn_r = str(((underlay_mod.lire_plan_site("10-intrants.yml") or {}) + .get("acces_admin_vpn") or {}).get("reseau") or "") + return sorted({str(r["sous_reseau"]) for r in underlay_mod.reseaux(_carte) + if r.get("nom") == "management" and r.get("sous_reseau")} + | ({_vpn_r} - {""})) + + _adm = str(_lien.get("administration") or "") + if _adm == "entrante": + with underlay_mod.sur_le_site(_pair): + _adm_pair = _reseaux_admin(underlay_mod.charger() or {}) + _ma_gestion = _gestion_frontiere(_u) + if _adm_pair and _ma_gestion: + alias[f"SETOPS_ADMPAIR_{_court}"] = { + "type": "network", "contenu": _adm_pair, + "description": f"Administration de {_pair} (gestion et tunnel des admins), " + f"par le lien entre les sites"} + alias["SETOPS_FRONTIERE_GESTION"] = { + "type": "host", "contenu": [_ma_gestion], + "description": "Patte de gestion de la frontiere — le rebond du site"} + for _port, _chiffre, _quoi in (("22", "ssh", "SSH vers le rebond du site"), + ("443", "tls", "API et console de la frontiere")): + regles.append({ + "sens": "in", "interface": _if_lien, "protocole": "tcp", + "source": f"SETOPS_ADMPAIR_{_court}", + "destination": "SETOPS_FRONTIERE_GESTION", + "ports": [_port], "chiffrement": _chiffre, "role": "administration", + "tenant": "INTERSITES", + "raison": f"{_quoi}, depuis l'administration de {_pair}, par le tunnel. " + f"Jusqu'a la frontiere seulement : la suite passe par le rebond.", + }) + for _c in _adm_pair: + routes.append({"reseau": _c, "prochain_saut": _saut, "tenant": "INTERSITES", + "description": f"Administration de {_pair} : le retour par " + f"le tunnel"}) + elif _adm == "sortante": + with underlay_mod.sur_le_site(_pair): + _rebond = _gestion_frontiere(underlay_mod.charger() or {}) + _gestion_ici = [c for c in _reseaux_admin(_u) if not _par_le_vpn(c)] + _vpn_ici = [c for c in _reseaux_admin(_u) if _par_le_vpn(c)] + if _rebond: + alias[f"SETOPS_REBOND_{_court}"] = { + "type": "host", "contenu": [_rebond], + "description": f"Patte de gestion de la frontiere de {_pair} — son rebond"} + for _itf, _src in ((if_gestion, _gestion_ici), (if_vpn, _vpn_ici)): + if not _src: + continue + _a_src = f"SETOPS_ADMSORT_{'VPN' if _itf == if_vpn else 'GESTION'}" + alias[_a_src] = {"type": "network", "contenu": _src, + "description": "Administration de ce site, vers le rebond " + "d'un site pair"} + for _port, _chiffre, _quoi in (("22", "ssh", "SSH vers le rebond"), + ("443", "tls", "API et console de la frontiere")): + regles.append({ + "sens": "in", "interface": _itf, "protocole": "tcp", + "source": _a_src, "destination": f"SETOPS_REBOND_{_court}", + "ports": [_port], "chiffrement": _chiffre, "role": "administration", + "tenant": "INTERSITES", + "raison": f"{_quoi} de {_pair}, par le lien entre les sites.", + }) + routes.append({"reseau": f"{_rebond}/32", "prochain_saut": _saut, + "tenant": "INTERSITES", + "description": f"Rebond de {_pair}, par le tunnel"}) + elif _adm: + print(f"note : lien vers {_pair} : `administration: {_adm}` inconnu (entrante, " + f"sortante) — aucune regle emise.", file=sys.stderr) + # L'INTERIEUR REFUSE A VOIX HAUTE, LA FRONTIERE AUSSI (2026-10-01). # # Les machines refusaient deja en le disant (nftables : `reject` admin-prohibited ; @@ -2187,11 +2268,15 @@ def verifier(devis: dict) -> tuple[bool, list[str]]: # alias du lien, et cet alias doit servir a une regle — rien de route que le filtre # refuserait. _lien = [r for r in devis.get("routes") or [] if r.get("tenant") == "INTERSITES"] - _alias_lien = {n for n in devis["alias"] if n.startswith("SETOPS_DNSPUB_") + # L'administration par le lien (2026-10-14) route des RESEAUX (ceux du pair) : un contenu + # qui porte deja son masque se compare tel quel. + _alias_lien = {n for n in devis["alias"] if n.startswith(("SETOPS_DNSPUB_", "SETOPS_ADMPAIR_", + "SETOPS_REBOND_")) or n == "SETOPS_DNS_PRIMAIRES_DISTANTS"} _servis = {n for n in _alias_lien for r in devis["regles"] if n in (str(r["source"]).lstrip("!"), str(r["destination"]).lstrip("!"))} - _adresses_lien = {f"{c}/32" for n in _servis for c in devis["alias"][n]["contenu"]} + _adresses_lien = {c if "/" in str(c) else f"{c}/32" + for n in _servis for c in devis["alias"][n]["contenu"]} for r in _lien: if str(r["reseau"]) not in _adresses_lien: erreurs.append(f"{r['reseau']} est route par le lien entre les sites, mais aucune " diff --git a/scripts/vpn_admin.py b/scripts/vpn_admin.py index 61bba9a..f916091 100644 --- a/scripts/vpn_admin.py +++ b/scripts/vpn_admin.py @@ -155,9 +155,32 @@ def reseaux_joignables() -> list[str]: and (str(r.get("nom", "")) in ("management", "grappe-controle", "transit-frontiere") or str(r.get("nom", "")).startswith("site-"))} out |= {str(s) for s in _supernets_locataires()} + out |= set(rebonds_des_pairs()) return sorted(out, key=lambda c: ipaddress.ip_network(c, strict=False)) +def rebonds_des_pairs() -> list[str]: + """Le rebond (patte de gestion de la frontiere) de chaque site pair que nos + administrateurs administrent PAR LE LIEN (`opnsense_liens_sites`, `administration: + sortante`, 2026-10-14) — en /32, comme le devis le route. Rien d'autre du pair. + """ + out: list[str] = [] + actifs = underlay_mod.sites_pairs_actifs() + for lien in devis_mod.intrants_frontiere().get("opnsense_liens_sites") or []: + pair = str(lien.get("site") or "") + if str(lien.get("administration") or "") != "sortante" or pair not in actifs: + continue + try: + with underlay_mod.sur_le_site(pair): + carte = underlay_mod.charger() or {} + except FileNotFoundError: + continue + out += [f"{h['ip']}/32" for h in underlay_mod.hotes(carte) + if str(h.get("role")) == "frontiere" and str(h.get("reseau")) == "management" + and h.get("ip")] + return sorted(set(out)) + + def _supernets_locataires() -> list[str]: """Les supernets des locataires heberges — decouverts, jamais listes.""" import devis_reseau