frontiere : les interfaces internes refusent a voix haute, le WAN reste muet

Un reject final, journalise, sur chaque interface interne (zones du site,
gestion, transit, WireGuard) : un flux mal declare entre deux zones echoue
au lieu d'expirer. P53 affinee, P50 ne compte que les block, test du devis.
La sonde classe ECONNREFUSED en avertissement : le RST de la frontiere est
indiscernable d'un port ferme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-10-01 15:39:46 -04:00
parent a4b6565056
commit 6a8fc53084
6 changed files with 125 additions and 18 deletions

View file

@ -323,6 +323,7 @@ test: ## Lance les tests unitaires (derivation de nomenclature et d'inventaire)
python3 scripts/tests/test_sonde_tcp.py
python3 scripts/tests/test_restauration.py
python3 scripts/tests/test_repertoires_partages.py
python3 scripts/tests/test_frontiere_refus.py
# LA SONDE QUI RAPPORTE CE QUI DISTINGUE (2026-08-28). Trois faux diagnostics en une
# journee, tous dus a l'instrument : `curl` et `bash /dev/tcp` ecrasent « la machine

View file

@ -65,7 +65,7 @@
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 309 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 13 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere : WAN muet, interfa |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 9 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 13 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 13. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |

View file

@ -16,10 +16,12 @@
#
# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre :
# ouvert le flux passe -> OK
# ECONNREFUSED le paquet est PASSE, personne n'ecoute sur ce port -> INFORMATION
# (pas une coupure : un flux declare que rien ne sert — p. ex. une
# interface liee en local derriere la passerelle SSO. Nomme et compte,
# mais pas en jaune : un avertissement permanent apprend a ignorer le jaune)
# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT
# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un
# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les
# `seulement_si`, aucun flux declare ne mene plus a un port sans service :
# ce cas n'est plus jamais normal. Jaune, pas rouge — un service qui
# redemarre ne doit pas reveiller quelqu'un.
# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou
# machine injoignable -> CRITIQUE
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
@ -54,7 +56,7 @@ def tcp(ip, port):
return "delai"
except OSError as e:
if e.errno == errno.ECONNREFUSED:
return "personne"
return "refuse"
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
return "rejete"
return errno.errorcode.get(e.errno, str(e.errno))
@ -75,8 +77,8 @@ sorties = d.get("sorties") or []
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
resultats = list(ex.map(essai, sorties))
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "personne")]
personne = [(f, r) for f, r in resultats if r == "personne"]
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "refuse")]
personne = [(f, r) for f, r in resultats if r == "refuse"]
ouverts = len(resultats) - len(coupes) - len(personne)
# Entrees etablies hors des regles declarees.
@ -135,8 +137,10 @@ if coupes:
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
sys.exit(2)
avert = []
info = (f" ; {len(personne)} flux declare(s) sans service (rien n'ecoute) — {nomme(personne)}"
if personne else "")
info = ""
if personne:
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
f"{nomme(personne)}")
if hors:
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
+ ", ".join(sorted(hors)[:5]))

View file

@ -1371,6 +1371,42 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
"raison": str(s.get("motif") or "bruit de journal, deja refuse par defaut"),
})
# L'INTERIEUR REFUSE A VOIX HAUTE, LA FRONTIERE AUSSI (2026-10-01).
#
# Les machines refusaient deja en le disant (nftables : `reject` admin-prohibited ;
# Proxmox : politique REJECT). La frontiere, elle, jetait en silence sur TOUTES ses
# interfaces — y compris celles qui ne voient que nos propres zones. La sonde
# `connectivite` l'a montre au site : un flux mal declare entre deux zones n'echouait pas,
# il EXPIRAIT, et l'application attendait son propre delai. « Pour eviter des delais
# internes en cas de pepin » (l'exploitant).
#
# Une regle d'interface INTERNE ne voit que du trafic venu de nos zones : repondre ne
# renseigne personne d'exterieur. Le WAN, lui, reste muet (P53, silence `wan`). Place
# APRES les autorisations (1), Wireguard (100) et les silences (900) : OPNsense evalue en
# `quick`. Journalise : c'est le refus par defaut qu'on consignait deja, rendu audible.
#
# OPNsense rend un RST pour TCP : indiscernable, depuis la source, d'un port ou personne
# n'ecoute. La sonde `connectivite` le classe donc en AVERTISSEMENT, pas en information.
_internes = ({if_gestion, if_transit, if_site, if_vpn} | set(if_zones.values())
| {str(r.get("interface")) for r in regles})
for _itf in sorted(i for i in _internes if i and i != if_wan and not i.startswith("<")):
regles.append({
"sens": "in",
"interface": _itf,
"protocole": "any",
"source": "any",
"destination": "any",
"ports": [],
"role": f"refus-audible:{_itf}",
"tenant": "REFUS",
"action": "reject",
"journaliser": True,
"sequence": "950",
"chiffrement": "sans objet",
"raison": ("Refus par defaut AUDIBLE sur une interface interne : un flux non "
"declare echoue tout de suite au lieu d'expirer. Le WAN reste muet."),
})
# DEUX DECLARATIONS JUSTES NE FONT QU'UNE REGLE (2026-09-10).
#
# Un flux interne au site est declare DES DEUX COTES : `client_journal` sort vers

View file

@ -2792,7 +2792,7 @@ def preuve_insemination_sans_secret() -> tuple[bool, str]:
def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]:
"""L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE.
"""L'interne REFUSE A VOIX HAUTE, la bordure reste MUETTE sur l'Internet.
POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses
incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de
@ -2855,15 +2855,21 @@ def preuve_interne_parle_bordure_se_tait() -> tuple[bool, str]:
"`POLITIQUE_VM`, sinon les deux divergeront")
mesures.append("pare-feu est-ouest en REJECT, source unique")
# 3. La bordure se tait — et ce n'est PAS une preference, c'est la condition de
# prudence du reject interne. Les actions du devis OPNsense restent `pass`/`block`.
# 3. La bordure se tait SUR LE WAN — c'est la condition de prudence de tout reject
# interne. Mais ses interfaces INTERNES refusent a voix haute depuis le 2026-10-01 :
# elles ne voient que nos zones, et un flux mal declare entre deux zones expirait au
# lieu d'echouer. Le devis emet donc des `reject`, et JAMAIS sur l'interface WAN.
front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
if '"reject"' in front or "'reject'" in front:
fautes.append("le devis de la frontiere emet un `reject` : la bordure doit rester "
"muette, sans quoi elle repond aux balayages d'Internet")
if '"action": "reject"' not in front:
fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un "
"flux mal declare entre deux zones expirerait au lieu d'echouer")
if "i != if_wan" not in front:
fautes.append("le refus audible de la frontiere n'exclut pas le WAN : la bordure "
"repondrait aux balayages d'Internet")
actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front))
| set(re.findall(r'action.*?or\s+"(\w+)"', front)))
mesures.append("frontiere muette (actions : " + ", ".join(actions or ["pass"]) + ")")
mesures.append("frontiere : WAN muet, interfaces internes audibles (actions : "
+ ", ".join(actions or ["pass"]) + ")")
if fautes:
return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(fautes)
@ -3049,7 +3055,9 @@ def preuve_silences_declares() -> tuple[bool, str]:
f"Sans lui, on ne sait pas ce que la frontiere recevrait.")
regles = devis.get("regles") or []
silences = [r for r in regles if str(r.get("action") or "pass") != "pass"]
# UN SILENCE EST UN `block` (2026-10-01). Les `reject` sont les refus AUDIBLES des
# interfaces internes (P53) : journalises par conception, ils ne sont pas des silences.
silences = [r for r in regles if str(r.get("action") or "pass") == "block"]
if not silences:
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
"redeviendra parlante des qu'un silence sera declare.")

View file

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""La frontiere refuse A VOIX HAUTE a l'interieur, et se tait sur l'Internet.
POURQUOI (2026-10-01). Les machines refusaient deja en le disant (nftables, Proxmox) ; la
frontiere jetait en silence sur TOUTES ses interfaces. Entre deux zones, un flux mal declare
expirait au lieu d'echouer — la sonde `connectivite` l'a mesure au site. Chaque interface
interne recoit desormais un `reject` final. Le WAN, lui, reste muet : y repondre servirait
les balayages d'Internet (P53).
Sur le VRAI devis (il faut voir le site : sans `underlay.yml`, le test se saute) :
- aucun `reject` sur le WAN ;
- chaque interface interne qui porte des regles porte un `reject` ;
- ce `reject` vient APRES toute autre regle de son interface (OPNsense evalue en `quick`).
"""
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
regles = json.loads(r.stdout).get("regles") or []
wan = str((json.loads(r.stdout).get("interfaces") or {}).get("wan") or "wan")
echecs = []
refus = [x for x in regles if x.get("action") == "reject"]
if any(x.get("interface") == wan for x in refus):
echecs.append("un `reject` sur le WAN : la bordure repondrait aux balayages")
interfaces = {x.get("interface") for x in regles} - {wan}
for itf in sorted(i for i in interfaces if i):
siens = [x for x in regles if x.get("interface") == itf]
rj = [x for x in siens if x.get("action") == "reject"]
if not rj:
echecs.append(f"{itf} : aucun refus audible")
continue
haut = max([int(x.get("sequence") or 1) for x in siens if x.get("action") != "reject"] or [0])
if int(rj[0].get("sequence") or 1) <= haut:
echecs.append(f"{itf} : le `reject` ({rj[0].get('sequence')}) ne vient pas apres "
f"toutes les autres regles ({haut})")
for e in echecs:
print("ECHEC " + e)
if echecs:
return 1
print(f"OK {len(refus)} interface(s) interne(s) refusent a voix haute, chacune en dernier ; "
f"WAN muet.")
return 0
if __name__ == "__main__":
sys.exit(main())