Set-OPS-Public/scripts/sonde_tcp.py

131 lines
5.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Sonde TCP honnete : distingue LIVRE, BLOQUE et AMBIGU — et refuse de conclure.
Pourquoi ce fichier existe. Pendant deux jours, `connect()` a menti : a travers la
frontiere, toute connexion aboutissait, y compris vers `172.31.99.99` une adresse qui
n'appartient a personne. La cause etait un `synproxy` porte par une regle heritee : le
pare-feu repondait au SYN a la place de la destination. Trois faux diagnostics en sont
sortis avant qu'un controle ne le revele.
D'ou les deux principes de cette sonde :
1. **Un CONTROLE avant toute conclusion.** On sonde d'abord une adresse ou personne
n'ecoute. Si elle « repond », l'instrument est en cause et le releve est declare NUL
aucun verdict n'est rendu. Mieux vaut pas de mesure qu'une mesure fausse.
2. **On ne conclut jamais sur `connect()` seul.** Etablir n'est pas livrer. La sonde
essaie de faire PARLER le service, dans cet ordre : lire une banniere (SSH, SMTP,
IMAP parlent en premier), sinon envoyer une requete HTTP minimale, sinon tenter une
poignee TLS. Si rien ne revient, le verdict est **AMBIGU**, pas « ouvert » : certains
protocoles (LDAP, PostgreSQL) attendent un message bien forme qu'on ne fabrique pas
ici, et un synproxy se comporte exactement pareil.
Usage : la liste des cibles arrive en JSON sur l'entree standard, les verdicts sortent en
JSON sur la sortie standard. Aucun etat, aucun fichier, aucun privilege.
echo '{"controle": ["172.31.99.99", 22], "cibles": [["10.27.18.21", 22, "ssh"]]}' \
| python3 scripts/sonde_tcp.py
"""
from __future__ import annotations
import json
import socket
import ssl
import sys
import time
from concurrent.futures import ThreadPoolExecutor
DELAI_CONNEXION = 4.0
# Genereux a dessein : Postfix retarde sa banniere de plusieurs secondes (postscreen le
# fait expres, pour pieger les spambots). A 2 s, un port parfaitement sain ressortait
# AMBIGU. On compense le cout par le parallelisme plutot qu'en raccourcissant l'attente —
# raccourcir aurait fabrique de faux ecarts, ce que cette sonde existe pour eviter.
DELAI_LECTURE = 8.0
PARALLELE = 12
def _sonder(hote: str, port: int) -> dict:
"""Un verdict pour une cible : BLOQUE, LIVRE ou AMBIGU, avec la preuve."""
debut = time.monotonic()
try:
s = socket.create_connection((hote, port), timeout=DELAI_CONNEXION)
except OSError as e:
return {"verdict": "BLOQUE", "ms": int((time.monotonic() - debut) * 1000),
"preuve": type(e).__name__}
ms = int((time.monotonic() - debut) * 1000)
try:
s.settimeout(DELAI_LECTURE)
# 1. Le serveur parle-t-il en premier ? (SSH, SMTP, IMAP, POP3, redis apres coup)
try:
data = s.recv(96)
if data:
return {"verdict": "LIVRE", "ms": ms,
"preuve": data.decode("utf-8", "replace").strip()[:60]}
except (TimeoutError, socket.timeout, OSError):
pass
# 2. Sinon, le faire parler. Une requete HTTP minimale suffit pour tout ce qui
# parle HTTP, et arrache une erreur lisible a plusieurs qui ne le parlent pas.
try:
s.sendall(b"GET / HTTP/1.0\r\nHost: sonde\r\n\r\n")
data = s.recv(96)
if data:
return {"verdict": "LIVRE", "ms": ms,
"preuve": data.decode("utf-8", "replace").strip()[:60]}
except (TimeoutError, socket.timeout, OSError):
pass
finally:
try:
s.close()
except OSError:
pass
# 3. Dernier essai : une vraie poignee TLS. En TLS c'est le CLIENT qui parle en
# premier — le silence des deux etapes precedentes ne prouve donc rien ici.
try:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((hote, port), timeout=DELAI_CONNEXION) as brut:
with ctx.wrap_socket(brut) as tls:
cert = tls.getpeercert(binary_form=True)
if cert:
return {"verdict": "LIVRE", "ms": ms,
"preuve": f"TLS {tls.version()}, certificat servi"}
except (OSError, ssl.SSLError):
pass
return {"verdict": "AMBIGU", "ms": ms,
"preuve": "connexion etablie, aucune donnee applicative"}
def main() -> int:
# En argument quand la sonde est deposee sur un hote distant (`ansible.builtin.script`
# ne sait pas alimenter l'entree standard), sur stdin en local.
entree = json.loads(sys.argv[1]) if len(sys.argv) > 1 else json.load(sys.stdin)
ctrl_hote, ctrl_port = entree["controle"]
controle = _sonder(str(ctrl_hote), int(ctrl_port))
frontiere : etanche — CONFORME sur 56 lignes, dans les deux sens L'exploitant a retire la derniere regle heritee. `make frontiere-mesurer` rend CONFORME (code 0) : tout ce qui est declare est livre, tout le reste est refuse — y compris collab-01:9980, le seul qui livrait vraiment un HTTP 200 depuis le poste. Et mon instrument avait tort, pas la frontiere. Il comptait 38 ecarts en concluant depuis le client : « connexion etablie => la bordure a relaye ». Faux, verifie A LA DESTINATION : pendant que le poste tenait une connexion « etablie » vers idm-01:389, idm-01 n'en voyait aucune ; collab-01 n'en voyait aucune sur 9980. La frontiere repond a la poignee TCP sans relayer. Le devis raisonne desormais sur la LIVRAISON seule, et le controle ne rend le releve NUL que s'il LIVRE des donnees — qu'il ressorte AMBIGU est attendu ici et le rapport le dit a chaque execution. Cette relaxation rend aussi le sens sortant mesurable : il etait declare NUL en permanence. Nouveau mot-cle `poste: false` dans meta/flux.yml : un service publie n'est pas forcement fait pour un poste de travail. Le 25 entrant de Postfix est un flux serveur a serveur ; la frontiere l'etendait au VLAN d'administration ou le nftables de l'hote le refusait. Deux regles retirees. Le mot-cle vit avec le role qui sait ce que son port veut dire ; le generateur ne connait toujours aucun numero de port. Verifie : frontiere-plan sans ecart (41 regles, 12 routes), flotte 14/14, frontiere-mesurer CONFORME, prouver.py 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:33:04 -04:00
# Le controle ne doit rien LIVRER. Qu'il ressorte AMBIGU est tolerable et meme
# attendu ici : la frontiere repond elle-meme a la poignee TCP sans jamais relayer
# (verifie a la destination le 2026-08-09 — `idm-01` et `collab-01` ne voyaient
# aucune connexion pendant que le poste en tenait une « etablie »). Ce que le controle
# garantit, c'est que la LIVRAISON n'est pas simulee. S'il livre des donnees, quelque
# chose parle a la place des destinations et plus aucun verdict n'a de sens.
if controle["verdict"] == "LIVRE":
json.dump({"valide": False, "controle": controle, "resultats": []}, sys.stdout)
return 0
def _un(cible) -> dict:
hote, port = str(cible[0]), int(cible[1])
return {"hote": hote, "port": port,
"etiquette": cible[2] if len(cible) > 2 else "",
**_sonder(hote, port)}
liste = entree.get("cibles") or []
with ThreadPoolExecutor(max_workers=PARALLELE) as pool:
resultats = list(pool.map(_un, liste))
json.dump({"valide": True, "controle": controle, "resultats": resultats}, sys.stdout)
return 0
if __name__ == "__main__":
sys.exit(main())