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 ::) et BROADCAST (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 ou SocketAddrV6, 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…

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 extension hyper-tls pour le TLS.
  • reqwest pour un client HTTP de plus haut niveau.
  • De très (trop ?) nombreux frameworks serveur, tels que axum et actix.

Beaucoup de ces bibliothèques utilisent des communications asynchrones. Nous donnerons donc un exemple qui les utilise dans le chapitre associé.