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:
parent
a4b6565056
commit
6a8fc53084
6 changed files with 125 additions and 18 deletions
1
Makefile
1
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
58
scripts/tests/test_frontiere_refus.py
Normal file
58
scripts/tests/test_frontiere_refus.py
Normal 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())
|
||||
Loading…
Reference in a new issue