#!/usr/bin/env python3 """Service de ping de r-no.fr (outil ip/ping). Un navigateur ne sait pas envoyer de ping (ICMP). Et l'intérêt de l'outil est justement de pinguer une adresse depuis un AUTRE chemin réseau que celui du visiteur : pour vérifier qu'une machine répond depuis l'extérieur, qu'une nouvelle route ou une règle de pare-feu fonctionne. C'est donc le serveur de r-no.fr qui envoie le ping, et ce fichier est le code qui le fait. Ce même fichier est servi tel quel sur la page de l'outil, avec son empreinte, et c'est lui que copie l'image du conteneur, au même commit. Le visiteur peut le lire ; il ne peut pas vérifier que c'est bien lui qui tourne. La page le dit. Une seule route, servie derrière Traefik : POST /ip/ping/api {"cible": "192.0.2.1"} quatre ping, une seconde d'écart, et leurs résultats GET /sante état du service, pour le HEALTHCHECK (non routé) Garde-fous, pour que l'adresse du serveur ne soit jamais vue comme une source d'abus (plaintes chez l'hébergeur, listes de blocage) : - une adresse IPv4 écrite en chiffres, jamais un nom : le serveur ne fait aucune résolution DNS pour le compte d'un visiteur ; - jamais une adresse privée, locale ou réservée, ni celle du serveur lui-même ; et les adresses dont le titulaire a demandé à être exclu (variable IP_EXCLUES) ; - toujours quatre paquets de 64 octets, une seconde d'écart : le visiteur ne choisit ni le nombre, ni la taille, ni la cadence ; - aucun ping continu : jamais plus de quatre paquets par demande, et 30 secondes de pause obligatoire entre deux demandes d'un même visiteur. Un script qui boucle n'obtient pas mieux que quatre paquets toutes les 30 s, puis bute sur la limite de dix par dix minutes ; - des limites par visiteur, par adresse visée, par réseau visé (/24, contre le balayage d'une plage) et pour tout le service ; quelques ping en même temps au plus, un seul par visiteur ; - le contenu des paquets dit d'où ils viennent (« r-no.fr/ip/ping ») : l'administrateur de la machine visée sait à qui s'adresser ; - un coupe-circuit : PING_ACTIF=0 met le service en pause sans le retirer ; - seules les pages de r-no.fr sont servies (Sec-Fetch-Site et Origin), comme pour ip/my-ip : un frein à l'usage par des scripts, pas un verrou. Ce qui est gardé : l'adresse du visiteur et l'adresse visée, en mémoire seulement, le temps des limites (dix minutes au plus), jamais sur disque, effacées à chaque redémarrage. Le service n'écrit aucune ligne de journal par requête ; Traefik, lui, note chaque requête (sans son contenu), comme pour toute page du site. Le ping passe par une socket ICMP « datagramme » : le noyau Linux l'accepte d'un utilisateur sans privilège quand net.ipv4.ping_group_range le permet (réglé dans docker-compose.yml). Le conteneur n'a donc besoin d'aucune capacité, pas même NET_RAW, et le noyau ne lui remet que les réponses à ses propres paquets. Aucune dépendance : tout est dans la bibliothèque standard. """ import ipaddress import json import math import os import socket import struct import sys import threading import time from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # --- Ce que le visiteur ne choisit pas ------------------------------------ NOMBRE = 4 # paquets par ping INTERVALLE = 1.0 # secondes entre deux paquets ; une réponse plus lente est perdue TAILLE_CHARGE = 56 # octets de données : 64 avec l'en-tête ICMP, comme ping # Le contenu des paquets se présente, pour l'administrateur de la machine visée. CHARGE = b"r-no.fr/ip/ping - https://r-no.fr/ip/ping/ ".ljust(TAILLE_CHARGE, b".") # Limites : (nombre de ping au plus, sur une fenêtre de tant de secondes). LIMITES = { "pause": (1, 30), # pas de ping continu : 30 s entre deux ping d'un visiteur "visiteur": (10, 600), # un visiteur : 10 ping par 10 minutes "cible": (10, 600), # une adresse visée, tous visiteurs confondus "reseau": (20, 600), # un /24 visé : contre le balayage d'une plage "global": (120, 600), # tout le service : moins d'un paquet par seconde } SIMULTANES = 4 # ping en cours en même temps, au plus CORPS_MAX = 256 # octets : {"cible": "255.255.255.255"} en fait 27 # Plages jamais visées, en IPv4 (registre IANA des usages spéciaux). Écrites # ici plutôt que déduites de ipaddress.is_global, dont la réponse change selon # la version de Python : la liste se lit et s'audite telle quelle. RESERVEES = [ ("0.0.0.0/8", "« ce réseau »"), ("10.0.0.0/8", "privée (RFC 1918)"), ("100.64.0.0/10", "NAT d'opérateur (RFC 6598)"), ("127.0.0.0/8", "bouclage"), ("169.254.0.0/16", "lien local"), ("172.16.0.0/12", "privée (RFC 1918)"), ("192.0.0.0/24", "protocoles de l'IETF"), ("192.0.2.0/24", "documentation (RFC 5737)"), ("192.88.99.0/24", "relais 6to4 (obsolète)"), ("192.168.0.0/16", "privée (RFC 1918)"), ("198.18.0.0/15", "banc d'essai (RFC 2544)"), ("198.51.100.0/24", "documentation (RFC 5737)"), ("203.0.113.0/24", "documentation (RFC 5737)"), ("224.0.0.0/4", "multicast"), ("240.0.0.0/4", "réservée, et diffusion"), ] RESERVEES = [(ipaddress.IPv4Network(p), nom) for p, nom in RESERVEES] def lire_reseaux(texte: str) -> list: """« 192.0.2.1, 198.51.100.0/24 » → réseaux ; une entrée invalide arrête le service au démarrage plutôt que d'être ignorée en silence.""" reseaux = [] for morceau in (texte or "").replace(";", ",").split(","): morceau = morceau.strip() if morceau: reseaux.append(ipaddress.IPv4Network(morceau, strict=False)) return reseaux # --- Configuration, lue une fois au démarrage ------------------------------- ORIGINE = os.environ.get("ORIGINE", "https://r-no.fr") # L'adresse publique du serveur : affichée au visiteur (c'est de là que # partent les paquets, à autoriser dans son pare-feu) et interdite comme cible. IP_SOURCE = os.environ.get("IP_SOURCE", "").strip() INTERDITES = lire_reseaux(IP_SOURCE) + lire_reseaux(os.environ.get("IP_INTERDITES", "")) # Adresses dont le titulaire a demandé à ne pas être pinguées d'ici. EXCLUES = lire_reseaux(os.environ.get("IP_EXCLUES", "")) ACTIF = os.environ.get("PING_ACTIF", "1").strip() not in ("0", "non", "false", "") PORT = int(os.environ.get("PORT", "8080")) # --- La cible ------------------------------------------------------------------ def lire_cible(texte, interdites=None, exclues=None): """Renvoie (adresse, None) ou (None, code d'erreur). Codes : invalide, ipv6, reservee, interdite (le serveur lui-même), exclue (à la demande de son titulaire).""" interdites = INTERDITES if interdites is None else interdites exclues = EXCLUES if exclues is None else exclues if not isinstance(texte, str): return None, "invalide" texte = texte.strip() if ":" in texte: return None, "ipv6" parties = texte.split(".") # Chiffres seulement, sans zéro en tête (« 010 » est lu en octal par # certains outils), quatre nombres de 0 à 255 : pas de « 1.2.3 », pas de nom. if len(parties) != 4 or not all(p.isascii() and p.isdigit() and (p == "0" or p[0] != "0") and len(p) <= 3 and int(p) <= 255 for p in parties): return None, "invalide" adresse = ipaddress.IPv4Address(texte) if any(adresse in reseau for reseau, _ in RESERVEES): return None, "reservee" if any(adresse in reseau for reseau in interdites): return None, "interdite" if any(adresse in reseau for reseau in exclues): return None, "exclue" return str(adresse), None def reseau_24(adresse: str) -> str: return str(ipaddress.IPv4Network(f"{adresse}/24", strict=False)) # --- Les limites ----------------------------------------------------------------- class Limites: """Fenêtres glissantes en mémoire, jamais écrites sur disque. prendre() vérifie toutes les limites d'un ping avant d'en compter aucune : un ping refusé ne consomme rien.""" def __init__(self, regles=None, horloge=time.monotonic): self.regles = regles or LIMITES self.horloge = horloge self.traces = {} self.verrou = threading.Lock() def prendre(self, cles: dict): """cles : {« visiteur »: adresse, « cible »: …}. Renvoie (None, 0) si le ping peut partir, sinon (la limite atteinte, secondes à attendre).""" with self.verrou: maintenant = self.horloge() for regle, cle in cles.items(): maximum, fenetre = self.regles[regle] file = self.traces.get((regle, cle)) while file and maintenant - file[0] >= fenetre: file.popleft() if file and len(file) >= maximum: return regle, max(1, math.ceil(file[0] + fenetre - maintenant)) for regle, cle in cles.items(): self.traces.setdefault((regle, cle), deque()).append(maintenant) self._menage(maintenant) return None, 0 def _menage(self, maintenant): # Les traces expirées disparaissent : la mémoire ne garde que les dix # dernières minutes, et ne grossit pas indéfiniment. if len(self.traces) < 2000: return for cle in list(self.traces): fenetre = self.regles[cle[0]][1] file = self.traces[cle] while file and maintenant - file[0] >= fenetre: file.popleft() if not file: del self.traces[cle] # --- ICMP ------------------------------------------------------------------------- def somme_controle(donnees: bytes) -> int: """Somme de contrôle Internet (RFC 1071). Le noyau la recalcule pour une socket ICMP datagramme ; elle est faite ici pour que le paquet soit juste par lui-même.""" if len(donnees) % 2: donnees += b"\0" total = sum(struct.unpack(f"!{len(donnees) // 2}H", donnees)) while total >> 16: total = (total & 0xFFFF) + (total >> 16) return ~total & 0xFFFF def paquet_echo(sequence: int) -> bytes: """Demande d'écho ICMP (type 8). L'identifiant est remplacé par le noyau.""" entete = struct.pack("!BBHHH", 8, 0, 0, 0, sequence) controle = somme_controle(entete + CHARGE) return struct.pack("!BBHHH", 8, 0, controle, 0, sequence) + CHARGE def lire_reponse(donnees: bytes): """Numéro de séquence d'une réponse d'écho (type 0), sinon None.""" if len(donnees) < 8: return None type_, code, _, _, sequence = struct.unpack("!BBHHH", donnees[:8]) return sequence if type_ == 0 and code == 0 else None IP_RECVTTL = getattr(socket, "IP_RECVTTL", 12) # Linux IP_TTL = getattr(socket, "IP_TTL", 2) def lire_ttl(auxiliaires): for niveau, type_, donnees in auxiliaires: if niveau == socket.IPPROTO_IP and type_ == IP_TTL and len(donnees) >= 4: return struct.unpack("i", donnees[:4])[0] return None def ouvrir_socket(): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_ICMP) s.setsockopt(socket.IPPROTO_IP, IP_RECVTTL, 1) return s def pinguer(cible: str, fabrique=ouvrir_socket, horloge=time.monotonic, dormir=time.sleep): """Envoie NOMBRE demandes d'écho à `cible`. Renvoie une liste de {"seq", "ms", "ttl"} ; ms vaut None pour un paquet sans réponse à temps.""" envois = [] with fabrique() as s: for sequence in range(1, NOMBRE + 1): depart = horloge() resultat = {"seq": sequence, "ms": None, "ttl": None} try: s.sendto(paquet_echo(sequence), (cible, 0)) except OSError: resultat["erreur"] = "envoi" # réseau injoignable, par exemple else: while True: reste = depart + INTERVALLE - horloge() if reste <= 0: break s.settimeout(reste) try: donnees, auxiliaires, _, source = s.recvmsg(1024, socket.CMSG_SPACE(4)) except (socket.timeout, TimeoutError): break except OSError: break # Une réponse tardive à un paquet précédent est ignorée. if source[0] == cible and lire_reponse(donnees) == sequence: resultat["ms"] = round((horloge() - depart) * 1000, 2) resultat["ttl"] = lire_ttl(auxiliaires) break envois.append(resultat) reste = depart + INTERVALLE - horloge() if reste > 0 and sequence < NOMBRE: dormir(reste) return envois # --- HTTP --------------------------------------------------------------------------- limites = Limites() en_cours = set() # visiteurs dont un ping est en cours verrou_en_cours = threading.Lock() places = threading.BoundedSemaphore(SIMULTANES) class Gestionnaire(BaseHTTPRequestHandler): server_version = "r-no-ping" sys_version = "" # Coupe une connexion qui n'envoie rien : un client lent ne garde pas un # fil d'exécution indéfiniment. timeout = 10 def log_message(self, format, *args): # noqa: A002 # Aucune ligne de journal par requête : ni l'adresse du visiteur, ni # l'adresse visée. Traefik note déjà la requête, sans son contenu. pass def _repondre(self, code: int, corps: dict): donnees = json.dumps(corps).encode() self.send_response(code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(donnees))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(donnees) def _visiteur(self) -> str: # Traefik pose X-Real-Ip après avoir retiré celui du client (aucune # source de confiance déclarée) : le visiteur ne peut pas l'imposer. entete = (self.headers.get("X-Real-Ip") or "").strip() return entete if entete and len(entete) <= 45 else self.client_address[0] def do_GET(self): # noqa: N802 if self.path == "/sante": self._repondre(200, {"etat": "ok", "actif": ACTIF}) elif self.path.startswith("/ip/ping/api"): self._repondre(405, {"erreur": "methode"}) else: self._repondre(404, {"erreur": "introuvable"}) def do_POST(self): # noqa: N802 if self.path != "/ip/ping/api": return self._repondre(404, {"erreur": "introuvable"}) # Seules les pages de r-no.fr : le navigateur pose ces deux en-têtes, # une page ne peut pas les choisir. Un script peut les forger : c'est # un frein, et les limites ci-dessous restent le vrai garde-fou. if self.headers.get("Sec-Fetch-Site") != "same-origin" or self.headers.get("Origin") != ORIGINE: return self._repondre(403, {"erreur": "origine"}) try: longueur = int(self.headers.get("Content-Length") or "0") except ValueError: longueur = -1 if not 0 < longueur <= CORPS_MAX: return self._repondre(400, {"erreur": "invalide"}) try: corps = json.loads(self.rfile.read(longueur)) except (ValueError, UnicodeDecodeError): return self._repondre(400, {"erreur": "invalide"}) cible, erreur = lire_cible(corps.get("cible") if isinstance(corps, dict) else None) if erreur: return self._repondre(400, {"erreur": erreur}) if not ACTIF: return self._repondre(503, {"erreur": "coupe"}) code, reponse = self._traiter(cible, self._visiteur()) # La réponse part APRÈS la libération du visiteur et de la place (voir # _traiter) : un client qui relance dès qu'il l'a reçue ne doit pas # tomber sur un « en-cours » fantôme. return self._repondre(code, reponse) def _traiter(self, cible: str, visiteur: str): with verrou_en_cours: if visiteur in en_cours: return 429, {"erreur": "en-cours", "attente": NOMBRE} en_cours.add(visiteur) try: if not places.acquire(blocking=False): return 503, {"erreur": "occupe"} try: regle, attente = limites.prendre({ "pause": visiteur, "visiteur": visiteur, "cible": cible, "reseau": reseau_24(cible), "global": "", }) if regle: return 429, {"erreur": regle, "attente": attente} try: envois = pinguer(cible) except OSError as e: # Socket ICMP refusée : ping_group_range absent, par exemple. print(f"ping impossible : {e}", file=sys.stderr) return 500, {"erreur": "panne"} return 200, { "cible": cible, "source": IP_SOURCE or None, "taille": TAILLE_CHARGE + 8, "envois": envois, } finally: places.release() finally: with verrou_en_cours: en_cours.discard(visiteur) def main(): serveur = ThreadingHTTPServer(("0.0.0.0", PORT), Gestionnaire) serveur.daemon_threads = True print(f"ping de r-no.fr sur le port {PORT}, {'actif' if ACTIF else 'EN PAUSE (PING_ACTIF=0)'}", file=sys.stderr) serveur.serve_forever() if __name__ == "__main__": main()