Le site de TechnoLibre n'a qu'une adresse DHCP. Trois adresses fixes sont louees sur un VPS (le relais), qui repond a l'ARP pour elles et les route en /32 dans un tunnel WireGuard que la frontiere initie. Aucune traduction : l'adresse du client arrive intacte. - underlay.py : `tunnel: wireguard` dispense de VLAN ; role d'hote `relais`. - devis_opnsense.py : `opnsense_face_publique`. Les entrees publiques s'y posent avec reply-to ; les sorties des locataires du pool passent par le relais ; une redirection n'y vise plus `wanip`. Le devis de Chezlepro est identique au caractere pres. - appliquer_opnsense.py : replyto et gateway transmis et dans l'identite (seulement s'ils existent) ; le NAT sortant en mode automatic arrete l'application au lieu de dupliquer. - role serveur_relais, inventorie depuis la carte comme un hyperviseur ; cle privee dans la voute du site. Deploye, idempotent. - site_inventaire.py : les hyperviseurs ne sont plus filtres sur 192.168.11. ; site-appliquer charge la voute designee par SETOPS_UNDERLAY ; voute.py n'exige plus d'un locataire un secret du site. - prouver.py : P67 lit le plan par underlay.plan_site() ; P53 couvre la face publique. - Tests : test_underlay_tunnel, test_face_publique_relais, test_mode_nat_sortant. 95 preuves (make verifier), make test passe. CHANGELOG 144 et 145. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
4.7 KiB
Python
92 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Une face publique par relais : chaque entree y repart par ou elle est arrivee.
|
|
|
|
POURQUOI (2026-10-13). Le site de TechnoLibre recoit ses adresses fixes par un tunnel vers
|
|
un VPS (le relais), sur sa propre interface. Trois pings depuis l'Internet sont arrives sur
|
|
`wg0` de la frontiere et n'ont recu AUCUNE reponse : OPNsense ne pose `reply-to` que sur une
|
|
interface dont la passerelle amont est declaree, et la reponse repartait par la route par
|
|
defaut — le WAN. Une seule regle oubliee suffit a publier un service qui ne repond jamais.
|
|
|
|
Sur le VRAI devis (sans site visible, le test se saute) :
|
|
- si une face publique est declaree (`if_publique`) :
|
|
* toute regle ENTRANTE de cette interface porte `replyto` ;
|
|
* toute sortie vers l'Internet d'un locataire au pool porte la passerelle du relais,
|
|
et sa traduction sortante est sur la face publique ;
|
|
* aucune regle `reject` n'y repond (elle est l'Internet, comme le WAN) ;
|
|
* le WAN ne porte plus aucune entree `pass` (tout ce qui est publie a demenage) ;
|
|
* aucune redirection n'y vise `wanip` (l'adresse du tunnel) ;
|
|
- sinon (temoin : le site de Chezlepro), AUCUNE regle ne porte `replyto` ni passerelle —
|
|
la nouveaute ne deborde pas sur un site qui ne l'a pas declaree.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def main() -> int:
|
|
r = subprocess.run([sys.executable, "scripts/devis_opnsense.py", "--json"],
|
|
capture_output=True, text=True, cwd=RACINE)
|
|
if r.returncode != 0 or not r.stdout.strip().startswith("{"):
|
|
print("SAUTE le devis de la frontiere ne se genere pas ici (site invisible)")
|
|
return 0
|
|
devis = json.loads(r.stdout)
|
|
regles = devis.get("regles") or []
|
|
pub, gw, wan = devis.get("if_publique"), devis.get("passerelle_publique"), devis.get("if_wan")
|
|
echecs: list[str] = []
|
|
if not pub:
|
|
portant = [x for x in regles if x.get("replyto") or x.get("passerelle")]
|
|
if portant:
|
|
echecs.append(f"{len(portant)} regle(s) portent reply-to/passerelle sans face "
|
|
f"publique declaree")
|
|
for e in echecs:
|
|
print("ECHEC " + e)
|
|
if not echecs:
|
|
print("OK aucune face publique declaree : aucune regle ne porte reply-to ni "
|
|
"passerelle (temoin)")
|
|
return 1 if echecs else 0
|
|
|
|
entrees = [x for x in regles if x.get("interface") == pub and x.get("sens") == "in"
|
|
and str(x.get("action") or "pass") == "pass"]
|
|
bloquees = [x for x in regles if x.get("interface") == pub and x.get("action") == "block"
|
|
and x.get("replyto")]
|
|
for x in bloquees:
|
|
echecs.append(f"{pub} : un blocage ({x.get('role')}) porte reply-to")
|
|
sans = [x for x in entrees if x.get("replyto") != gw]
|
|
if not entrees:
|
|
echecs.append(f"aucune regle entrante sur la face publique {pub}")
|
|
for x in sans:
|
|
echecs.append(f"{pub} : {x.get('role')} -> {x.get('destination')} sans reply-to {gw}")
|
|
relayes = {str(n.get("tenant")) for n in devis.get("nat") or [] if n.get("interface") == pub}
|
|
sorties = [x for x in regles if x.get("sens") == "out" and x.get("tenant") in relayes
|
|
and str(x.get("destination") or "").startswith("!")]
|
|
for x in sorties:
|
|
if x.get("passerelle") != gw:
|
|
echecs.append(f"sortie de {x.get('tenant')} ({x.get('role')}) sans passerelle {gw}")
|
|
# Sur un relais, `wanip` est l'adresse du TUNNEL : une redirection qui la vise ne
|
|
# recoit jamais rien (trouve le 2026-10-13, au premier chargement sur requin).
|
|
for d in devis.get("redirections") or []:
|
|
if d.get("interface") == pub and str(d.get("destination") or "wanip") == "wanip":
|
|
echecs.append(f"redirection {d.get('role')} {d.get('protocole')}/{d.get('port_public')} "
|
|
f"vise `wanip` sur {pub} : l'adresse du tunnel, que l'Internet ne frappe pas")
|
|
if any(x.get("action") == "reject" and x.get("interface") == pub for x in regles):
|
|
echecs.append(f"un `reject` sur {pub} : la face publique repondrait aux balayages")
|
|
restes = [x for x in regles if x.get("interface") == wan and x.get("sens") == "in"
|
|
and str(x.get("action") or "pass") == "pass"]
|
|
for x in restes:
|
|
echecs.append(f"entree encore sur le WAN : {x.get('role')} -> {x.get('destination')}")
|
|
for e in echecs:
|
|
print("ECHEC " + e)
|
|
if echecs:
|
|
return 1
|
|
print(f"OK face publique {pub} : {len(entrees)} entree(s), toutes reply-to {gw} ; "
|
|
f"{len(sorties)} sortie(s) de {len(relayes)} locataire(s) par {gw} ; WAN sans entree.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|