Une connexion qui expire ne vous dit presque rien. Le bout d’en face peut n’avoir rien qui écoute, un pare-feu à trois sauts peut avaler votre SYN en silence sans produire la moindre ligne de journal, ou la route peut simplement ne pas exister — et d’où vous êtes assis, tout ça se ressemble. Vous attendez. Rien ne se passe.

Alors le ticket est écrit « le port 445 est bloqué quelque part », et c’est « quelque part » qui le fait rebondir. Votre opérateur vérifie son bord, le trouve propre, et vous le rend. Vous vérifiez le pare-feu de votre machine, vous le trouvez propre, et vous le lui rendez. Une semaine passe. Rien n’est réparé.

Le mot qui fait les dégâts, c’est « quelque part ». Il n’a pas à être là. Chaque routeur entre vous et la destination est tenu de vous dire quand c’est lui, et cette obligation est écrite dans les normes depuis 1995. Il suffit de demander de la bonne façon.

Pourquoi il faut l’écrire noir sur blanc

Parce que trop de gens dans ce métier ne savent pas faire les bases, et que ça coûte de l’argent réel à leurs employeurs chaque semaine.

Je ne parle pas des juniors. Je parle de gens avec des années derrière eux, des certifications au mur et « senior » dans le titre, dont le diagnostic d’un port qui expire s’arrête à « c’est bloqué » et ne va pas plus loin. Ils lancent ping. Ça échoue, ou ça marche, et dans les deux cas ils n’ont rien appris sur le port qu’on leur demandait. Puis le ticket part chez l’opérateur, l’opérateur le renvoie, et quinze jours du salaire de quelqu’un partent dans un fil qui aurait pu être une seule commande.

Rien de tout ça n’est difficile. Distinguer un drop d’un reject, lire le TTL d’une réponse, savoir qu’une étoile dans un traceroute ne veut rien dire toute seule — c’est un après-midi à apprendre et ça dure une carrière. Si les gens ne le savent pas, ce n’est pas qu’ils sont bêtes. C’est que personne ne l’enseigne. La formation constructeur vous apprend la console d’un constructeur. Les certifications vous apprennent l’examen. Les fondamentaux en dessous sont supposés acquis au premier jour et jamais réellement couverts, alors les gens arrivent à des postes seniors sans qu’on les leur ait jamais montrés, et à ce stade c’est gênant de demander.

Alors plutôt que de râler, le voici écrit. Ce sont les bases, détaillées, avec les commandes et un script que vous pouvez lancer aujourd’hui.

Et un mot sur pourquoi je m’y suis mis : j’ai lancé ça contre ma propre ligne en l’écrivant et j’ai trouvé trois défauts que je ne me connaissais pas. Un blocage SMB à onze sauts. Des resets SMTP forgés à un saut. Une règle de pare-feu qui existe en IPv4 et pas en IPv6. Un après-midi, sans root, sur une ligne dont je m’occupe moi-même et à laquelle je fais attention. Demandez-vous ce qui traîne sans être vu sur les réseaux que quelqu’un est payé pour exploiter.

Pourquoi le Fisher-Price OS (Windows) n’est pas ici

Deux raisons pour lesquelles il n’est pas là. L’une est technique et l’autre non, et je préfère vous donner les deux plutôt que de faire croire que tout est de l’ingénierie.

La technique, c’est qu’il ne sait pas faire ça. tracert envoie des requêtes d’écho ICMP et rien d’autre — la référence de Microsoft elle-même le décrit comme « sending Internet Control Message Protocol (ICMP) echo Request or ICMPv6 messages to the destination with incrementally increasing time to live (TTL) field values », et il n’y a de paramètre de port nulle part dans sa syntaxe. pathping est le même outil avec des statistiques boulonnées dessus. Test-NetConnection vous dira qu’un port TCP est ouvert ou fermé et absolument rien sur la distance d’où la réponse est venue. Aucun d’eux ne peut sonder le port qui vous intéresse à une distance choisie, ce qui est toute la méthode de ce billet.

Et vous ne pouvez pas non plus vous en tirer par un script. Poser le TTL sur un socket est assez facile en .NET, mais relire l’erreur ICMP est la moitié difficile, et il n’y a pas d’équivalent de l’astuce sur laquelle ce billet s’appuie — aucun moyen de faire rapporter l’erreur par le noyau sur le socket ordinaire qui l’a causée. Il reste le socket brut, et la documentation de Microsoft dit elle-même que « only members of the Administrators group can create sockets of type SOCK_RAW ». Donc la voie non privilégiée n’existe pas et la voie privilégiée veut un jeton d’administrateur local. Vous voilà parti sur des téléchargements tiers avant même d’avoir commencé.

L’autre raison, c’est que je m’en moque, et je préfère le dire que l’habiller. Le nom n’est pas une pique gratuite non plus, c’est une description. C’est le système qu’on vous colle quand vous n’en avez jamais eu d’autre, il vous cache la machine par choix de conception et non par accident, et à la seconde où vous voulez poser une question précise au réseau, il s’avère que l’outil n’a jamais été construit — parce qu’on n’attendait pas des gens pour qui il est fait qu’ils demandent. Trente ans après, tracert ne sait toujours pas viser un port.

Ce n’est pas un vrai système d’exploitation pour les vrais informaticiens, et si c’est le seul que vous ayez jamais utilisé alors vous ne faites pas ce métier au niveau pour lequel ce billet est écrit. Je sais que ça passe mal. Je n’écris pas pour être aimé, j’écris ce que je mesure pour des gens qui mesurent des choses, et je me soucie peu que quelqu’un qui n’a jamais fait tourner autre chose préfère que je le dise autrement.

La liste des outils est l’argument, pas l’opinion. Chaque Unix du tableau plus bas vous laissera poser une limite de sauts et choisir un protocole, quatre d’entre eux depuis une seule commande, et Linux fera toute la mesure sans même un sudo. Ce n’est pas parce qu’ils sont plus durs à utiliser. C’est parce qu’ils ont été bâtis par des gens qui attendaient de celui qui est au clavier qu’il veuille savoir des choses. Un système d’exploitation dont le diagnostic s’arrête à ping vous a dit clairement ce qu’il pense de la personne qui s’en sert, et trente ans de gens qui l’ont accepté, voilà comment on a fini avec une industrie incapable de localiser un paquet perdu.

Alors je ne le fais pas tourner, je ne l’ai pas fait tourner sérieusement depuis des années, et je ne vais pas lui écrire une section à lui pour avoir l’air équilibré sur un manque qui est réel. Tout ce sur quoi je bâtis et tout ce depuis quoi il vaut la peine de mesurer est de l’Unix, et c’est là que vit ce billet.

Si la machine cassée se trouve faire tourner le Fisher-Price OS (Windows), ça ne change rien à la méthode. Prenez un shell sur autre chose — une VM Linux, un Mac, un Raspberry Pi sur le même switch — et faites monter la limite de sauts vers elle. La mesure se moque totalement de ce que fait tourner le bout d’en face. Elle ne s’occupe que de ce qu’il y a entre les deux.

Le TTL est un budget de sauts, et chaque routeur vous doit un reçu

L’en-tête IPv4 a un champ Time to Live de 8 bits. Le nom est un reste : il était spécifié en secondes, et plus rien ne le traite comme des secondes depuis des décennies. La RFC 1812 a tranché le débat en 1995 et a rendu la lecture en nombre de sauts normative :

Each router (or other module) that handles a packet MUST decrement the TTL by at least one, even if the elapsed time was much less than a second. Since this is very often the case, the TTL is effectively a hop count limit on how far a datagram can propagate through the Internet.

Puis la partie qui compte ici, tirée de la même section :

If the TTL is reduced to zero (or less), the packet MUST be discarded, and if the destination is not a multicast address the router MUST send an ICMP Time Exceeded message, Code 0 (TTL Exceeded in Transit) message to the source.

C’est un MUST. Pas une politesse, pas une suggestion. La RFC 792 de 1981 disait seulement qu’une passerelle « may also notify the source host » ; treize ans plus tard l’exigence a été resserrée, et la RFC 1812 dit pourquoi en toutes lettres :

ICMP Time Exceeded messages are required because the traceroute diagnostic tool depends on them.

IPv6 a laissé tomber le faux-semblant et a renommé le champ. La RFC 8200 l’appelle Hop Limit — « 8-bit unsigned integer. Decremented by 1 by each node that forwards the packet » — et le message d’expiration est devenu le type 3, code 0 d’ICMPv6, « hop limit exceeded in transit ».

Lisez ça comme un instrument plutôt que comme une règle et ça dit quelque chose d’utile. Chaque routeur du chemin est une balise que vous pouvez adresser par la distance. Posez la limite de sauts à 4 et le quatrième routeur s’identifie. Vous n’avez pas besoin de connaître la topologie, vous n’avez besoin d’accès à rien, et vous n’avez pas besoin de la coopération de l’opérateur. Il vous faut un paquet par saut.

Traceroute fait exactement ça depuis la fin des années 1980. Ce qu’il fait mal, c’est justement la chose qui vous intéresse, parce que par défaut il sonde des ports UDP dans la plage 33434, un port que personne ne filtre et que personne ne sert, donc il vous renseigne sur un chemin que rien de réel n’emprunte jamais. Un traceroute propre vers une machine que vous ne joignez pas en TCP/445 prouve seulement que l’UDP/33434 y arrive. Ce n’était pas la question.

Alors sondez le port qui vous intéresse.

Lisez ce qui est revenu, pas si quelque chose est revenu

Avant de compter les sauts, regardez ce que fait le bout d’en face quand vous l’atteignez avec une limite de sauts normale. Il y a cinq réponses distinctes et les gens les réduisent régulièrement à une seule.

Ce qui revientCe que ça veut direQui l’a envoyé
SYN-ACK, la connexion s’ouvrele port est ouvertl’hôte, ou quelque chose qui répond pour lui
RST TCPrefusé activementl’hôte sans rien qui écoute, ou un équipement configuré pour rejeter
ICMP 3/13, communication administrativement interditeun équipement refuse par politique et le ditcet équipement — son adresse source est votre réponse
ICMP 3/1, 3/2, 3/3hôte, protocole ou port injoignablele dernier routeur, ou l’hôte
rien du toutquelqu’un jette en silenceinconnu, alors allez le mesurer

La troisième ligne est celle pour laquelle il vaut la peine de changer ses habitudes. Quand un pare-feu est configuré pour rejeter plutôt que jeter, il met sa propre adresse dans le champ source de l’ICMP et vous livre le coupable gratuitement. Sur Linux, c’est ce que produit nft ... reject with icmpx admin-prohibited, et ce que iptables -j REJECT --reject-with icmp-admin-prohibited a toujours produit. La plupart des outils le jettent et affichent « filtré ». nmap --reason vous le montrera. Le script plus bas aussi.

Les lignes deux et cinq sont l’échec intéressant. Un rejet silencieux est une décision de politique de ne rien vous dire, et comme c’est le défaut sur quasiment tous les pare-feu commerciaux, c’est celui que vous rencontrerez vraiment. Attendez-vous au silence.

La ligne deux mérite la méfiance aussi. Un reset n’est pas la preuve que l’hôte l’a envoyé, et j’y reviendrai avec un exemple réel, parce que j’en ai trouvé un sur ma propre ligne en écrivant ceci.

Marchez sur le port qui vous intéresse, deux fois

La méthode, c’est deux passages et un diff. C’est tout.

  1. Faites monter la limite de sauts de 1 à 20 avec le protocole et le port exacts qui échouent, et notez quel routeur répond à chaque saut.
  2. Faites pareil avec quelque chose qui marche — idéalement le même hôte et un port qui s’ouvre.
  3. Le saut où les réponses s’arrêtent au premier passage et continuent au second est l’équipement qui vous jette. Le second passage vous donne son adresse.

Pourquoi les réponses s’arrêtent au lieu de changer : un routeur applique sa liste d’accès entrante avant de faire quoi que ce soit d’autre du paquet, donc si la politique dit « jeter », le paquet est parti avant que le chemin d’acheminement ne regarde la limite de sauts, aucun Time Exceeded n’est produit, et l’équipement ne signe jamais ce qu’il a fait. À ce titre, le silence commence au saut fautif, pas après lui.

J’ai écrit un petit outil pour la marche, parce qu’aucun des outils natifs ne le fait de façon portable. Il pose IP_TTL (ou IPV6_UNICAST_HOPS) sur un socket ordinaire, se connecte, et relit l’erreur ICMP. Sur Linux, IP_RECVERR rapporte cette erreur sur le socket même qui l’a provoquée, ce qui veut dire que le tout tourne sans privilèges — pas de root, pas de sockets bruts, pas de capacités. Sur macOS, les BSD et Solaris, le noyau ne vous remet pas l’ICMP de cette façon, alors il se rabat sur un socket brut et demande root.

hopfind.py — le marcheur de TTL hopfind.py · 11 kB

C’est 279 lignes de bibliothèque standard et rien d’autre, et le tout est imprimé à la fin de ce billet si vous préférez le lire que le télécharger.

python3 hopfind.py example.net 445      # the port under suspicion
python3 hopfind.py example.net 443      # the reference run
python3 hopfind.py example.net 53 --proto udp
python3 hopfind.py 2001:db8::1 443 -6

Utilisez le même protocole pour les deux passages. Comparer une trace TCP à une trace ICMP, c’est comparer deux chemins, parce que la répartition de charge hache le quintuplet et que l’ICMP n’a pas de ports à hacher. Même hôte, même protocole, port différent : voilà la comparaison honnête.

Tout ce qui suit est de la sortie réelle de ma propre ligne le 28 août 2026, lancée en utilisateur non privilégié sur Fedora. Chaque adresse dedans a été réécrite dans les plages de documentation — RFC 5737 pour IPv4, RFC 3849 pour IPv6, avec l’unique identifiant d’interface modifié lui aussi. Donc 198.51.100.x est mon propre routeur et mon FAI, 203.0.113.x est du transit et du peering, 192.0.2.x est le réseau distant, et 2001:db8::/32 est tout le chemin IPv6. La structure est préservée partout : mêmes frontières de préfixe, mêmes formes de partie hôte, même nombre de réseaux distincts. Les numéros de sauts, les temps, les types ICMP et le saut qui s’est tu sont exactement tels que mesurés.

Un hôte, trois ports, trois défauts différents

Même destination partout : une machine sur l’internet public, à quatorze sauts, avec le 443 ouvert. D’abord le passage de référence.

$ python3 hopfind.py 192.0.2.4 443 --max 15
walking to 192.0.2.4  TCP/443  hop limit 1-15
  1  198.51.100.254                              0.3 ms  ICMP 11/0 time exceeded in-transit
  2  198.51.100.133                             28.8 ms  ICMP 11/0 time exceeded in-transit
  3  *
  4  198.51.100.153                              6.3 ms  ICMP 11/0 time exceeded in-transit
  5  *
  6  203.0.113.240                              16.5 ms  ICMP 11/0 time exceeded in-transit
  7  203.0.113.188                              16.5 ms  ICMP 11/0 time exceeded in-transit
  8  203.0.113.185                              16.5 ms  ICMP 11/0 time exceeded in-transit
  9  203.0.113.15                               15.6 ms  ICMP 11/0 time exceeded in-transit
 10  203.0.113.125                              16.1 ms  ICMP 11/0 time exceeded in-transit
 11  192.0.2.31                                 26.6 ms  ICMP 11/0 time exceeded in-transit
 12  *
 13  *
 14  192.0.2.4                                  19.5 ms  connected

Verdict: TCP/443 is open. It answered at hop 14.

Notez les sauts 3, 5, 12 et 13. Quatre routeurs sur un chemin qui marche manifestement n’ont rien dit, parce que beaucoup d’équipements sont configurés pour ne pas produire d’ICMP pour eux-mêmes, ou le limitent en débit très fort. Une étoile n’est pas la preuve d’un pare-feu. Retenez ça si rien d’autre. Le signal n’est jamais la présence d’étoiles dans un seul passage ; c’est l’endroit où deux passages cessent d’être d’accord.

Maintenant le même hôte sur le 445, qui expire d’ici.

$ python3 hopfind.py 192.0.2.4 445 --max 15
walking to 192.0.2.4  TCP/445  hop limit 1-15
  1  198.51.100.254                              0.3 ms  ICMP 11/0 time exceeded in-transit
  2  198.51.100.133                              8.0 ms  ICMP 11/0 time exceeded in-transit
  3  *
  4  198.51.100.153                              6.4 ms  ICMP 11/0 time exceeded in-transit
  5  203.0.113.76                                5.6 ms  ICMP 11/0 time exceeded in-transit
  6  203.0.113.240                              15.9 ms  ICMP 11/0 time exceeded in-transit
  7  203.0.113.188                              15.8 ms  ICMP 11/0 time exceeded in-transit
  8  203.0.113.185                              16.6 ms  ICMP 11/0 time exceeded in-transit
  9  203.0.113.15                               15.9 ms  ICMP 11/0 time exceeded in-transit
 10  203.0.113.125                              15.0 ms  ICMP 11/0 time exceeded in-transit
 11  *
 12  *
 13  *
 14  *
 15  *

Verdict: answers stop after hop 10 (203.0.113.125).
         Whatever swallows TCP/445 is hop 11.
         Walk a port that works and read off the address at hop 11.

Le saut 11 a répondu au passage 443 en 26,6 ms et n’a rien dit du tout au passage 445. Même boîte, même chemin, les dix mêmes routeurs devant. Recoupez avec le passage qui marche et le saut 11 a un nom : 192.0.2.31. Voilà l’équipement qui jette SMB, à trois sauts de la destination et à huit sauts au-delà du bord de mon opérateur. Pas le mien, et pas celui de mon FAI.

Le saut 5 fait la démonstration sur les étoiles dans l’autre sens. Il était une étoile au passage 443 et a répondu au passage 445 — l’inverse du défaut. Ça peut être de la limitation de débit ICMP, ou ça peut être les deux passages qui prennent des chemins différents à travers un répartiteur de charge. Je ne sais pas lequel, et vous non plus. Répétez les deux passages avant de croire l’un ou l’autre.

Deux marches de limite de sauts vers le même hôte, et le saut où elles cessent d'être d'accordDeux marches vers le même hôte, et le saut où elles cessent d'être d'accordUne sonde par limite de sauts. Une cellule ombrée veut dire que ce routeur a renvoyé ICMP Time Exceeded et s'est nommé.le routeur a répondurien n'est revenusilencieux à partir d'ici1234567891011121314limite de sauts posée sur la sondeTCP/443référence, il s'ouvre••*•*••••••**✓s'ouvreTCP/445à l'essai, il expire••*•••••••****le saut 11 a répondu à une marche et pas à l'autreLes sauts 3, 5, 12 et 13 n'ont rien dit sur un chemin qui marche manifestement, donc une étoile seule ne veut rien dire.Le saut 11 a répondu à la marche de référence en 26,6 ms et n'a jamais répondu à la marche de test.C'est la frontière, et la marche de référence est ce qui lui donne une adresse : 192.0.2.31.
Les deux mêmes passages, côte à côte. La seule cellule qui compte est le saut 11, et ce qui compte à son sujet est le désaccord : il a répondu à un passage et pas à l’autre.

Puis le port 25. Celui-là m’a arrêté.

$ python3 hopfind.py 192.0.2.4 25 --max 15
walking to 192.0.2.4  TCP/25  hop limit 1-15
  1  192.0.2.4                                   0.4 ms  TCP reset

Verdict: a reset came back to a probe with a hop limit of 1.
         Nothing more than one hop away can have sent it, so check the reply
         TTL before you believe the host did.

Une limite de sauts de 1 veut dire que le paquet est mort à mon propre routeur. Il a fait un saut. Il ne peut pas en avoir fait quatorze. Et pourtant un reset TCP est revenu en 0,4 ms avec l’adresse de la destination dessus, et mon noyau a docilement rapporté « connexion refusée ». Sans la limite de sauts posée, j’aurais lu ça comme « le bout d’en face n’a pas de serveur de messagerie » et j’aurais fermé le ticket.

Quelque chose à un saut forge des resets pour le SMTP sortant et les signe avec l’adresse de la destination. Bloquer le 25 sortant est une chose parfaitement ordinaire pour un routeur grand public ou un FAI, et le faire par un reset plutôt qu’un rejet est sans doute la version polie, mais le reset porte l’adresse de quelqu’un d’autre et je n’avais aucune idée que la mienne le faisait. C’est la limite de sauts qui l’a attrapé, et rien d’autre dans la réponse ne l’aurait fait.

À un près : où siège la liste d’accès dans le pipeline

Le verdict ci-dessus dit que le jeteur est le saut 11 parce que les réponses se sont arrêtées après le saut 10. Attention à cette arithmétique, parce qu’elle dépend de l’ordre dans lequel l’équipement fautif fait deux travaux.

La plupart des équipements appliquent la politique entrante d’abord et le contrôle de la limite de sauts ensuite. Le refus frappe, le paquet est jeté, et aucun Time Exceeded n’est jamais produit, donc l’équipement n’apparaît jamais et le silence commence à son propre numéro de saut. C’est le cas ci-dessus. C’est aussi le cas courant.

Certaines plateformes traitent l’expiration de la limite de sauts dans le chemin rapide, avant l’évaluation de la politique. Là, l’équipement répond à la sonde qui lui est adressée et ne jette que les sondes visant au-delà de lui, donc il apparaît normalement et le silence commence un saut plus loin.

Pourquoi le saut fautif n'apparaît d'habitude pas : la politique est évaluée avant le contrôle de la limite de sautsÀ l'intérieur du saut qui vous jette, l'ordre de deux contrôles décide de ce que vous voyezVotre sonde arrive avec un saut restant sur son budget, et elle correspond à une règle qui dit « refuser ».Politique d'abord — presque tous les pare-feu, et chaque cas mesuré dans ce billetle routeur au saut Nsonde entrantepolitique entrantecontrôle limite de sautsacheminerjetée avant que quoi que ce soit ne regarde la limite de sautsAucun ICMP n'est produit, donc ce routeur ne signe jamais le rejet.Votre marche se tait au saut N.Expiration d'abord — certaines plateformes la traitent dans le chemin rapidele routeur au saut Nsonde entrantecontrôle limite de sautspolitique entranteacheminerexpirée, donc ICMP 11/0 repart avec l'adresse de ce routeurIl répond pour lui-même et n'avale que les sondes visant plus loin.Votre marche se tait à partir du saut N+1.
Pourquoi le saut qui jette reste d’habitude invisible. Sur presque tous les pare-feu, le refus est évalué en premier, donc la sonde est partie avant que le chemin d’acheminement ne remarque que la limite de sauts a expiré et aucun ICMP n’est jamais produit. Sur les équipements qui traitent l’expiration dans le chemin rapide, le même équipement répond à la sonde qui lui est adressée et n’avale que celles visant plus loin.

La lecture honnête de « les réponses s’arrêtent après le saut N » est donc : le jeteur est le saut N+1 s’il a jeté votre sonde avant de remarquer que le budget était épuisé, ou le saut N lui-même s’il a répondu à la sonde qui lui était adressée et avalé tout ce qui visait plus loin. Deux équipements voisins, et le passage qui marche les nomme tous les deux. Citez les adresses, pas le numéro de saut — un numéro de saut ne veut rien dire pour la personne qui lit votre ticket, qui compte depuis un autre endroit.

Le TTL de la réponse vous dit qui a vraiment répondu

Le reset SMTP ci-dessus a été attrapé par la limite de sauts à l’aller. Il y a un second contrôle, indépendant, disponible dans chaque réponse qui revient, et il ne coûte rien.

Les valeurs initiales de TTL ne sont pas normalisées, mais en pratique il y en a trois :

Part deÉmetteur typique
64Linux, macOS, les BSD, illumos, la plupart des hôtes
255Cisco IOS, Junos, Solaris, le trafic propre de la plupart des équipements réseau
128le Fisher-Price OS (Windows), dont vous avez encore besoin pour lire une réponse issue de l’un d’eux

Soustrayez le TTL reçu de la valeur immédiatement au-dessus et vous avez le nombre de sauts au retour. Une réponse qui arrive avec un TTL de 50 est partie de 64 et a fait 14 sauts. Une qui arrive à 250 est partie de 255 et en a fait 5. ping l’affiche sans qu’on le lui demande :

ping -c1 192.0.2.4          # ttl=50 → 14 hops away
tcpdump -n -v 'icmp'        # -v prints the ttl of every packet it shows

Deux choses en découlent. Les deux sont gratuites.

Une réponse dont le nombre de sauts au retour ne correspond pas aux autres réponses de l’hôte n’a pas été envoyée par l’hôte. Si une réponse d’écho d’un serveur revient de 14 sauts et que le RST sur le port 25 revient d’un saut, c’est un boîtier intermédiaire qui a écrit le RST. Même astuce que la section précédente, par l’autre bout, et ça marche même quand vous ne pouvez pas poser le TTL sortant.

Une réponse partie de 255 vient d’un équipement réseau, pas d’un serveur. Utile quand vous cherchez à savoir si la chose qui vous rejette est l’hôte ou le routeur devant lui.

Pour voir le champ sur du TCP plutôt que de l’ICMP il vous faut une capture, et le filtre mérite d’être appris par cœur :

# every hop-limit expiry coming back to you, IPv4 and IPv6
tcpdump -n -v 'icmp[icmptype] == 11 or icmp6[icmp6type] == 3'

# who is resetting you, and from how far
tcpdump -n -v 'tcp[tcpflags] & tcp-rst != 0'

tcpdump affiche l’expiration comme ICMP time exceeded in-transit — la même formule que les normes, et le même événement que celui qui apparaît en « TTL expired in transit » sur les plateformes qui le formulent ainsi.

La même astuce avec les outils natifs, sur cinq Unix

Si vous préférez ne pas lancer de script, les outils natifs feront l’essentiel. Ils sont simplement plus en désaccord entre eux que vous ne le penseriez. -P en particulier veut dire trois choses différentes selon le traceroute que vous tenez, et l’une d’elles ruinera votre test en silence.

Linux (traceroute 2.1.x)macOS / FreeBSDOpenBSDNetBSDSolaris 11
Sondes TCP-T-P tcpinutilisablenonnon
Sondes ICMP-I-I-I-I-I
UDP vers un port fixe-U -p N-e -p Nnonnonnon
port de destination-p N (constant pour TCP)-p N (s’incrémente sans -e)-p N (s’incrémente)-p N (s’incrémente)-p N (s’incrémente)
ce que veut dire -Ppas utiliséprotocole de sondeprotocole numérique, « will not work reliably for most protocols »pose DF et sonde le MTU du cheminpause entre sondes, en secondes
demande des privilègesoui, pour -T et -Iouiouiouioui

Chacun d’eux a besoin de sockets bruts, donc chacun a besoin de privilèges — même si macOS et les BSD livrent en général traceroute setuid root, donc vous n’aurez peut-être pas à taper sudo devant. Linux non, et Fedora non plus, ce qui est la moitié de la raison d’être du script ci-dessus.

Trois pièges dans ce tableau, et j’ai vu les trois gâcher un après-midi.

Sur les BSD et macOS, -p est un port de base qui s’incrémente à chaque sonde. Donc traceroute -P tcp -p 445 host teste le 445, puis le 446, puis le 447, et au saut 10 vous posez une question sur un port dont personne n’a jamais entendu parler. Il vous faut -e en plus, que la page de manuel appelle le mode d’évasion de pare-feu et qui veut en fait juste dire « garde le port immobile » :

sudo traceroute -P tcp -e -p 445 example.net     # macOS, FreeBSD

Sur Linux, -p tout court fait la même chose pour la méthode UDP par défaut et il vous faut -U -p pour un port UDP constant. Pour TCP, -T -p est déjà constant — la page de manuel est explicite : « for TCP and others specifies just the (constant) destination port to connect ».

sudo traceroute -T -p 445 example.net            # Linux
sudo traceroute -U -p 53  example.net            # Linux, UDP/53 specifically

Sur Solaris et NetBSD il n’y a aucun moyen de fixer le port, et sur Solaris -P est une pause en secondes, donc une ligne de commande copiée depuis Linux s’exécutera sans erreur et ne mesurera rien de ce que vous avez demandé. Solaris n’a pas non plus de mode de sonde TCP. C’est le cas où vous voulez le script.

Redox est le cas à part et mérite une phrase parce que j’attends la question. Toute sa boîte à outils réseau est netutils — dns, ifconfig, nc, ping, telnetd, wget. Pas de traceroute, pas de tcpdump, rien pour capturer. Si une machine Redox est un bout du problème, mesurez depuis l’autre bout et pointez la marche vers elle.

mtr mérite aussi une mention, parce qu’il fait la partie « répéter et moyenner » que les tableaux ci-dessus vous font faire à la main :

sudo mtr -T -P 445 --report --report-cycles 20 example.net

Lancez ça contre le port qui échoue puis contre un qui marche, côte à côte. Même méthode, sortie plus jolie.

Rien de tout ça ne marche si quelqu’un bloque l’ICMP

Chaque mesure de ce billet est faite d’erreurs ICMP qui me reviennent. Bloquez-les et tout le diagnostic s’éteint — et pas mal d’autres choses avec.

Bloquer l’ICMP en bloc est encore traité comme une posture de sécurité par endroits. Ça n’en est plus une défendable depuis les années 1990. Les attaques que c’est censé arrêter étaient le ping of death et le smurf, tous deux corrigés dans les piles plutôt qu’à la frontière, et tous deux corrigés avant la naissance de certains des ingénieurs qui répètent encore le conseil. Ce que le blocage global arrête aujourd’hui, c’est le diagnostic. Rien d’autre.

La RFC 1812 ne laisse pas de place à l’interprétation là-dessus : Time Exceeded est un MUST, et la norme dit que sa raison d’être est que traceroute en dépend. Jetez-le et vous avez cassé un outil que le document d’exigences des routeurs de l’internet nomme lui-même comme la justification de l’existence du message.

La découverte du MTU de chemin est celle qui coûte cher. Elle a besoin que l’ICMP 3/4, fragmentation needed, revienne à l’émetteur. Filtrez-le et vous obtenez le défaut que tout ingénieur réseau a poursuivi au moins une fois, celui où la poignée de main se termine, où les petits transferts marchent, et où tout ce qui porte un paquet de taille pleine se bloque pour toujours. SSH se connecte et scp cale. La page charge et l’image n’arrive jamais. Rien dans les journaux. Rien à greper.

En IPv6, ça cesse d’être une affaire de goût. La RFC 4890 §4.3.1 liste les messages qu’un pare-feu ne doit pas jeter :

  • Destination Unreachable (Type 1) - All codes
  • Packet Too Big (Type 2)
  • Time Exceeded (Type 3) - Code 0 only
  • Parameter Problem (Type 4) - Codes 1 and 2 only

et sur Packet Too Big elle est franche sur la conséquence : « Effectively, parts of the Internet will become inaccessible. » Les routeurs IPv6 ne fragmentent pas. Si Packet Too Big ne peut pas atteindre l’émetteur, il n’y a pas de voie de récupération.

Le contrôle est une limitation de débit, pas un rejet. Autorisez les types 3 et 11 en entrée, comptez-les, plafonnez-les à quelque chose comme cent par seconde, journalisez ce qui dépasse le plafond, et vous avez gardé le diagnostic, gardé la découverte du MTU de chemin en état de marche, et gardé chaque miette de la protection que la règle globale était censée apporter au départ. Restreindre la quantité que vous acceptez d’une chose est un contrôle. Refuser la totalité et appeler ça du durcissement, c’est juste refuser d’être mesuré.

Pour quiconque exploite un parc pour des clients : si la ligne de votre client jette les erreurs ICMP, vous lui avez retiré la capacité de prouver dans quel réseau se situe un défaut — et la vôtre avec. La prochaine fois qu’un défaut siégera entre deux opérateurs qui disent tous les deux être propres, ce sera la facture de la politique.

Sauf l’écho. Jetez-le.

Tout ce qui précède parle des erreurs ICMP. L’écho est un animal différent, et c’est la seule partie du protocole que je retirerais du fil à la frontière.

Regardez ce que la norme exige de lui. En IPv4, la RFC 792 dit d’une requête d’écho que « the data received in the echo message must be returned in the echo reply message ». IPv6 est plus franc encore — la RFC 4443 définit le champ comme « zero or more octets of arbitrary data » puis exige qu’il « MUST be returned entirely and unmodified in the ICMPv6 Echo Reply message ».

Lisez ça en attaquant plutôt qu’en exploitant. La norme oblige chaque hôte de la planète à accepter un bloc d’octets que vous choisissez et à vous le rendre tel quel. Ce n’est pas un effet de bord. C’est un canal bidirectionnel, à charge utile arbitraire, imposé par la norme, qui circule sur un protocole que la plupart des pare-feu laissent passer sans inspecter et que la plupart des systèmes de journalisation enregistrent comme un compteur de paquets plutôt que comme du contenu.

Ça fait trente ans qu’on bâtit des tunnels dessus. Loki l’a fait dans Phrack 49 en 1996. Ptunnel fera passer une session TCP entière dans du ping et est à un paquet de distance depuis deux décennies. Si votre politique de sortie est « bloquer tout, autoriser l’ICMP parce que l’équipe réseau en a besoin », vous n’avez pas de politique de sortie. Vous avez un VPN avec des étapes en plus, et le trafic sort en ayant l’air de quelqu’un qui teste si l’internet marche.

La RFC 4890 n’est pas d’accord avec moi, et il vaut mieux le dire franchement que de ne citer que la moitié qui m’arrange. Le §4.3.1 met Echo Request et Echo Response dans la même liste des messages à ne pas jeter que les erreurs. Puis lisez la justification donnée :

For Teredo tunneling [RFC4380] to IPv6 nodes on the site to be possible, it is essential that the connectivity checking messages are allowed through the firewall.

La raison invoquée pour garder l’écho ouvert, c’est que quelqu’un a besoin de bâtir un tunnel à travers votre pare-feu avec. C’est mon argument, écrit par les gens qui défendent le contraire.

La politique est donc étroite, pas globale :

# transit rules. permit the errors, drop the ping
ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem } \
    limit rate 100/second accept
ip protocol icmp icmp type { echo-request, echo-reply } drop

ip6 nexthdr ipv6-icmp icmpv6 type { destination-unreachable, packet-too-big, \
    time-exceeded, parameter-problem } limit rate 100/second accept
ip6 nexthdr ipv6-icmp icmpv6 type { echo-request, echo-reply } drop

En IPv6, ne portez pas ce motif sur une chaîne lien-local ou hôte sans garder la découverte de voisins. Les types 133 à 137 — de nd-router-solicit à nd-redirect — sont la façon dont IPv6 fait le travail qu’ARP fait en IPv4. Jetez-les et le segment cesse de marcher en quelques minutes, et ça n’aura pas l’air d’un défaut de pare-feu. Filtrez l’écho à la frontière, pas sur le fil entre un hôte et son propre routeur.

Qu’est-ce que ça vous coûte ? ping à travers la frontière, et rien d’autre. Tout ce qui est dans ce billet continue de marcher, parce que pas une seule mesure ici n’envoie de requête d’écho. hopfind.py marche en TCP et en UDP et lit les erreurs qui reviennent ; traceroute -T et -U font pareil. La découverte du MTU de chemin a besoin de Packet Too Big, qui est une erreur. L’astuce du TTL inverse marche sur n’importe quelle réponse, et une poignée de main TCP vous en donnera une. La seule chose que vous perdez est l’outil le moins instructif de la boîte, et tout ce billet est un argument sur le fait que s’arrêter à ping est le problème de départ.

Ma propre ligne fait déjà exactement ça, même si je doute que ce soit voulu. ping vers ma passerelle par défaut donne 100 % de perte, et l’ICMP Time Exceeded de cette même passerelle revient en 0,3 ms — comme le montre chaque trace de ce billet. Écho fermé, erreurs ouvertes. Celui qui a livré ce micrologiciel a trouvé la bonne réponse, et je n’ai découvert qu’il l’avait fait qu’en allant regarder.

La même règle, écrite une seule fois

Voici le défaut que je ne m’attendais pas à trouver chez moi. Un résolveur DNS public, parcouru en TCP/443 sur les deux familles, à quelques minutes d’intervalle.

$ python3 hopfind.py 192.0.2.53 443 --max 10
walking to 192.0.2.53  TCP/443  hop limit 1-10
  1  *
  2  *
  3  *
  4  *
  5  *
  6  *
  7  *
  8  *
  9  *
 10  *

Verdict: nothing answered at all, not even the first hop.

Rien du tout. Pas un seul saut. Mon propre routeur n’a même pas rapporté l’expiration qu’il a forcément produite, la même expiration qu’il a rapportée en 0,3 ms pour toutes les autres marches de ce billet — donc le rejet se produit au saut 1, avant même que le contrôle de la limite de sauts ne s’exécute, et le saut 1 est le mien.

Le chemin va bien, ce que la même destination prouve en UDP :

$ python3 hopfind.py 192.0.2.53 53 --proto udp --max 10
  1  198.51.100.254                              0.3 ms  ICMP 11/0 time exceeded in-transit
  2  198.51.100.133                              5.4 ms  ICMP 11/0 time exceeded in-transit
  3  *
  4  198.51.100.167                             14.5 ms  ICMP 11/0 time exceeded in-transit
  5  203.0.113.50                                6.3 ms  ICMP 11/0 time exceeded in-transit
  6  203.0.113.174                               5.7 ms  ICMP 11/0 time exceeded in-transit
  7  203.0.113.201                               6.4 ms  ICMP 11/0 time exceeded in-transit
  8  *

Sept sauts de réponses propres vers la même adresse. Ce n’est donc pas du routage et ce n’est pas la destination — quelque chose sur ma ligne jette le TCP vers cet hôte et laisse passer l’UDP.

Puis le même résolveur, même port, en IPv6 :

$ python3 hopfind.py 2001:db8:53::53 443 -6 --max 10
walking to 2001:db8:53::53  TCP/443  hop limit 1-10
  1  2001:db8:1:ee:beef:abcd:fec0:2a30           0.5 ms  ICMP 3/0 hop limit exceeded in-transit
  2  2001:db8:1::15c                            24.6 ms  ICMP 3/0 hop limit exceeded in-transit
  3  *
  4  2001:db8:2:200::50                          5.5 ms  ICMP 3/0 hop limit exceeded in-transit
  5  2001:db8:2:2::4                             5.7 ms  ICMP 3/0 hop limit exceeded in-transit
  6  2001:db8:2:991::                           16.6 ms  ICMP 3/0 hop limit exceeded in-transit
  7  2001:db8:53::53                             5.7 ms  connected

Verdict: TCP/443 is open. It answered at hop 7.

Tout droit. Sept sauts, sans histoire. Même service, même port, même intention, et la règle n’existe que sur une seule famille d’adresses.

Celui qui a écrit cette règle l’a écrite pour IPv4 et n’a jamais écrit la jumelle. Je n’ai aucune idée de ce qu’elle était censée accomplir — je devine quelque chose autour de garder le DNS local — mais quoi que ce fût, ça l’accomplit sur la moitié du trafic depuis que cette ligne a de l’IPv6. Si elle était là pour une raison, elle ne marche pas. Si elle ne l’était pas, elle ne devrait pas être là.

Voilà la version de tous les jours du problème de la double pile, et c’est bien plus courant que les débats sur l’opportunité de déployer IPv6 tout court. Deux recueils de règles. Un seul entretenu.

Le script, en entier

Aucune dépendance, aucune installation, rien que la bibliothèque standard. Python 3.6 ou plus, et sur Linux aucun privilège.

Les deux classes sont toute l’histoire de la portabilité. ErrorQueue est la voie Linux : armez IP_RECVERR sur le socket, et après l’échec de la sonde, lisez MSG_ERRQUEUE et tirez l’adresse du routeur de la structure sock_extended_err à laquelle le noyau l’accole. RawIcmp est partout ailleurs : ouvrez un socket ICMP brut, lisez ce qui arrive, prenez l’adresse source sur le paquet. Le premier n’a besoin de rien, le second a besoin de root, et le reste du programme se moque de ce qu’on lui a remis.

Un détail mérite d’être signalé parce que c’est la différence entre une bonne réponse et une réponse plausible. Dans probe(), la file d’erreurs est vidée avant que SO_ERROR ne soit consulté. Une erreur ICMP atteint un socket TCP sous forme d’un simple errno — un ICMP 3/3 port unreachable arrive en ECONNREFUSED, exactement comme un vrai reset — donc regarder SO_ERROR d’abord aurait rapporté le reset SMTP forgé plus haut dans ce billet comme un refus honnête du bout d’en face. Lisez la file d’abord et ee_origin vous dit qu’un routeur a parlé.

#!/usr/bin/env python3
"""hopfind - work out how many hops away the thing blocking your port is.

Walks the IPv4 TTL, or the IPv6 hop limit, up from 1 and records which router
answers at each step - using the protocol and port you actually care about
instead of traceroute's default UDP high ports.

Run it twice. Once against something that works, once against the port that
does not. The hop where the answers stop is the device dropping you, and the
run that works gives you its address.

    python3 hopfind.py example.net 445            # the port under suspicion
    python3 hopfind.py example.net 443            # the reference run
    python3 hopfind.py example.net 53 --proto udp
    python3 hopfind.py 2001:db8::1 443 -6

On Linux this needs no privileges at all: IP_RECVERR and IPV6_RECVERR hand the
ICMP errors back on the ordinary socket that caused them. On macOS, the BSDs
and Solaris the errors have to be read off a raw ICMP socket, which means root.

Written for https://blogs.damiendye.uk/networking/how-far-away-is-the-firewall/
Public domain. Do what you like with it.
"""

import argparse
import errno
import os
import select
import socket
import struct
import sys
import time

# Linux socket options. Absent from the socket module on some builds, so they
# are spelled out rather than looked up.
IP_RECVERR = 11
IPV6_RECVERR = 25

# ee_origin values from linux/errqueue.h. Anything else means the errno came
# from the local stack rather than from a router.
SO_EE_ORIGIN_ICMP = 2
SO_EE_ORIGIN_ICMP6 = 3

ICMP_V4 = {
    (11, 0): "time exceeded in-transit",
    (11, 1): "fragment reassembly time exceeded",
    (3, 0): "net unreachable",
    (3, 1): "host unreachable",
    (3, 2): "protocol unreachable",
    (3, 3): "port unreachable",
    (3, 4): "fragmentation needed",
    (3, 9): "net administratively prohibited",
    (3, 10): "host administratively prohibited",
    (3, 13): "communication administratively prohibited",
    (5, 0): "redirect",
}

ICMP_V6 = {
    (3, 0): "hop limit exceeded in-transit",
    (3, 1): "fragment reassembly time exceeded",
    (1, 0): "no route to destination",
    (1, 1): "communication administratively prohibited",
    (1, 3): "address unreachable",
    (1, 4): "port unreachable",
    (2, 0): "packet too big",
}


def describe(family, icmp_type, icmp_code):
    table = ICMP_V4 if family == socket.AF_INET else ICMP_V6
    return table.get((icmp_type, icmp_code), "unrecognised")


def is_expiry(family, icmp_type):
    """Was this the router saying 'your hop budget ran out here'?"""
    return icmp_type == (11 if family == socket.AF_INET else 3)


class ErrorQueue:
    """Linux. The kernel reports the ICMP error on the socket that provoked it."""

    def arm(self, sock, family):
        if family == socket.AF_INET:
            sock.setsockopt(socket.IPPROTO_IP, IP_RECVERR, 1)
        else:
            sock.setsockopt(socket.IPPROTO_IPV6, IPV6_RECVERR, 1)

    def extra_readers(self):
        return []

    def collect(self, sock, family):
        try:
            _, ancillary, _, _ = sock.recvmsg(0, 1024, socket.MSG_ERRQUEUE)
        except OSError:
            return None
        wanted = (socket.IPPROTO_IP, IP_RECVERR) if family == socket.AF_INET \
            else (socket.IPPROTO_IPV6, IPV6_RECVERR)
        for level, kind, data in ancillary:
            if (level, kind) != wanted or len(data) < 16:
                continue
            # struct sock_extended_err, then the sockaddr of the router that
            # sent the error - SO_EE_OFFENDER in the kernel headers.
            _, origin, icmp_type, icmp_code = struct.unpack_from("=IBBB", data, 0)
            if origin not in (SO_EE_ORIGIN_ICMP, SO_EE_ORIGIN_ICMP6):
                return None
            addr = None
            if len(data) >= 24:
                offender_family, = struct.unpack_from("=H", data, 16)
                if offender_family == socket.AF_INET:
                    addr = socket.inet_ntoa(data[20:24])
                elif offender_family == socket.AF_INET6 and len(data) >= 40:
                    addr = socket.inet_ntop(socket.AF_INET6, data[24:40])
            return addr, icmp_type, icmp_code
        return None


class RawIcmp:
    """macOS, the BSDs, illumos, Solaris. Read the ICMP off a raw socket, as root."""

    def __init__(self, family):
        proto = socket.IPPROTO_ICMP if family == socket.AF_INET else socket.IPPROTO_ICMPV6
        self.sock = socket.socket(family, socket.SOCK_RAW, proto)
        self.sock.setblocking(False)

    def arm(self, sock, family):
        pass

    def extra_readers(self):
        return [self.sock]

    def collect(self, sock, family):
        try:
            packet, peer = self.sock.recvfrom(1500)
        except OSError:
            return None
        if family == socket.AF_INET:
            # BSD raw sockets hand back the IP header too.
            header_len = (packet[0] & 0x0F) * 4
            packet = packet[header_len:]
        if len(packet) < 2:
            return None
        return peer[0], packet[0], packet[1]


def probe(dest, port, proto, family, hop_limit, timeout, listener):
    """One probe at one hop limit. Returns (icmp, socket_state, note)."""
    kind = socket.SOCK_STREAM if proto == "tcp" else socket.SOCK_DGRAM
    sock = socket.socket(family, kind)
    if family == socket.AF_INET:
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, hop_limit)
    else:
        sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_UNICAST_HOPS, hop_limit)
    listener.arm(sock, family)
    sock.setblocking(False)

    try:
        if kind == socket.SOCK_DGRAM:
            sock.connect((dest, port))
            sock.send(b"\x00" * 32)
        else:
            try:
                sock.connect((dest, port))
            except BlockingIOError:
                pass
    except OSError as exc:
        sock.close()
        return None, None, "local error: %s" % exc.strerror

    readers = [sock] + listener.extra_readers()
    writers = [] if kind == socket.SOCK_DGRAM else [sock]
    deadline = time.time() + timeout
    icmp = state = None

    while time.time() < deadline:
        ready_r, ready_w, ready_x = select.select(
            readers, writers, [sock], max(0.01, deadline - time.time()))
        if not (ready_r or ready_w or ready_x):
            continue
        # Drain the error queue first, always. An ICMP error reaches a TCP
        # socket as a plain errno, so SO_ERROR on its own cannot tell you
        # whether a router spoke or the far end did.
        icmp = listener.collect(sock, family)
        if icmp:
            break
        if ready_w:
            err = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
            if err == 0:
                state = "connected"
            elif err == errno.ECONNREFUSED:
                state = "TCP reset"
            else:
                state = os.strerror(err)
            break

    sock.close()
    if icmp or state:
        return icmp, state, None
    return None, None, "no reply"


def walk(dest, port, proto, family, first, last, timeout, listener):
    print("walking to %s  %s/%d  hop limit %d-%d" % (dest, proto.upper(), port, first, last))
    answered = []
    for hop in range(first, last + 1):
        started = time.time()
        icmp, state, _ = probe(dest, port, proto, family, hop, timeout, listener)
        rtt = (time.time() - started) * 1000
        if icmp:
            addr, icmp_type, icmp_code = icmp
            print(" %2d  %-39s %7.1f ms  ICMP %d/%d %s"
                  % (hop, addr or "?", rtt, icmp_type, icmp_code,
                     describe(family, icmp_type, icmp_code)))
            if is_expiry(family, icmp_type):
                answered.append((hop, addr))
            else:
                return answered, hop, "icmp-reject", addr
        elif state:
            print(" %2d  %-39s %7.1f ms  %s" % (hop, dest, rtt, state))
            return answered, hop, state, dest
        else:
            print(" %2d  *" % hop)
    return answered, None, "silent", None


def main():
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("host")
    parser.add_argument("port", nargs="?", type=int, default=443)
    parser.add_argument("--proto", choices=("tcp", "udp"), default="tcp")
    parser.add_argument("--first", type=int, default=1, help="hop limit to start at")
    parser.add_argument("--max", type=int, default=20, help="hop limit to stop at")
    parser.add_argument("--wait", type=float, default=2.0, help="seconds to wait per hop")
    parser.add_argument("-6", dest="v6", action="store_true", help="force IPv6")
    parser.add_argument("-4", dest="v4", action="store_true", help="force IPv4")
    args = parser.parse_args()

    family = socket.AF_INET6 if args.v6 else socket.AF_INET
    kind = socket.SOCK_STREAM if args.proto == "tcp" else socket.SOCK_DGRAM
    dest = socket.getaddrinfo(args.host, args.port, family, kind)[0][4][0]

    if sys.platform.startswith("linux"):
        listener = ErrorQueue()
    else:
        try:
            listener = RawIcmp(family)
        except PermissionError:
            sys.exit("%s cannot report ICMP errors on a normal socket, so this "
                     "needs a raw one. Run it as root." % sys.platform)

    answered, stop, why, who = walk(dest, args.port, args.proto, family,
                                    args.first, args.max, args.wait, listener)
    what = "%s/%d" % (args.proto.upper(), args.port)
    print()

    if why == "connected":
        print("Verdict: %s is open. It answered at hop %d." % (what, stop))
    elif why == "icmp-reject":
        print("Verdict: %s at hop %d is refusing %s on policy, and is honest "
              "enough to say so." % (who, stop, what))
    elif why == "TCP reset":
        print("Verdict: a reset came back to a probe with a hop limit of %d." % stop)
        print("         Nothing more than %s away can have sent it, so check the reply"
              % ("one hop" if stop == 1 else "%d hops" % stop))
        print("         TTL before you believe the host did.")
    elif answered:
        last_hop, last_addr = answered[-1]
        print("Verdict: answers stop after hop %d (%s)." % (last_hop, last_addr))
        print("         Whatever swallows %s is hop %d." % (what, last_hop + 1))
        print("         Walk a port that works and read off the address at hop %d."
              % (last_hop + 1))
    else:
        print("Verdict: nothing answered at all, not even the first hop. Either the")
        print("         first hop is the one dropping you, or the ICMP errors are being")
        print("         filtered on the way back. Walk a port that works to tell those")
        print("         two apart.")


if __name__ == "__main__":
    main()

Ce que ça ne peut pas vous dire

La méthode est bon marché et elle est honnête sur la plupart des choses, mais ce n’est pas un scanner de topologie. Soyez droit dans le ticket sur ce que vous avez réellement mesuré.

Des quintuplets différents peuvent prendre des chemins différents. L’ECMP hache les ports source et destination dans le choix du saut suivant, donc deux passages sur deux ports différents ne sont pas garantis de traverser les mêmes routeurs, ce qui est l’une des deux explications possibles du saut 5 ci-dessus qui a répondu à un passage et s’est tu à l’autre. Répétez les deux passages. Une frontière qui bouge n’est pas prouvée.

Le MPLS cache des sauts. Un cœur à commutation d’étiquettes peut se présenter comme un seul saut, ou comme aucun. Tout comptage à travers l’ossature de quelqu’un d’autre est une borne basse.

La production d’ICMP est limitée en débit à peu près partout. Sondez plus vite que le routeur ne veut répondre et vous fabriquez vos propres étoiles. hopfind.py envoie une sonde par saut et attend ; c’est délibéré.

Le chemin de retour n’est pas forcément celui de l’aller. Le nombre de sauts à l’aller n’est pas celui du retour, et l’arithmétique du TTL inverse ne mesure que le trajet de retour.

L’anycast fait que l’hôte au saut N peut ne pas être la même boîte deux fois. Les résolveurs publics et les CDN, en particulier.

Une poignée de main achevée ne veut pas dire que la session survit. Un pare-feu à état peut laisser passer le SYN et jeter la suite à l’inspection. Si la connexion s’ouvre puis meurt, ce n’est pas le bon instrument — allez capturer.

Vous avez trouvé le premier équipement qui jette, pas celui que quelqu’un admettra. Dans un CGN ou un réseau d’opérateur, l’adresse au saut N+1 peut être l’une de plusieurs boîtes derrière une seule adresse. Ça reste la bonne chose à citer, parce que c’est un fait sur le chemin.

À quoi ça sert vraiment

À arrêter les rebonds. C’est tout le retour sur l’exercice.

« Le port 445 est bloqué quelque part » est une invitation à vous rendre le ticket. Ceci ne l’est pas :

TCP/445 vers 192.0.2.4 est jeté silencieusement au saut 11, adresse 192.0.2.31. Le saut 11 répond ICMP Time Exceeded au TCP/443 sur le même chemin en 26 ms et ne répond rien du tout sur le 445, donc le rejet est une décision de politique sur cet équipement, pas un défaut de routage. Les dix sauts devant lui sont propres. Reproduit quatre fois en vingt minutes, depuis un shell non privilégié, script joint.

Personne ne vous rend ça. Ça nomme un équipement, dit ce qu’il a fait, dit ce qu’il n’a pas fait, et montre le travail. Qu’ils choisissent ou non de le changer reste leur affaire. Mais la semaine de ping-pong est finie, et ça a pris quatre commandes.

Tout ça est sorti d’un champ de 8 bits spécifié comme un minuteur en 1981, jamais utilisé comme tel une seule fois, et qui transforme discrètement « quelque part » en une adresse.

Ça vaut la peine d’apprendre à le lire.