Chapitre 1220 minDébutant
Python et le réseau : ipaddress, ping et socket
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- calculer et vérifier des réseaux avec ipaddress ;
- trouver les hôtes qui répondent au ping, sous Linux et Windows ;
- accélérer un balayage avec des threads ;
- tester un port TCP et résoudre un nom DNS avec socket.
3 exercices corrigés1 schéma
Python sait calculer et tester un réseau avec sa seule bibliothèque standard : le module ipaddress manipule les adresses et les réseaux IPv4 et IPv6 sans erreur de calcul, subprocess lance la commande ping, socket teste un port TCP et résout un nom DNS. Ce chapitre vérifie le plan d'adressage de Sorbier Logistique, écrit un script qui trouve les hôtes du réseau des serveurs qui répondent au ping, l'accélère avec des fils d'exécution (threads), puis teste les services un par un. Les calculs de masque et de sous-réseau sont expliqués dans le cours Réseaux ; ici, Python les fait pour toi.
Le module ipaddress
>>> import ipaddress
>>> serveurs = ipaddress.ip_network("192.168.10.176/28")
>>> serveurs.netmask, serveurs.broadcast_address
(IPv4Address('255.255.255.240'), IPv4Address('192.168.10.191'))
>>> serveurs.num_addresses
16
>>> hotes = list(serveurs.hosts())
>>> len(hotes), hotes[0], hotes[-1]
(14, IPv4Address('192.168.10.177'), IPv4Address('192.168.10.190'))
>>> ipaddress.ip_address("192.168.10.183") in serveurs
True
>>> ipaddress.ip_address("192.168.10.200") in serveurs
False
>>> ipaddress.ip_address("8.8.8.8").is_private
False
>>> interface = ipaddress.ip_interface("192.168.21.186/24")
>>> interface.network
IPv4Network('192.168.21.0/24')
>>> [str(s) for s in ipaddress.ip_network("192.168.21.0/24").subnets(new_prefix=26)]
['192.168.21.0/26', '192.168.21.64/26', '192.168.21.128/26', '192.168.21.192/26']
>>> ipaddress.ip_network("192.168.10.177/28")
Traceback (most recent call last):
File "<python-input-12>", line 1, in <module>
ipaddress.ip_network("192.168.10.177/28")
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.15/ipaddress.py", line 71, in ip_network
return IPv4Network(address, strict)
File "/usr/local/lib/python3.15/ipaddress.py", line 1541, in __init__
raise ValueError('%s has host bits set' % self)
ValueError: 192.168.10.177/28 has host bits set
>>> ipaddress.ip_address("192.168.10.300")
Traceback (most recent call last):
File "<python-input-13>", line 1, in <module>
ipaddress.ip_address("192.168.10.300")
~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.15/ipaddress.py", line 51, in ip_address
raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 address')
ValueError: '192.168.10.300' does not appear to be an IPv4 or IPv6 address
| Objet ou attribut | Rôle |
|---|---|
ip_address("192.168.10.183") | Une adresse ; elle se compare, s'incrémente (ip + 1) et se teste (is_private, is_loopback) |
ip_network("192.168.10.176/28") | Un réseau, donné par son adresse de réseau |
reseau.netmask, reseau.broadcast_address | Le masque, l'adresse de diffusion |
reseau.num_addresses | Le nombre total d'adresses, réseau et diffusion comprises |
reseau.hosts() | Les adresses utilisables par des hôtes, une par une |
ip in reseau | L'adresse appartient-elle au réseau ? |
reseau.subnets(new_prefix=26) | Le découpage en sous-réseaux plus petits |
reseau.overlaps(autre) | Les deux réseaux se chevauchent-ils ? |
ip_interface("192.168.21.186/24") | Une adresse avec son masque, comme sur une carte réseau ; .network donne son réseau |
Le module refuse ce qui est faux : 192.168.10.177/28 n'est pas une adresse de réseau (des bits d'hôte sont à 1), et 192.168.10.300 n'est pas une adresse. Une saisie se valide donc en une ligne, dans un try qui intercepte ValueError. Tout fonctionne de la même façon en IPv6 (ip_network("2001:db8:10::/64")).
Vérifier un plan d'adressage
Le plan d'adressage des VLAN de Sorbier est décrit dans le cours pfSense : chaque VLAN a son réseau et sa passerelle, portée par le pare-feu, à la dernière adresse utilisable. Un script vérifie ces deux règles et l'absence de chevauchement entre deux VLAN, l'erreur qui rend un réseau incompréhensible :
# plan.py : vérifier le plan d'adressage des VLAN de Sorbier Logistique
import ipaddress
from itertools import combinations
PLAN = {
"10 POSTES": ("192.168.10.64/26", "192.168.10.126"),
"20 SERVEURS": ("192.168.10.176/28", "192.168.10.190"),
"21 APPLIS": ("192.168.21.0/24", "192.168.21.254"),
"30 ENTREPOT": ("192.168.10.128/27", "192.168.10.158"),
"40 VISITEURS": ("192.168.10.0/26", "192.168.10.62"),
"50 TELEPHONIE": ("192.168.10.160/28", "192.168.10.174"),
"60 DMZ": ("192.168.10.224/28", "192.168.10.238"),
"99 ADMIN": ("192.168.10.208/28", "192.168.10.222"),
}
reseaux = {}
for vlan, (cidr, passerelle) in PLAN.items():
reseau = ipaddress.ip_network(cidr)
gw = ipaddress.ip_address(passerelle)
derniere = list(reseau.hosts())[-1]
remarque = "" if gw == derniere else f" ATTENTION : passerelle {gw}, dernière adresse {derniere}"
print(f"VLAN {vlan:<14}{str(reseau):<19}{reseau.num_addresses - 2:>4} hôtes{remarque}")
reseaux[vlan] = reseau
for (v1, r1), (v2, r2) in combinations(reseaux.items(), 2):
if r1.overlaps(r2):
print(f"CHEVAUCHEMENT : VLAN {v1} et VLAN {v2}")
print("Contrôle des chevauchements terminé")
VLAN 10 POSTES 192.168.10.64/26 62 hôtes
VLAN 20 SERVEURS 192.168.10.176/28 14 hôtes
VLAN 21 APPLIS 192.168.21.0/24 254 hôtes
VLAN 30 ENTREPOT 192.168.10.128/27 30 hôtes
VLAN 40 VISITEURS 192.168.10.0/26 62 hôtes
VLAN 50 TELEPHONIE 192.168.10.160/28 14 hôtes
VLAN 60 DMZ 192.168.10.224/28 14 hôtes
VLAN 99 ADMIN 192.168.10.208/28 14 hôtes
Contrôle des chevauchements terminé
combinations(reseaux.items(), 2), du module itertools, donne chaque paire de VLAN une seule fois : 28 paires pour 8 VLAN. Le plan est cohérent : aucune remarque, aucun chevauchement. Change une passerelle ou un masque dans le dictionnaire pour voir le script réagir.
Quels hôtes répondent au ping ?
Python n'a pas de fonction ping toute faite : envoyer un paquet ICMP demande de le construire soi-même avec un socket « brut », ce qui exige en général les droits d'administrateur. Le plus simple est donc de lancer la commande ping du système avec subprocess, une fois par adresse, et de regarder son code de retour. Ses options diffèrent selon le système :
| Système | Un seul paquet | Délai d'attente |
|---|---|---|
| Linux | -c 1 | -W 1 (en secondes) |
| Windows | -n 1 | -w 1000 (en millisecondes) |
#!/usr/bin/env python3
"""Teste quels hôtes d'un réseau répondent au ping."""
import argparse
import ipaddress
import platform
import subprocess
from concurrent.futures import ThreadPoolExecutor
WINDOWS = platform.system() == "Windows"
def hote_repond(ip, delai=1.0):
"""Envoie un seul ping à ip et renvoie True si une réponse arrive dans le délai."""
if WINDOWS:
commande = ["ping", "-n", "1", "-w", str(int(delai * 1000)), str(ip)]
else:
commande = ["ping", "-c", "1", "-W", str(delai), str(ip)]
try:
resultat = subprocess.run(commande, capture_output=True, timeout=delai + 2)
except subprocess.TimeoutExpired:
return False
if WINDOWS:
# Sous Windows, « Impossible de joindre l'hôte » renvoie aussi le code 0 :
# seule une vraie réponse d'écho contient « TTL= ».
return resultat.returncode == 0 and b"TTL=" in resultat.stdout
return resultat.returncode == 0
def balayer(reseau, delai=1.0, fils=32):
"""Renvoie la liste des (adresse, répond) pour chaque hôte du réseau, dans l'ordre."""
hotes = list(ipaddress.ip_network(reseau).hosts())
with ThreadPoolExecutor(max_workers=fils) as executeur:
reponses = executeur.map(lambda ip: hote_repond(ip, delai), hotes)
return list(zip(hotes, reponses))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("reseau", help="réseau à tester, par exemple 192.168.10.176/28")
parser.add_argument("-d", "--delai", type=float, default=1.0, metavar="S",
help="attente maximale par hôte, en secondes (1 par défaut)")
parser.add_argument("--actifs", action="store_true", help="n'affiche que les hôtes qui répondent")
args = parser.parse_args()
resultats = balayer(args.reseau, args.delai)
for ip, repond in resultats:
if repond or not args.actifs:
print(f"{str(ip):<16}{'répond' if repond else '-'}")
actifs = sum(1 for _, repond in resultats if repond)
print(f"{actifs} hôte(s) sur {len(resultats)} répondent au ping")
if __name__ == "__main__":
main()
Lancé depuis le poste d'administration (192.168.10.209, VLAN 99 ADMIN) sur le réseau des serveurs :
python balayage.py 192.168.10.176/28
192.168.10.177 répond
192.168.10.178 répond
192.168.10.179 répond
192.168.10.180 répond
192.168.10.181 répond
192.168.10.182 répond
192.168.10.183 répond
192.168.10.184 répond
192.168.10.185 répond
192.168.10.186 répond
192.168.10.187 -
192.168.10.188 répond
192.168.10.189 répond
192.168.10.190 répond
13 hôte(s) sur 14 répondent au ping
Treize machines sur quatorze répondent. Seule .187, la dernière adresse libre du VLAN 20, reste muette : c'est pour cela que les nouvelles machines vont désormais dans le VLAN 21 APPLIS.
Trois points du script méritent l'attention.
- Windows et le code de retour. Sous Windows,
pingrenvoie parfois le code 0 quand le routeur répond « Impossible de joindre l'hôte de destination » à la place de la machine. Seule une vraie réponse d'écho contientTTL=, dans toutes les langues de Windows : le script la cherche dans la sortie, lue en octets (b"TTL="), ce qui évite tout problème d'encodage de la console. - Les threads. Un hôte éteint fait attendre le délai complet : 14 hôtes testés un par un, c'est jusqu'à 14 secondes, et un
/24de 254 hôtes plus de 4 minutes.ThreadPoolExecutor, du moduleconcurrent.futures, lance jusqu'à 32 pings en même temps ;executeur.map()applique la fonction à chaque adresse et rend les résultats dans l'ordre des adresses. Le/24se teste alors en une dizaine de secondes. - Une réponse absente ne prouve rien. Le pare-feu de Windows peut bloquer l'écho ICMP entrant (la règle « Partage de fichiers et d'imprimantes (Demande d'écho - Trafic entrant ICMPv4) » n'est pas toujours activée), et un pare-feu entre deux VLAN peut filtrer ICMP. Une machine qui ne répond pas au ping peut très bien fonctionner : vérifie un port TCP qu'elle doit ouvrir.
Tester un port TCP avec socket
Un socket est l'extrémité d'une connexion réseau. socket.create_connection((hote, port), timeout) tente d'établir une connexion TCP : si elle s'établit, un service écoute sur ce port ; sinon, elle est refusée (rien n'écoute), ou le délai expire (un pare-feu filtre, ou la machine est éteinte). C'est ce que fait Test-NetConnection en PowerShell ou nc -zv sous Linux (les ports TCP et UDP).
# ports.py : tester si un service TCP accepte les connexions
import socket
def port_ouvert(hote, port, delai=2.0):
"""Renvoie True si une connexion TCP vers hote:port s'établit dans le délai."""
try:
with socket.create_connection((hote, port), timeout=delai):
return True
except OSError: # refusée, délai dépassé, nom inconnu, réseau injoignable
return False
SERVICES = [("127.0.0.1", 8000, "web de test"), ("127.0.0.1", 3306, "MySQL"), ("127.0.0.1", 22, "SSH")]
for hote, port, nom in SERVICES:
etat = "ouvert" if port_ouvert(hote, port) else "fermé ou filtré"
print(f"{hote}:{port:<6}{nom:<13}{etat}")
Pour l'essayer sur ta machine, lance dans un autre terminal le petit serveur web de Python, python -m http.server 8000 --bind 127.0.0.1, puis le script :
127.0.0.1:8000 web de test ouvert
127.0.0.1:3306 MySQL fermé ou filtré
127.0.0.1:22 SSH fermé ou filtré
Sur le labo, la liste SERVICES contiendrait les services attendus : srv-ad sur les ports 53 (DNS) et 389 (LDAP), srv-web sur le port 443, srv-glpi sur le port 443. Le bloc with ferme la connexion aussitôt établie : le test ne fait qu'ouvrir et refermer, sans rien envoyer.
Résoudre un nom DNS
# dns.py : résoudre des noms avec le résolveur du système
import socket
for nom in ["localhost", "srv-inconnu.sorbier.example"]:
try:
print(nom, "->", socket.gethostbyname(nom))
except socket.gaierror:
print(nom, "-> nom introuvable")
localhost -> 127.0.0.1
srv-inconnu.sorbier.example -> nom introuvable
socket.gethostbyname() passe par le résolveur du système, comme n'importe quel programme : le fichier hosts, puis les serveurs DNS configurés sur la carte réseau. Sur un poste du domaine de Sorbier, srv-ad.ad.sorbier.example donnerait 192.168.10.177, résolu par le DNS de l'Active Directory. Un nom inconnu lève socket.gaierror. Pour interroger un serveur DNS précis ou lire d'autres enregistrements (MX, SRV, TXT), il faut un paquet tiers comme dnspython, ou les outils nslookup et dig (dépanner DNS et DHCP).
Exercices
Exercice 1 sur 3 Le réseau des postes Réussi À revoir
Avec ipaddress, réponds pour le VLAN 10 POSTES, 192.168.10.64/26 : masque, adresse de diffusion, nombre d'hôtes, première et dernière adresse utilisables. La passerelle 192.168.10.126 est-elle bien la dernière adresse utilisable ? Le terminal de scan 192.168.10.130 est-il dans ce réseau ?
Voir la réponseMasquer la réponse de l'exercice 1
>>> import ipaddress
>>> postes = ipaddress.ip_network("192.168.10.64/26")
>>> postes.netmask, postes.broadcast_address
(IPv4Address('255.255.255.192'), IPv4Address('192.168.10.127'))
>>> postes.num_addresses - 2
62
>>> hotes = list(postes.hosts())
>>> hotes[0], hotes[-1]
(IPv4Address('192.168.10.65'), IPv4Address('192.168.10.126'))
>>> ipaddress.ip_address("192.168.10.126") == hotes[-1]
True
>>> ipaddress.ip_address("192.168.10.130") in postes
False
Le masque /26 laisse 6 bits d'hôte : 2⁶ = 64 adresses, dont 62 utilisables, de .65 à .126. La passerelle est bien la dernière. .130 appartient au VLAN 30 ENTREPOT (192.168.10.128/27) : un terminal de scan, c'est logique.
Tu avais trouvé ?
Exercice 2 sur 3 Une adresse libre dans le VLAN 21 Réussi À revoir
Dans le VLAN 21 APPLIS (192.168.21.0/24, passerelle .254), les adresses .10, .20, .21, .22, .23, .182 et .186 sont prises. Écris une fonction adresses_libres(reseau, utilisees, reservees, nombre) qui renvoie les nombre premières adresses libres, et affiche les cinq premières, puis le nombre total d'adresses libres.
Voir la réponseMasquer la réponse de l'exercice 2
# ex_libres.py : les premières adresses libres du VLAN 21 APPLIS
import ipaddress
reseau = ipaddress.ip_network("192.168.21.0/24")
passerelle = ipaddress.ip_address("192.168.21.254")
utilisees = {ipaddress.ip_address(f"192.168.21.{n}") for n in [10, 20, 21, 22, 23, 182, 186]}
def adresses_libres(reseau, utilisees, reservees, nombre):
libres = []
for ip in reseau.hosts():
if ip not in utilisees and ip not in reservees:
libres.append(ip)
if len(libres) == nombre:
break
return libres
print([str(ip) for ip in adresses_libres(reseau, utilisees, {passerelle}, 5)])
print(len(list(reseau.hosts())) - len(utilisees) - 1, "adresses libres en tout")
['192.168.21.1', '192.168.21.2', '192.168.21.3', '192.168.21.4', '192.168.21.5']
246 adresses libres en tout
Les adresses sont comparées comme objets IPv4Address, pas comme chaînes : "192.168.21.5" < "192.168.21.20" est faux en texte, vrai en adresses. 254 hôtes, moins 7 adresses prises et la passerelle : 246. Une adresse « libre » dans le fichier peut être occupée en réalité : avant de l'attribuer, vérifie qu'elle ne répond pas au ping et qu'elle n'est pas dans une étendue DHCP.
Tu avais trouvé ?
Exercice 3 sur 3 Ping ou port ? Réussi À revoir
Le script de balayage dit que srv-ad2 (192.168.10.181) ne répond plus au ping depuis la mise à jour de la veille, mais les utilisateurs s'authentifient normalement. Propose une explication et une vérification en Python.
Voir la réponseMasquer la réponse de l'exercice 3
La mise à jour a probablement réinitialisé une règle du pare-feu de Windows : l'écho ICMP entrant est de nouveau bloqué. Le serveur fonctionne, il ne répond simplement plus au ping. On vérifie un service qu'il doit rendre, avec la fonction port_ouvert() du chapitre : port_ouvert("192.168.10.181", 389) pour LDAP, ou port_ouvert("192.168.10.181", 53) pour le DNS. Si ces ports répondent, le serveur est vivant, et la règle ICMP se rétablit par GPO pour ne plus dépendre d'une mise à jour. La supervision (Zabbix) teste d'ailleurs les services, pas seulement le ping (cours Supervision).
Tu avais trouvé ?