Punch-Through : la technique qui rend le P2P possible derrière un NAT
Comment deux machines derrière un routeur arrivent à se parler directement

Y a pas longtemps, j’ai voulu connecter deux machines pour du P2P. Pas de cloud, pas de serveur central — juste deux peers qui se parlent directement. Simple, non ?
Sauf que les deux machines étaient derrière des routeurs maison. Et là, putain, j’ai découvert que deux machines qui peuvent toutes les deux ping Google ne peuvent pas se parler entre elles.
Le coupable ? Le NAT. Le Network Address Translation. Ce truc que ton routeur fait pour que ta machine ait une IP privée (192.168.x.x) et que tout le trafic sortant passe par une seule IP publique. Sympa pour l’IPv4. Sauf que le NAT bloque tout trafic entrant non solicité. Si A veut parler à B, le paquet de A est droppé par le NAT de B. Fin de l’histoire.
Sauf que non. Parce que y a une technique. Une technique qui perce un “trou” dans le NAT. Une technique qui dit au NAT : “ce paquet entrant, c’est pas un attaquant, c’est une réponse à quelque chose que j’ai demandé.”
Cette technique, c’est le punch-through. Et c’est la base de tout le P2P moderne — WebRTC, Skype, BitTorrent, Tailscale.
Lexique
C’est là mes définitions et si elles vous déplaisent, cassez-vous je suis pas là pour discuter lexique.
- NAT (Network Address Translation): Le routeur qui traduit les IPs privées (192.168.x.x) en IP publique. Résout le problème de l’IPv4, casse le P2P.
- Punch-through (Hole Punching): Technique pour percer un “trou” dans le NAT et autoriser le trafic entrant depuis un peer spécifique.
- Rendezvous server: Serveur public qui aide les deux peers à s’échanger leurs adresses publiques. Pas de relay — juste un introduction.
- Cone NAT: NAT qui map un port privé vers un port public fixe, quel que soit la destination. Le meilleur cas pour le punch-through.
- Symmetric NAT: NAT qui map un port différent par destination. Le casse-tête. Le punch-through est quasi impossible.
- STUN (Session Traversal Utilities for NAT): Protocol pour discovering son adresse publique depuis derrière un NAT.
- TURN (Traversal Using Relays around NAT): Serveur relay — quand le punch-through échoue, le trafic passe par un serveur intermédiaire. Coûteux en latence.
- Keepalive: Paquet périodique (toutes les 15-30 secondes) pour maintenir le mapping NAT actif. Sans ça, le “trou” se referme.
- CGNAT (Carrier-Grade NAT): NAT fait par le fournisseur d’accès. Double NAT. Le pire ennemi du punch-through.
Le problème : pourquoi on peut pas se parler
Regarde ce schéma. C’est la réalité de 99% des machines connectées à Internet aujourd’hui.
A (192.168.1.10) B (10.0.0.5)
| |
▼ ▼
NAT_A NAT_B
(198.51.100.10) (203.0.113.20)
| |
└──────── Internet ─────┘
A veut parler à B. A connaît l’adresse publique de B (203.0.113.20). A envoie un paquet UDP vers 203.0.113.20:50001.
Et là ? Le paquet est droppé par NAT_B.
Pourquoi ? Parce que NAT_B n’a aucun state pour cette connexion. Il a jamais vu de paquet sortant vers 203.0.113.20:50001. Pour lui, c’est du trafic entrant non sollicité. Un attaquant potentiel. Drop.
C’est la loi du NAT : si tu n’as pas initié la connexion, tu ne reçois rien.
Les alternatives sont moches :
- Port forwarding : tu ouvres un port manuellement sur ton routeur. Fonctionne, mais c’est un admin nightmare et un risque sécurité.
- UPnP : le routeur ouvre les ports automatiquement. Insecure, désactivé par défaut sur les routeurs modernes.
- TURN relay : tu fais passer tout le trafic par un serveur intermédiaire. Fonctionne toujours, mais +50-100ms de latence et un coût serveur.
Ou… tu peux utiliser le punch-through.
La solution : UDP Punch-Through
Le punch-through repose sur une astuce simple : le NAT autorise le trafic entrant qui est une réponse à un trafic sortant.
Si A envoie un paquet vers B, et que B envoie un paquet vers A — les deux NATs voient ça comme des réponses à des connexions sortantes. Ils créent un “trou” dans leur table de traduction. Et les paquets suivants passent.
Le protocole en 5 étapes
1. A contacte S (rendezvous) → S note l'adresse publique de A
2. B contacte S (rendezvous) → S note l'adresse publique de B
3. S échange les adresses → A sait où est B, B sait où est A
4. A et B s'envoient des paquets → Les NATs créent le "trou"
5. Connexion directe établie → Plus besoin de S
Étape 1-2 : Le rendezvous
A et B contactent un serveur public S. S observe leurs adresses publiques :
A (192.168.1.10:4321) → NAT_A → S voit 198.51.100.10:40001
B (10.0.0.5:61000) → NAT_B → S voit 203.0.113.20:50009
Étape 3 : L’échange
S dit à A : “B est à 203.0.113.20:50009” S dit à B : “A est à 198.51.100.10:40001”
Étape 4 : Le punch
A envoie un paquet UDP vers 203.0.113.20:50009. B envoie un paquet UDP vers 198.51.100.10:40001.
Le premier paquet de chaque côté est droppé par le NAT destinataire. Mais il crée un state :
NAT_B : "J'ai vu un paquet sortant vers 198.51.100.10:40001.
Si un paquet arrive de cette source, je le laisse passer."
Les paquets suivants passent. La connexion directe est établie.
Étape 5 : Le keepalive
Les mappings NAT expirent (30 secondes à 2 minutes). Faut envoyer des keepalives régulièrement pour maintenir le “trou” ouvert.
0# Keepalive toutes les 15 secondes
1while True:
2 s.sendto(b'KA', (peer_host, peer_port))
3 time.sleep(15)Code : implémentation UDP
Voici un exemple minimal en Python. Pas de STUN, pas de TURN — juste le cœur du punch-through.
Le rendezvous server
0import socket
1import threading
2
3peers = {}
4
5def rendezvous():
6 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
7 s.bind(('0.0.0.0', 9999))
8 print("[Rendezvous] En attente des peers...")
9
10 while True:
11 data, addr = s.recvfrom(1024)
12 name = data.decode()
13 peers[name] = addr
14 print(f"[Rendezvous] {name} connecté depuis {addr}")
15
16 if len(peers) == 2:
17 names = list(peers.keys())
18 # Échange les adresses
19 peer_a = f"{peers[names[0]][0]}:{peers[names[0]][1]}"
20 peer_b = f"{peers[names[1]][0]}:{peers[names[1]][1]}"
21 s.sendto(peer_b.encode(), peers[names[0]])
22 s.sendto(peer_a.encode(), peers[names[1]])
23 print(f"[Rendezvous] Échange fait : {names[0]} <-> {names[1]}")
24 peers.clear()
25
26threading.Thread(target=rendezvous, daemon=True).start()Le peer
0import socket
1import time
2import sys
3
4def peer(name, rendezvous_host, rendezvous_port):
5 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
6 s.bind(('0.0.0.0', 0)) # Port aléatoire
7
8 # Se présenter au rendezvous
9 s.sendto(name.encode(), (rendezvous_host, rendezvous_port))
10
11 # Recevoir l'adresse de l'autre peer
12 data, _ = s.recvfrom(1024)
13 host, port = data.decode().split(':')
14 port = int(port)
15 print(f"[{name}] Peer trouvé : {host}:{port}")
16
17 # Punch-through : envoyer des paquets pour ouvrir le NAT
18 print(f"[{name}] Punch-through en cours...")
19 for i in range(10):
20 s.sendto(b'PUNCH', (host, port))
21 time.sleep(0.2)
22
23 # Écouter les messages
24 def listener():
25 while True:
26 try:
27 data, addr = s.recvfrom(1024)
28 msg = data.decode()
29 if msg == 'PUNCH':
30 print(f"[{name}] Punch-through réussi avec {addr}!")
31 elif msg == 'KA':
32 pass # Keepalive, ignorer
33 else:
34 print(f"[{name}] Reçu : {msg}")
35 except:
36 break
37
38 threading.Thread(target=listener, daemon=True).start()
39
40 # Boucle principale : envoyer des keepalives et lire l'input
41 while True:
42 s.sendto(b'KA', (host, port))
43 try:
44 msg = input()
45 if msg:
46 s.sendto(msg.encode(), (host, port))
47 except:
48 break
49
50if __name__ == '__main__':
51 name = sys.argv[1] if len(sys.argv) > 1 else 'peer'
52 peer(name, '127.0.0.1', 9999)Test
0# Terminal 1 : lancer le rendezvous
1$ python rendezvous.py
2
3# Terminal 2 : lancer peer A
4$ python peer.py alice
5[alice] Peer trouvé : 203.0.113.20:50009
6[alice] Punch-through en cours...
7[alice] Punch-through réussi avec ('198.51.100.10', 40001)!
8
9# Terminal 3 : lancer peer B
10$ python peer.py bob
11[bob] Peer trouvé : 198.51.100.10:40001
12[bob] Punch-through en cours...
13[bob] Punch-through réussi avec ('203.0.113.20', 50009)!
14
15# Les deux peers peuvent maintenant se parler directement
16$ python peer.py alice
17Salut Bob !C’est tout. Pas de bibliothèque externe. Pas de STUN. Pas de TURN. Juste des sockets UDP et une astuce de NAT.
TCP Punch-Through : plus complexe
Le UDP, c’est le best case. Le TCP, c’est un autre monde.
Pourquoi ? Parce que le TCP handshake est plus strict. Quand A envoie un SYN vers B, le NAT de B voit un paquet entrant non sollicité et le droppé (ou pire, envoie un RST).
La solution : le simultaneous open. Les deux peers appellent connect() en même temps. Les SYN se croisent dans le réseau. Les NATs voient ça comme des connexions sortantes.
0// Les deux peers font ça simultanément
1int sock = socket(AF_INET, SOCK_STREAM, 0);
2
3// Permet de bind plusieurs sockets au même port
4int opt = 1;
5setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
6setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
7
8// Le peer A appelle connect() vers B
9struct sockaddr_in addr_b;
10addr_b.sin_port = htons(peer_b_port);
11connect(sock, (struct sockaddr*)&addr_b, sizeof(addr_b));
12
13// En parallèle, le peer B appelle connect() vers A
14// Si le timing est bon, les SYN se croisent
15// Les NATs voient ça comme des connexions sortantes → ça passe
Le piège du timing
Le TCP punch-through est extrêmement sensible au timing. Si les SYN n’arrivent pas en même temps, le NAT du destinataire voit un SYN entrant non sollicité et le droppé.
En pratique, faut envoyer des SYN en burst (plusieurs tentatives rapides) et espérer qu’au moins un paquet passe des deux côtés.
Statistiques
D’après le papier fondateur de Ford & Bovykin (USENIX 2006) :
| Protocole | NATs supportant le punch-through |
|---|---|
| UDP | 82% |
| TCP | 64% |
Le TCP est moins fiable. En 2026, avec les CGNAT et les firewalls enterprise, c’est encore pire.
Les types de NAT : le facteur limitant
Le punch-through dépend entièrement du comportement du NAT. Et malheureusement, le NAT n’est pas standardisé.
Classification RFC 3489
| Type | Comportement | Mapping | Punch-Through |
|---|---|---|---|
| Full Cone | Un port映射, tous les trafics autorisés | Fixe | ✅ Facile |
| Restricted Cone | Un port映射, seul le trafic vers l’IP source autorisé | Fixe | ✅ Possible |
| Port-Restricted Cone | Un port映射, seul IP + port source autorisé | Fixe | ✅ Possible |
| Symmetric | Un port映射 par destination | Variable | ❌ Quasi impossible |
Le problème du Symmetric NAT
En Symmetric NAT, si A contacte S sur le port 9999, le NAT map sur le port 40001. Mais si A contacte B sur le port 50009, le NAT map sur un autre port — disons 40002.
Résultat : l’observation de S (port 40001) est inutile pour A. Le port que B verra sera différent.
A → S : NAT map 4321 → 40001
A → B : NAT map 4321 → 40002 (port différent!)
C’est mort. Le punch-through classique ne fonctionne pas.
Les solutions de secours
- Port prediction : certains NATs allouent les ports séquentiellement. On peut prédire le prochain port. Fonctionne parfois.
- Birthday attack : envoyer des paquets sur beaucoup de ports différents. Si le NAT alloue un port dans une plage prédictable, on a une chance de le “toucher”.
- TURN relay : le fallback. Tout passe par un serveur intermédiaire. Fonctionne toujours, mais c’est lent et coûteux.
Keepalive : ne pas laisser fermer le trou
Les mappings NAT expirent. C’est pas une question de “si”, c’est une question de “quand”.
| NAT | Timeout UDP | Timeout TCP |
|---|---|---|
| Routeur maison | 30s - 2min | 5-10min |
| CGNAT | 30s - 1min | Variable |
| Firewall enterprise | Variable | Variable |
La règle : envoyer un keepalive toutes les 15-30 secondes. Un paquet vide, un byte, n’importe quoi. L’important c’est que le NAT voie du trafic sortant pour maintenir le mapping actif.
0# Keepalive thread
1def keepalive(socket, peer_addr, interval=15):
2 while True:
3 socket.sendto(b'\x00', peer_addr) # 1 byte
4 time.sleep(interval)Sans keepalive, ton “trou” se referme en 30 secondes. Et faut refaire tout le processus.
Comparaison des solutions
| Solution | Latence | Coût | Fiabilité | Complexité |
|---|---|---|---|---|
| Punch-through UDP | ~0ms | Gratuit | 82% | Moyenne |
| Punch-through TCP | ~0ms | Gratuit | 64% | Élevée |
| Port forwarding | ~0ms | Gratuit | 100% | Manuelle |
| UPnP | ~0ms | Gratuit | Variable | Faible (insecure) |
| TURN relay | +50-100ms | $$/mois | 100% | Faible |
En pratique, tu fais punch-through UDP en premier. Si ça marche pas, tu fallback sur TURN. Le TCP punch-through, c’est pour les cas spécifiques où tu as besoin de la fiabilité TCP et où tu contrôles les deux peers.
Mot de la fin
Le punch-through, c’est la fondation invisible du P2P moderne. WebRTC l’utilise. Skype l’utilise. Tailscale l’utilise. BitTorrent l’utilise.
C’est pas magique. C’est une astuce qui exploite le comportement par défaut des NATs : “si tu as initié la connexion, les réponses sont autorisées.”
Mais c’est pas infaillible. Le Symmetric NAT te tue. Le CGNAT te tue. Le firewall enterprise te tue. Et dans ces cas-là, t’as besoin d’un fallback — TURN relay.
La leçon : commence par UDP punch-through. Teste sur ton réseau. Regarde si ça marche. Si ça marche pas, c’est probablement ton NAT qui est le problème. Et là, t’as deux choix : port forwarding (admin nightmare) ou TURN (coûteux).
Et souviens-toi : le meilleur protocole P2P, c’est celui qui fonctionne derrière le NAT de ta belle-mère.
See ya space-cowboy!