Set-OPS-Public/scripts/tests/test_face_publique_relais.py
Daniel Allaire f9e45b1676 relais : la face publique d'un site sans adresse fixe, et le site de reprise de TechnoLibre
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>
2026-10-13 21:20:04 -04:00

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())