Réseau
Pour le meilleur et pour le pire, nous vivons dans un monde connecté à l’extrême où même les petits appareils électroménagers proposent des fonctionnalités basées sur Internet. Pourtant, ni C ni C++ n’offrent un moyen standardisé de communiquer en réseau : dans ces deux langages, on est encore condamné à utiliser des APIs spécifiques à chaque système d’exploitation pour ça.
Rust, en bon citoyen du monde moderne, fournit dans le module
std::net
de sa bibliothèque
standard ce qu’il faut pour répondre au besoin le plus courant : résoudre des
noms d’hôtes et communiquer avec les protocoles TCP et UDP sur des réseaux IPv4
et IPv6.
Adressses IP
Sur le principe, une addresse IP pourrait être représentée au niveau du langage
comme un simple tableau d’octets : 4 octets pour IPv4, 16 octets pour IPv6. Mais
Rust choisit d’utiliser à la place des types spécifiques
Ipv4Addr
et
Ipv6Addr
car cela
permet…
- De renforcer la discipline de typage : on ne peut pas utiliser accidentellement un tableau d’octets qui n’a rien à voir avec une adresse IP comme adresse IP.
- D’exposer facilement les adresses spéciales
LOCALHOST
(127.0.0.1
et::1
),UNSPECIFIED
(0.0.0.0
et::
) etBROADCAST
(255.255.255.255
, en IPv4 seulement). - D’exposer facilement des fonctions de classification comme
is_link_local()
. - De supporter facilement de nombreuses conversions : entre tableaux d’octets, chaîne de caractères et adresses IP; entre adresses IPv4 et IPv6, entre adresses IPv6 et tableaux de segments 16-bits…
Voici un exemple d’utilisation de ces types :
#![allow(unused)] fn main() { use std::net::Ipv4Addr; assert_eq!("192.168.0.1".parse(), Ok(Ipv4Addr::from([192, 168, 0, 1]))); assert_eq!(Ipv4Addr::LOCALHOST.to_string(), "127.0.0.1"); assert!(Ipv4Addr::BROADCAST.is_broadcast()); println!("localhost IPv4 -> {} IPv6", Ipv4Addr::LOCALHOST.to_ipv6_mapped()); }
Par ailleurs, alors que le déploiement de IPv6 continue de se poursuivre dans la
douleur, on doit de plus en plus souvent jongler entre adresses IPv4 et IPv6.
Rust fournit donc le type énuméré
IpAddr
, qui peut être
construit à partir d’une adresse IPv4 ou IPv6 (sous leurs diverses formes) et
fournit une couche d’abstraction simple pour manipuler ces deux types
d’adresses de façon homogène :
#![allow(unused)] fn main() { use std::net::{IpAddr, Ipv6Addr}; let addr = IpAddr::from(Ipv6Addr::LOCALHOST); assert!(addr.is_loopback()); }
Ports réseaux
Il est courant de vouloir exposer plusieurs services réseaux sur un serveur unique. On utilise pour ça le vénérable système des numéros de ports 16-bits de TCP et UDP.
Comme un numéro de port ne veut rien dire pris isolément (son interprétation
dépend du serveur cible), Rust n’expose pas de type port dédié, mais des types
SocketAddrV4
,
SocketAddrV6
et
SocketAddr
qui
représentent la combinaison d’une adresse IP et d’un port :
#![allow(unused)] fn main() { use std::net::{SocketAddr, SocketAddrV4, Ipv4Addr, Ipv6Addr}; // Décodage d'une paire (ip, port) en format textuel assert_eq!("127.0.0.1:80".parse(), Ok(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 80))); // Conversion d'une paire (ip, port) en addresse de socket générique let socket = SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 443); // Affichage de l'adresse générique et ses composantes println!("{socket} -> ip {}, port {}", socket.ip(), socket.port()); }
Résolution DNS
Les adresses IP sont faciles à manipuler pour les ordinateurs, mais difficiles à mémoriser pour les humains. On a donc inventé le Domain Name System (DNS), un énorme annuaire mondial qui associe des noms textuels plus mémorables aux adresses IP des serveurs.
Mais avec le DNS est aussi venue une problématique de sécurité : protéger le
service de résolution de nom des tentatives d’usurpation d’identité. En effet,
si le premier script kiddie
venu pouvait associer des noms de domaines réputés comme mail.google.com
à
l’adresse IP d’un serveur qu’il contrôle, les conséquences pour la sécurité des
services web seraient catastrophiques.
Pour éviter ce genre d’incident, il est important que les paramètres DNS soient gérés de façon centralisée au niveau du système d’exploitation, et que les applications s’en remettent toutes aux APIs de l’OS pour résoudre des noms de domaine. La bibliothèque standard Rust expose donc, de façon standardisée, la fonctionnalités de résolution de nom de domaine de l’OS sous-jacent.
L’interface actuellement fournie sur cette fonctionnalité est minimaliste et
basée sur le trait
ToSocketAddrs
.
Parmi les
implémentations, on trouve…
- Un tuple (adresse IP, port), où l’adresse IP peut être donnée aux formats
IpAddr
,Ipv4Addr
,Ipv6Addr
et textuels (suivant le standard IETF RFC 6943). - Une adresse de socket combinée
SocketAddr
,SocketAddrV4
ouSocketAddrV6
, ou sa représentation textuelle standardisée. - Un tuple (nom d’hôte, port), où le nom d’hôte sera résolu par une requête DNS.
- Une chaîne de caractères au format
<nom d'hôte>:<port>
usuel.
Il n’aura pas échappé au lecteur attentif qu’un nom d’hôte peut correspondre à
plusieurs adresses IP (typiquement une adresse IPv4 et une adresse IPv6). C’est
pourquoi la conversion ToSocketAddrs
produit en sortie un itérateur d’adresse
IP, et pas une adresse IP unique. Si on n’a pas besoin de résolution de nom
d’hôte, on peut également passer plusieurs SocketAddr
en entrée de la
conversion
ToSocketAddrs
,
et elles seront ré-émises en sortie.
#![allow(unused)] fn main() { // Cet exemple ne peut pas être exécuté sur le Rust Playground en raison des // restrictions réseau appliquées aux machines virtuelles utilisées. use std::net::ToSocketAddrs; const CIBLE: &str = "duckduckgo.com:443"; let addrs = CIBLE.to_socket_addrs().expect("Echec de la résolution DNS"); println!("Résultats de la résolution de {CIBLE} :"); for addr in addrs { println!("- {addr}"); } }
Les constructeurs de sockets TCP et UDP acceptent en paramètre l’ensemble des
types qui implémentent ToSocketAddrs
, ce qui offre une grande flexibilité
pour l’utilisateur. Lorsque l’implémentation ToSocketAddrs
produit plusieurs
SocketAddr
, elles seront essayées les unes après les autres par le
constructeur de socket jusqu’à en trouver une qui fonctionne.
Connexions TCP
L’API TCP de la bibliothèque standard Rust se compose de deux types
TcpListener
et
TcpStream
. Le
premier permet d’attendre des connexions entrantes, le deuxième représente une
connexion active via laquelle on peut échanger des données. Dans l’ensemble,
l’API est assez similaire à celle des sockets POSIX et les utilisateurs
de ces derniers devraient s’y retrouver facilement.
Pour accepter des connections entrantes, on commence par créer un TcpListener
en donnant une adresse d’écoute et un numéro de port à écouter. Le numéro de
port peut être 0, dans ce cas le système d’exploitation attribue automatiquement
un port accessible et non utilisé :
#![allow(unused)] fn main() { use std::net::TcpListener; // Activation de l'écoute TCP let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); // Récupération de l'adresse et du port utilisé let socket = ecoute.local_addr() .expect("Echec de récupération de l'adresse locale"); println!("Prêt à recevoir du trafic sur {socket}"); }
Ensuite, on peut commencer à attendre des clients, soit un par un via la
méthode
accept()
,
soit indéfiniment via l’itérateur de connexions
incoming()
:
#![allow(unused)] fn main() { use std::net::TcpListener; let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); for connexion in ecoute.incoming() { let connexion = connexion.expect("Echec de l'établissement de la connexion"); println!( "Connexion TCP établie avec {}", connexion.peer_addr() .expect("Echec de récupération de l'adresse distante") ); } }
Parfois, il n’est pas acceptable d’attendre indéfiniment l’arrivée d’une
connexion entrante. Dans ce cas, on peut configurer le TcpListener
en mode
non bloquant avec la méthode
set_nonblocking()
.
Dans ce mode, si il n’y a pas de connexion entrante, les appels à accept()
échoueront immédiatement avec une erreur
WouldBlock
:
#![allow(unused)] fn main() { use std::net::TcpListener; let ecoute = TcpListener::bind(("localhost", 0)) .expect("Echec de création du TcpListener"); // Activation du mode non bloquant ecoute.set_nonblocking(true) .expect("Echec d'activation du mode non bloquant"); // Tentative de récupération de connexion entrante println!( "Résultat de accept() : {:?}", ecoute.accept() ); }
Mais à l’heure actuelle, Rust ne fournit pas d’équivalent standard aux
primitives pour attendre des connexions sur N sockets différents comme
epoll()
sous Linux
et kqueue()
sous BSD. Il faut encore
utiliser les APIs spécifiques à chaque OS, ou des bibliothèques basées sur ces
dernières.
Les connexions entrantes sont représentées par le type
TcpStream
, qui
peut aussi être construit directement pour établir une connexion sortante avec
la méthode
connect()
:
#![allow(unused)] fn main() { use std::net::{Ipv4Addr, TcpStream}; // Echouera sur le Rust Playground pour des raisons de politique réseau let connexion = TcpStream::connect(("duckduckgo.com", 443)); println!("Résultat de la tentative de connexion : {connexion:?}"); }
TCP offre une abstraction de flux d’octets continu, donc TcpStream
implémente
Read
et Write
et peut être utilisé comme n’importe quel autre flux
d’octets implémentant ces traits. Mais les personnes familières avec la
programmation réseau savent que ce n’est pas toujours suffisant :
- En cas de problème de connexion, le programme peut se retrouver à attendre indéfiniment l’arrivée d’octets entrants ou le départ d’octets sortants, ce qui n’est pas toujours un comportement acceptable pour l’utilisateur.
- En communication réseau, il existe un compromis entre débit et latence. Pour maximiser le débit, il faut mettre les données sortantes en mémoire tampon et attendre qu’il y ait un volume suffisant avant de les envoyer véritablement sur le réseau. Alors que pour minimiser la latence, on peut parfois être amené à préférer envoyer les données sortantes dès que possible.
Par conséquent, TcpStream
permet entre autres…
- D’abandonner une tentative de connexion au bout d’un certain temps d’attente
en utilisant une variante de
connect()
appeléeTcpStream::connect_timeout()
. - De fixer une limite de temps d’attente lors de l’envoi et de la réception de
données avec
set_read_timeout()
etset_write_timeout()
, ou de rendre l’envoi et la réception complètement non bloquants avecset_nonblocking()
. - De contrôler le compromis débit/latence au niveau noyau avec
set_nodelay()
.
Sockets UDP
Le protocole TCP est facile à utiliser, car il permet de dissimuler la complexité des échanges réseau derrière une abstraction simple de flux d’octets. Mais cette abstraction a un coût. Comme la couche IP sous-jacente est non fiable et désordonnée, le trafic TCP doit être rendu fiable via un système d’accusés de réception et réordonné via un système de numérotation et buffering complexe.
Pour certains types de services réseau, les coûts associés à TCP sont
inacceptables. Il faut alors travailler à niveau d’abstraction inférieur avec le
protocole UDP, qui expose la non-fiabilité et le caractère désordonné de la
couche IP à l’utilisateur. En Rust, on utilise pour cela le type
UdpSocket
.
On se prépare à accepter des paquets UDP entrants avec la méthode
UdpSocket::bind()
,
qui ressemble à TcpListener::bind()
dans sa signature. Mais une fois le
socket UDP créé, son comportement sera différent de celui de TcpListener
.
En effet, en UDP, il n’y a pas de notion de connexion, juste des paquets reçus
depuis différentes adresses sources. Donc on n’a pas d’équivalent des méthodes
accept()
et incoming()
de TcpListener. A la place, on utilise
recv_from()
ou
peek_from()
avec un tampon suffisamment gros (la taille de paquet maximum
doit être négociée avec l’hôte distant et le réseau intermédiaire) pour recevoir
des paquets entrants et obtenir l’adresse source associée au passage :
#![allow(unused)] fn main() { use std::net::UdpSocket; // Début de l'écoute UDP let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); // Réception d'un paquet let mut buf = [0; 9000]; // Assez grand pour des jumbo frames typiques let (taille, source) = socket.recv_from(&mut buf[..]) .expect("Echec de la réception d'un paquet"); let paquet = &buf[..taille]; // Affichage du contenu du paquet println!("Reçu de {source} : {paquet:02x?}"); }
Une fois un socket UDP créé, on peut aussi prendre l’initiative d’envoyer des
paquets à un hôte distant. Il est possible de le faire directement avec
send_to()
…
#![allow(unused)] fn main() { use std::net::UdpSocket; let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); // Emission d'un paquet (échouera sur le Rust Playground) const TEXTE: &str = "GET / HTTP/1.1\nHost: www.perdu.com"; let resultat = socket.send_to(TEXTE.as_bytes(), ("www.perdu.com", 80)); println!("Resultat de l'émission d'un paquet : {resultat:?}"); }
…mais si on a l’intention d’échanger de nombreux paquets avec un même hôte
distant, ce n’est pas la façon la plus efficace de procéder. A la place, mieux
vaut enregistrer les paramètres de l’hôte distant avec la méthode
connect()
,
ce qui donne accès à des méthodes
send()
et
recv()
et
peek()
où l’hôte distant est implicite. Ainsi, cet exemple de code est équivalent au
précédent :
#![allow(unused)] fn main() { use std::net::UdpSocket; let socket = UdpSocket::bind(("localhost", 0)) .expect("Echec de la création du UdpSocket"); const TEXTE: &str = "GET / HTTP/1.1\nHost: www.perdu.com"; // Enregistrement de l'hôte distant (échouera sur le Rust Playground) let resultat = socket.connect(("www.perdu.com", 80)); println!("Résultat de la connexion : {resultat:?}"); // Envoi d'un paquet if let Ok(()) = resultat { let resultat = socket.send(TEXTE.as_bytes()); println!("Resultat de l'émission d'un paquet : {resultat:?}"); } }
Attention, UdpSocket::connect()
n’est pas équivalent à TcpStream::connect()
:
puisqu’il n’y a pas de connexions au niveau du protocole UDP, on ne peut pas
vérifier qu’il y a bien un serveur à l’adresse cible. Donc si on fournit
plusieurs adresses de destination, connect()
va sélectionner la première
adresse qui est joignable avec la configuration réseau active (même réseau ou
route IPv4/IPv6 connue vers le réseau cible), et ne vérifiera pas si il y a
réellement un serveur qui écoute.
Comme avec TcpStream
, on peut configurer des timeouts d’envoi et réception
et des entrées/sorties non bloquantes. Mais on ne retrouve pas les notions de
timeout de connexion et d’envoi immédiat, qui n’ont de sens qu’en TCP. En
revanche, on trouve de nouvelles fonctions permettant de contrôler les
paramètres broadcast
et
multicast, ce qui n’a de sens qu’en
UDP.
Couches supérieures
C’est très bien d’avoir accès au DNS, à TCP et à UDP dans la bibliothèque standard de son langage de programmation. Mais pour la plupart des applications, ce n’est pas suffisant. On va généralement avoir aussi besoin d’implémentations de protocoles de plus haut niveau basés sur TCP et UDP, par exemple le protocole HTTP des sites web et APIs REST et sa couche de sécurité TLS.
A l’heure actuelle, la bibliothèque standard Rust ne supporte pas directement de tels protocoles, et leur implémentation est déléguée à des bibliothèques tierces telles que…
hyper
pour l’utilisation directe de HTTP, et son extensionhyper-tls
pour le TLS.reqwest
pour un client HTTP de plus haut niveau.- De très (trop ?) nombreux frameworks serveur, tels que
axum
etactix
.
Beaucoup de ces bibliothèques utilisent des communications asynchrones. Nous donnerons donc un exemple qui les utilise dans le chapitre associé.