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_sonde_tcp.py
|
||||||
python3 scripts/tests/test_restauration.py
|
python3 scripts/tests/test_restauration.py
|
||||||
python3 scripts/tests/test_repertoires_partages.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
|
# 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
|
# 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. |
|
| 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 |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 :
|
# CE QUE L'ERRNO DIT — mesure du 2026-09-30, depuis web-frontal-01 de Technolibre :
|
||||||
# ouvert le flux passe -> OK
|
# ouvert le flux passe -> OK
|
||||||
# ECONNREFUSED le paquet est PASSE, personne n'ecoute sur ce port -> INFORMATION
|
# ECONNREFUSED REFUSE : personne n'ecoute, OU la frontiere refuse -> AVERTISSEMENT
|
||||||
# (pas une coupure : un flux declare que rien ne sert — p. ex. une
|
# Depuis le 2026-10-01 les interfaces internes de la frontiere rendent un
|
||||||
# interface liee en local derriere la passerelle SSO. Nomme et compte,
|
# `reject` — un RST pour TCP, indiscernable d'un port ferme. Et depuis les
|
||||||
# mais pas en jaune : un avertissement permanent apprend a ignorer le jaune)
|
# `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
|
# EHOSTUNREACH REJETE (pare-feu Proxmox : icmp host-prohibited), ou
|
||||||
# machine injoignable -> CRITIQUE
|
# machine injoignable -> CRITIQUE
|
||||||
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
|
# delai JETE en silence (politique `drop` de nftables) -> CRITIQUE
|
||||||
|
|
@ -54,7 +56,7 @@ def tcp(ip, port):
|
||||||
return "delai"
|
return "delai"
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.ECONNREFUSED:
|
if e.errno == errno.ECONNREFUSED:
|
||||||
return "personne"
|
return "refuse"
|
||||||
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
|
if e.errno in (errno.EHOSTUNREACH, errno.ENETUNREACH, errno.EACCES, errno.EPERM):
|
||||||
return "rejete"
|
return "rejete"
|
||||||
return errno.errorcode.get(e.errno, str(e.errno))
|
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:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=16) as ex:
|
||||||
resultats = list(ex.map(essai, sorties))
|
resultats = list(ex.map(essai, sorties))
|
||||||
|
|
||||||
coupes = [(f, r) for f, r in resultats if r not in ("ouvert", "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 == "personne"]
|
personne = [(f, r) for f, r in resultats if r == "refuse"]
|
||||||
ouverts = len(resultats) - len(coupes) - len(personne)
|
ouverts = len(resultats) - len(coupes) - len(personne)
|
||||||
|
|
||||||
# Entrees etablies hors des regles declarees.
|
# 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}")
|
print(f"COUPE : {len(coupes)}/{len(resultats)} flux sortant(s) ne passent pas — {nomme(coupes)}. {perf}")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
avert = []
|
avert = []
|
||||||
info = (f" ; {len(personne)} flux declare(s) sans service (rien n'ecoute) — {nomme(personne)}"
|
info = ""
|
||||||
if personne else "")
|
if personne:
|
||||||
|
avert.append(f"{len(personne)} flux refuse(s) — service absent, ou refus de la frontiere — "
|
||||||
|
f"{nomme(personne)}")
|
||||||
if hors:
|
if hors:
|
||||||
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
|
avert.append(f"{len(hors)} connexion(s) entrante(s) hors des regles declarees — "
|
||||||
+ ", ".join(sorted(hors)[:5]))
|
+ ", ".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"),
|
"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).
|
# 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
|
# 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]:
|
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
|
POURQUOI (2026-08-28). Sous `drop` partout, un timeout voulait dire trois choses
|
||||||
incompatibles : aucune machine, aucune route, ou une politique. Deux erreurs de
|
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")
|
"`POLITIQUE_VM`, sinon les deux divergeront")
|
||||||
mesures.append("pare-feu est-ouest en REJECT, source unique")
|
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
|
# 3. La bordure se tait SUR LE WAN — c'est la condition de prudence de tout reject
|
||||||
# prudence du reject interne. Les actions du devis OPNsense restent `pass`/`block`.
|
# 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")
|
front = (RACINE / "scripts" / "devis_opnsense.py").read_text(encoding="utf-8")
|
||||||
if '"reject"' in front or "'reject'" in front:
|
if '"action": "reject"' not in front:
|
||||||
fautes.append("le devis de la frontiere emet un `reject` : la bordure doit rester "
|
fautes.append("le devis de la frontiere n'emet aucun refus audible interne : un "
|
||||||
"muette, sans quoi elle repond aux balayages d'Internet")
|
"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))
|
actions = sorted(set(re.findall(r'"action":\s*"(\w+)"', front))
|
||||||
| set(re.findall(r'action.*?or\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:
|
if fautes:
|
||||||
return False, "La ligne n'est plus tenue :\n - " + "\n - ".join(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.")
|
f"Sans lui, on ne sait pas ce que la frontiere recevrait.")
|
||||||
|
|
||||||
regles = devis.get("regles") or []
|
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:
|
if not silences:
|
||||||
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
|
return True, ("Aucune regle de blocage au devis — rien a garder. La preuve "
|
||||||
"redeviendra parlante des qu'un silence sera declare.")
|
"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