diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce63af..ddaa8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # CHANGELOG — Set-OPS +## 2026-08-09 — Un sixième devis : « ce qui n'est pas déclaré est-il refusé ? » + +`devis_expositions.py` pose la question positive — chaque exposition déclarée répond-elle. +Il manquait la négative, et ce n'est pas la même : un pare-feu peut très bien servir tout +ce qu'on lui demande **et** laisser passer tout le reste. `make frontiere-mesurer` la pose. + +**Les cibles ne sont pas saisies** : ce sont les ports réellement en écoute dans la flotte, +relevés par le playbook. Sonder un port fermé ne prouverait rien du pare-feu — le refus +viendrait de la machine. **La politique attendue non plus** : elle est lue dans +`devis_opnsense.py`, la source même qui configure la frontière. + +**`scripts/sonde_tcp.py` refuse de conclure.** Deux principes, tirés des trois faux +diagnostics de la semaine : + +1. Un **contrôle** avant tout verdict — une adresse où personne n'écoute. Si elle répond, + le relevé est déclaré **NUL** et aucun verdict n'est rendu. Mieux vaut pas de mesure + qu'une mesure fausse. +2. **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête + HTTP minimale, sinon poignée TLS. Si rien ne revient, le verdict est **AMBIGU** — pas + « ouvert ». LDAP et PostgreSQL attendent un message bien formé qu'on ne fabrique pas + ici, et un synproxy se comporte exactement pareil. + +Le verdict distingue deux natures d'écart, qui n'appellent pas le même geste : *refusé +attendu mais connexion établie* = la bordure a relayé, c'est le trou ; *livré attendu mais +rien livré* = la bordure autorise et l'**hôte** refuse, rien ne fuit mais les deux couches +ne déclarent pas la même politique. + +**Deux pièges rencontrés en le construisant, tous deux corrigés et commentés sur place :** + +- La sonde « tenant » s'exécutait en réalité **sur le poste** : dans un play + `connection: local`, un `delegate_to` hérite de cette connexion. Elle rendait donc la + frontière joignable depuis un tenant — ce qu'elle est, depuis le VLAN d'administration. + Vérifié à la main avant de la croire : `TimeoutError` depuis `backup-01` comme depuis + `infra-dns-01`. Même famille que le `connect()` — vérifier d'où l'instrument mesure. +- Le délai de lecture de 2 s faisait ressortir le `25` d'un Postfix parfaitement sain en + AMBIGU : `postscreen` retarde sa bannière exprès. Porté à 8 s, compensé par du + parallélisme — raccourcir aurait fabriqué de faux écarts. + +**Premier verdict, la frontière étant encore en l'état : 38 écarts.** Trente-sept sont +« la bordure a relayé » (la règle héritée étiquetée `PAS SUPPOSÉ -> ACTION REQUISE` laisse +passer le VLAN d'administration vers tout port en écoute), dont un livre vraiment — +`collab-01:9980`, Collabora, répond `HTTP/1.1 200 OK` depuis le poste. Le trente-huitième +est de l'autre nature : la frontière autorise `edge-mta-01:25` depuis le VLAN +d'administration, mais l'hôte le refuse. Le relevé sortant est déclaré NUL, son contrôle +ayant répondu. + ## 2026-08-09 — « La frontière ne doit jamais laisser passer de trafic impertinent » — validé, et deux trous fermés Exigence de l'exploitant, validée à l'instrument : de vraies requêtes applicatives contre diff --git a/Makefile b/Makefile index 78b280e..3af7257 100644 --- a/Makefile +++ b/Makefile @@ -418,7 +418,7 @@ ca-empreinte: ansible-runtime _instance-requise ## Empreinte de la racine, lue S -a "step certificate fingerprint /etc/step-ca/certs/root_ca.crt" 2>/dev/null \ | tail -1 | tr -d ' \r' -.PHONY: frontiere-plan frontiere-appliquer +.PHONY: frontiere-plan frontiere-appliquer frontiere-mesurer courriel-plan: ansible-runtime ## Chaine Postfix -> LDAP -> Dovecot -> IMAP (aucune ecriture) @rm -f $(SETOPS_INSTANCE)/devis-courriel.json.* @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-courriel.yml >/dev/null @@ -433,6 +433,10 @@ expositions-plan: ansible-runtime ## Chaque exposition du plan repond-elle ? (ed @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-expositions.yml >/dev/null @python3 scripts/devis_expositions.py +frontiere-mesurer: ansible-runtime ## La frontiere refuse-t-elle ce qui n'est pas declare ? (sonde reelle, aucune ecriture) + @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-frontiere.yml >/dev/null + @python3 scripts/devis_frontiere.py + certificats-plan: ansible-runtime ## Ecart entre les certificats sur disque et ceux reellement servis (aucune ecriture) @rm -f $(SETOPS_INSTANCE)/devis-certificats.json.* @ansible-playbook -i $(SETOPS_INVENTAIRE) playbooks/maintenance/devis-certificats.yml >/dev/null diff --git a/docs/audit/preuve-2026-08-09.md b/docs/audit/preuve-2026-08-09.md index 720aa7d..50a627d 100644 --- a/docs/audit/preuve-2026-08-09.md +++ b/docs/audit/preuve-2026-08-09.md @@ -43,7 +43,7 @@ | P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 2 pool(s) Proxmox, 28 VM placee(s), aucun nom ni VMID en collision. | | P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 23 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 12, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv | | P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 2 zone(s), 12 VNet(s), 12 sous-reseau(x), aucune collision. | -| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 39 scripts expliques et atteignables, 89 cibles make documentees, 54 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 41 scripts expliques et atteignables, 90 cibles make documentees, 54 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 30 exigence(s) de role, toutes satisfaites (126 cle(s) declaree(s) par l'instance). | | P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 32 revendication(s) de port, aucune collision entre roles co-localises (33 groupes). | diff --git a/docs/devis-services.md b/docs/devis-services.md index a523a35..e0e3364 100644 --- a/docs/devis-services.md +++ b/docs/devis-services.md @@ -9,6 +9,7 @@ make certificats-plan # certificats sur disque contre certificats réellement make expositions-plan # chaque `expose:` du plan répond-il, depuis l'edge et depuis le poste make postgresql-plan # chiffrement imposé, et à quels réseaux make courriel-plan # Postfix → LDAP → LMTP → Dovecot → IMAP +make frontiere-mesurer # ce qui n'est pas déclaré à la frontière est-il refusé ? ``` ## Le trou qu'il comble @@ -65,6 +66,31 @@ elle-même, le certificat était **expiré depuis plus de huit heures** et le re échouait toutes les quatorze minutes sur `'step ca renew' requires the '--ca-url' flag`. Rien ne le signalait. +## Le devis d'étanchéité de la frontière + +Le devis des expositions pose la question **positive** — chaque exposition déclarée +répond-elle. Celui-ci pose la **négative** : la frontière laisse-t-elle passer ce que la +politique n'autorise pas ? Les deux sont nécessaires. Un pare-feu peut très bien servir +tout ce qu'on lui demande *et* laisser passer tout le reste. + +Rien n'y est saisi. Les **cibles** sont les ports réellement en écoute dans la flotte — +sonder un port fermé ne prouverait rien du pare-feu, le refus viendrait de la machine. La +**politique attendue** est lue dans `devis_opnsense.py`, la source même qui configure la +frontière. + +`scripts/sonde_tcp.py` porte deux principes, tirés de trois faux diagnostics : + +- **Un contrôle avant tout verdict** — une adresse où personne n'écoute. Si elle répond, + le relevé est déclaré **NUL**. Mieux vaut pas de mesure qu'une mesure fausse. +- **Établir n'est pas livrer.** La sonde fait parler le service : bannière, sinon requête + HTTP minimale, sinon poignée TLS. Sans réponse, le verdict est **AMBIGU**, jamais + « ouvert » — LDAP et PostgreSQL attendent un message qu'on ne fabrique pas ici, et un + synproxy se comporte exactement pareil. + +Le verdict sépare deux natures d'écart : *refusé attendu, connexion établie* = la bordure a +relayé, c'est le trou ; *livré attendu, rien livré* = la bordure autorise et l'hôte refuse +— rien ne fuit, mais les deux couches ne déclarent pas la même politique. + ## Le devis des expositions Il pose la question que se pose vraiment un utilisateur : **est-ce que mes services diff --git a/playbooks/maintenance/devis-frontiere.yml b/playbooks/maintenance/devis-frontiere.yml new file mode 100644 index 0000000..d0fc2b3 --- /dev/null +++ b/playbooks/maintenance/devis-frontiere.yml @@ -0,0 +1,118 @@ +--- +# Devis d'etancheite de la frontiere — RELEVE seul. Ne modifie rien (D-23). +# +# Question posee : la frontiere laisse-t-elle passer ce que la politique n'autorise PAS ? +# `devis-expositions.yml` pose la question inverse — chaque exposition declaree repond-elle. +# Les deux sont necessaires : un pare-feu peut servir tout ce qu'on lui demande *et* +# laisser passer tout le reste. +# +# LES CIBLES SONT LES PORTS REELLEMENT EN ECOUTE, releves ici. Sonder un port ferme ne +# prouverait rien du pare-feu : le refus viendrait de la machine, pas de la bordure. +# +# LE CONTROLE D'ABORD. La sonde interroge une adresse ou personne n'ecoute avant tout +# verdict ; si elle repond, le releve est declare NUL. C'est la lecon du 2026-08-09 : +# `connect()` a menti deux jours durant a travers un `synproxy` herite. +# +# make frontiere-mesurer + +- name: Relevé des ports réellement en écoute + hosts: hotes_actifs + become: true + gather_facts: false + + tasks: + - name: Recenser les ports TCP en écoute + ansible.builtin.shell: + executable: /bin/bash + # `|| true` en fin de chaîne : avec `pipefail`, un `grep -v` qui ne garde rien + # tuerait la tâche. Un hôte sans écoute publique est un relevé vide, pas une panne. + cmd: >- + set -o pipefail; + ss -ltnH | awk '{print $4}' + | grep -vE '^127\.|^\[::1\]' | sed 's/.*://' | sort -un | tr '\n' ' ' || true + register: devis_frontiere_ss + changed_when: false + + - name: Retenir l'écoute de cet hôte + ansible.builtin.set_fact: + devis_frontiere_ecoute: + ip: "{{ ansible_host }}" + ports: "{{ devis_frontiere_ss.stdout.split() | map('int') | list }}" + +- name: Sondage depuis les deux points de vue + hosts: localhost + connection: local + become: false + gather_facts: false + + tasks: + - name: Rassembler l'écoute de toute la flotte + ansible.builtin.set_fact: + devis_frontiere_ports: >- + {{ dict(groups['hotes_actifs'] + | map('extract', hostvars, 'devis_frontiere_ecoute') + | list | zip(groups['hotes_actifs']) | map('reverse') | list) }} + + # Forme `argv` et non `cmd` : le chemin du dépôt peut contenir une espace, et `cmd` + # découpe dessus. Mesuré ici même — « /home/.../Espace Chezlepro/... » cassait. + - name: Dériver les cibles à sonder depuis la politique + ansible.builtin.command: + argv: + - python3 + - "{{ playbook_dir }}/../../scripts/devis_frontiere.py" + - --cibles + stdin: "{{ devis_frontiere_ports | to_json }}" + register: devis_frontiere_cibles + changed_when: false + + - name: Sonder depuis le VLAN d'administration (le poste de l'exploitant) + ansible.builtin.command: + argv: + - python3 + - "{{ playbook_dir }}/../../scripts/sonde_tcp.py" + stdin: >- + {{ {'controle': (devis_frontiere_cibles.stdout | from_json).controle, + 'cibles': (devis_frontiere_cibles.stdout | from_json).admin} | to_json }} + register: devis_frontiere_admin + changed_when: false + +# Play distinct, et ce n'est pas cosmétique. Dans un play `connection: local`, un +# `delegate_to` hérite de cette connexion : la sonde « tenant » s'exécutait en réalité sur +# le poste. Elle rendait donc la frontière JOIGNABLE — depuis le VLAN d'administration, +# elle l'est. Mesuré et corrigé le 2026-08-09 ; c'est la même famille de piège que le +# `connect()` : vérifier d'où l'instrument mesure avant de croire ce qu'il dit. +- name: Sondage depuis un hôte du tenant (le sens sortant) + # Un seul hôte suffit : le devis mesure la bordure, pas les hôtes. Les règles sortantes + # portent sur l'alias du tenant, donc sur tous ses hôtes indistinctement. + # Motif `groupe[0]` et non un template : un `hosts:` templaté échoue quand l'inventaire + # est vide — c'est ce qui cassait `ansible-lint`, qui s'exécute sans inventaire. + hosts: hotes_actifs[0] + become: false + gather_facts: false + + tasks: + - name: Sonder les destinations internes interdites + ansible.builtin.script: + cmd: >- + "{{ playbook_dir }}/../../scripts/sonde_tcp.py" + '{{ {"controle": (hostvars["localhost"].devis_frontiere_cibles.stdout + | from_json).controle, + "cibles": (hostvars["localhost"].devis_frontiere_cibles.stdout + | from_json).tenant} | to_json }}' + args: + executable: python3 + register: devis_frontiere_tenant + changed_when: false + + - name: Déposer le relevé sur le contrôleur + ansible.builtin.copy: + dest: "{{ playbook_dir }}/../../instance/devis-frontiere.json" + mode: "0600" + content: >- + {{ { + 'admin': hostvars['localhost'].devis_frontiere_admin.stdout | from_json, + 'tenant': devis_frontiere_tenant.stdout | from_json, + 'sonde_depuis': inventory_hostname, + } | to_nice_json }} + delegate_to: localhost + become: false diff --git a/scripts/devis_frontiere.py b/scripts/devis_frontiere.py new file mode 100644 index 0000000..e68ea14 --- /dev/null +++ b/scripts/devis_frontiere.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Devis d'etancheite de la frontiere : ce qui n'est pas declare est-il refuse ? + +`devis_expositions.py` pose la question POSITIVE — chaque exposition declaree repond-elle. +Celui-ci pose la NEGATIVE, et c'est une autre question : la frontiere laisse-t-elle passer +ce que la politique n'autorise pas. Un pare-feu peut tres bien servir tout ce qu'on lui +demande *et* laisser passer tout le reste. + +La politique attendue n'est pas saisie : elle est LUE dans `devis_opnsense.py`, la meme +source qui produit les regles posees sur le boitier. Les cibles ne sont pas saisies non +plus : ce sont les ports REELLEMENT EN ECOUTE dans la flotte, releves par le playbook. +Sonder un port ferme ne prouverait rien du pare-feu. + +Deux modes : + --cibles lit le releve des ports en ecoute (stdin), emet les listes a sonder + (defaut) lit le releve complet et rend le verdict, code 1 s'il y a un ecart + +Aucun acces reseau ici — c'est le playbook qui sonde (D-23). + + make frontiere-mesurer +""" +from __future__ import annotations + +import argparse +import ipaddress +import json +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent + +# Adresse de controle : documentee comme non routable et attribuee a personne. Si elle +# repond, c'est l'instrument qui ment — voir l'en-tete de `sonde_tcp.py`. +CONTROLE = ["172.31.99.99", 22] + + +def _politique() -> dict: + """Ce que la frontiere est CENSEE autoriser, lu a la source qui la configure.""" + r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_opnsense.py"), + "--json"], cwd=RACINE, capture_output=True, text=True) + if r.returncode != 0: + raise SystemExit("Le devis de la frontiere ne se genere pas :\n" + + r.stderr.strip()[:400]) + return json.loads(r.stdout) + + +def _adresses(devis: dict, nom_alias: str) -> list[str]: + """Les adresses derriere un alias — hotes tels quels, reseaux developpes en prefixe.""" + a = devis["alias"].get(nom_alias.lstrip("!")) + return list(a["contenu"]) if a else [] + + +def autorise_depuis_gestion(devis: dict) -> set[tuple[str, int]]: + """Couples (adresse, port) que le VLAN d'administration a le DROIT d'atteindre.""" + permis: set[tuple[str, int]] = set() + for r in devis["regles"]: + if r["interface"] != devis.get("if_gestion") or r["sens"] != "in": + continue + if r["protocole"] not in ("tcp",): + continue + for cible in _adresses(devis, r["destination"]): + for p in r["ports"] or []: + if str(p).isdigit(): + permis.add((cible, int(p))) + return permis + + +def _couvert(ip: str, permis: set[tuple[str, int]], port: int) -> bool: + """Un permis peut etre un hote OU un reseau (le `/24` d'un tenant, par exemple).""" + for cible, p in permis: + if p != port: + continue + if cible == ip: + return True + if "/" in cible: + try: + if ipaddress.ip_address(ip) in ipaddress.ip_network(cible, strict=False): + return True + except ValueError: + continue + return False + + +def cibles(ports_par_hote: dict) -> dict: + """Les listes a sonder, derivees de la politique et de ce qui ecoute vraiment.""" + devis = _politique() + permis = autorise_depuis_gestion(devis) + + admin = [] + for hote, info in sorted(ports_par_hote.items()): + ip = str(info["ip"]) + for port in sorted(set(info["ports"])): + attendu = "LIVRE" if _couvert(ip, permis, int(port)) else "REFUSE" + admin.append([ip, int(port), f"{hote}:{port} attendu={attendu}"]) + + # Sens sortant : la frontiere elle-meme, qu'aucun tenant ne doit joindre. C'est le + # trou mesure le 2026-08-09 — `https://10.0.0.1/` repondait depuis une VM. + interne = [] + for r in devis["regles"]: + if r["interface"] == devis.get("if_gestion") and r["sens"] == "in": + for c in _adresses(devis, r["source"]): + if "/" in c: + reseau = ipaddress.ip_network(c, strict=False) + interne.append([str(next(reseau.hosts())), 443, + "frontiere depuis le tenant attendu=REFUSE"]) + vus, tenant = set(), [] + for c in interne: + if c[0] not in vus: + vus.add(c[0]) + tenant.append(c) + return {"controle": CONTROLE, "admin": admin, "tenant": tenant} + + +def analyser(releve: dict) -> tuple[list[str], list[str]]: + """Confronte les verdicts sondes a la politique. Rend (ecarts, lignes lisibles).""" + ecarts: list[str] = [] + lignes: list[str] = [] + + for vantage, cle in (("VLAN d'administration", "admin"), ("depuis un tenant", "tenant")): + bloc = releve.get(cle) or {} + if not bloc: + continue + if not bloc.get("valide", False): + ecarts.append( + f"Releve {vantage} NUL : le controle {CONTROLE[0]} a repondu — quelque " + "chose sur le chemin repond a la place des destinations. Aucun verdict " + "de ce point de vue n'a de sens tant que ce n'est pas corrige." + ) + continue + lignes.append(f" --- {vantage} ---") + for r in bloc.get("resultats") or []: + etiq = str(r.get("etiquette") or "") + attendu = etiq.rsplit("attendu=", 1)[-1] if "attendu=" in etiq else "?" + obtenu = str(r.get("verdict")) + # AMBIGU vaut « la connexion s'etablit sans rien livrer » : pour un port qui + # doit etre REFUSE, c'est un ecart — la frontiere a relaye. Pour un port qui + # doit etre LIVRE, c'est aussi un ecart : on n'a pas la preuve qu'il sert. + conforme = (attendu == obtenu) + lignes.append(f" {'ok ' if conforme else 'ECART'} {etiq[:52]:<52} " + f"{obtenu:<7} {str(r.get('preuve'))[:40]}") + if conforme: + continue + # Deux natures d'ecart, qui n'appellent pas le meme geste : + # - REFUSE attendu, connexion etablie -> la bordure a RELAYE. C'est le trou. + # - LIVRE attendu, rien livre -> la bordure autorise, l'HOTE refuse. + # Rien ne fuit ; ce sont les deux couches qui ne disent pas la meme chose. + if attendu == "LIVRE": + ecarts.append(f"{etiq} — la frontiere l'autorise mais l'hote le refuse : " + "les deux couches ne declarent pas la meme politique") + else: + ecarts.append(f"{etiq} — obtenu {obtenu} : la frontiere a relaye " + f"({r.get('preuve')})") + return ecarts, lignes + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cibles", action="store_true", + help="emet les listes a sonder (lit les ports en ecoute sur stdin)") + ap.add_argument("--releve", default=None, help="fichier de releve a analyser") + a = ap.parse_args(argv) + + if a.cibles: + json.dump(cibles(json.load(sys.stdin)), sys.stdout) + return 0 + + chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-frontiere.json" + if not chemin.is_file(): + raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.") + ecarts, lignes = analyser(json.loads(chemin.read_text())) + print("\n".join(lignes)) + if ecarts: + print(f"\nNON CONFORME : {len(ecarts)} ecart(s) — la frontiere ne dit pas ce que " + "la politique dit.") + for e in ecarts: + print(" -", e) + return 1 + print("\nCONFORME : tout ce qui est declare est livre, tout le reste est refuse.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/sonde_tcp.py b/scripts/sonde_tcp.py new file mode 100644 index 0000000..361f34e --- /dev/null +++ b/scripts/sonde_tcp.py @@ -0,0 +1,126 @@ +#!/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)) + + # Le controle DOIT etre bloque. S'il ne l'est pas, quelque chose sur le chemin repond + # a la place des destinations : plus aucun verdict de ce releve n'a de sens. + if controle["verdict"] != "BLOQUE": + 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())