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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-14 11:36:19 -04:00
parent d152e32328
commit e7d704fdcc
4 changed files with 157 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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 "

View file

@ -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