Apprenez à écrire rapidement des applications performantes et sûres dans Rust. Cet article vous guide dans la conception et la mise en œuvre d’un tunnel HTTP et couvre les bases de la création d’applications robustes, évolutives et observables.
Rust : Performance, Fiabilité, Productivité
Il y a environ un an, j’ai commencé à apprendre Rust. Les deux premières semaines ont été assez douloureuses. Rien compilé ; Je ne savais pas comment faire les opérations de base ; Je ne pouvais pas exécuter un programme simple. Mais petit à petit, j’ai commencé à comprendre ce que voulait le compilateur. Plus encore, j’ai réalisé que cela force la pensée juste et le comportement correct.
Oui, parfois, vous devez écrire des constructions apparemment redondantes. Mais il vaut mieux ne pas compiler un programme correct que d’en compiler un incorrect. Cela rend les erreurs plus difficiles.
Peu de temps après, je suis devenu plus ou moins productif et j’ai finalement pu faire ce que je voulais. Eh bien, la plupart du temps.
Par curiosité, j’ai décidé de relever un défi un peu plus complexe : implémenter un tunnel HTTP dans Rust. Cela s’est avéré étonnamment facile à faire et a pris environ une journée, ce qui est assez impressionnant. J’ai assemblé tokio, clap, serde et plusieurs autres caisses très utiles. Permettez-moi de partager les connaissances que j’ai acquises au cours de ce défi passionnant et d’expliquer pourquoi j’ai organisé l’application de cette façon. J’espère que vous l’apprécierez.
Qu’est-ce qu’un tunnel HTTP ?
En termes simples, il s’agit d’un VPN léger que vous pouvez configurer avec votre navigateur afin que votre fournisseur d’accès Internet ne puisse pas bloquer ou suivre votre activité, et que les serveurs Web ne voient pas votre adresse IP.
Si vous le souhaitez, vous pouvez le tester localement avec votre navigateur, par exemple avec Firefox (sinon, ignorez simplement cette section pour l’instant).
Didacticiel
1. Installez l’application à l’aide de Cargo
$ cargo install http-tunnel
2. Commencez
$ http-tunnel --bind 0.0.0.0:8080 http
Vous pouvez également consulter le référentiel GitHub du tunnel HTTP pour les instructions de construction/d’installation.
Maintenant, vous pouvez aller dans votre navigateur et définir le HTTP Proxy
pour localhost:8080
. Par exemple, dans Firefox, recherchez proxy
dans la section préférences :

Trouvez les paramètres de proxy, spécifiez-le pour HTTP Proxy
et vérifiez-le pour HTTPS:

Définissez le proxy sur juste construit http_tunnel
.Vous pouvez visiter plusieurs pages Web et consulter les ./logs/application.log
file—tout votre trafic passait par le tunnel. Par example:

Maintenant, parcourons le processus depuis le début.
Concevoir l’application
Chaque application commence par une conception, ce qui signifie que nous devons définir les éléments suivants :
- Exigences fonctionnelles.
- Prérogatives non fonctionnelles.
- Abstractions et composants d’application.
Exigences fonctionnelles
Nous devons suivre les spécifications décrites ici :
Négocier un objectif avec un HTTP CONNECT
demande. Par exemple, si le client souhaite créer un tunnel vers le site Web de Wikipédia, la requête ressemblera à ceci :
CONNECT www.wikipedia.org:443 HTTP/1.1
...
Suivi d’une réponse comme ci-dessous :
Après ce point, il suffit de relayer le trafic TCP dans les deux sens jusqu’à ce que l’un des côtés le ferme ou qu’une erreur d’E/S se produise.
Le tunnel HTTP devrait fonctionner pour les deux HTTP
et HTTPS
.
Nous devrions également être en mesure de gérer les cibles d’accès/blocage (par exemple, pour bloquer les trackers).
Prérogatives non fonctionnelles
Le service ne doit enregistrer aucune information permettant d’identifier les utilisateurs.
Il doit avoir un débit élevé et une faible latence (il doit être invisible pour les utilisateurs et relativement peu coûteux à exécuter).
Idéalement, nous voulons qu’il soit résistant aux pics de trafic, fournisse une isolation bruyante des voisins et résiste aux attaques DDoS de base.
La messagerie d’erreur doit être conviviale pour les développeurs. Nous voulons que le système soit observable pour le dépanner et le régler en production à grande échelle.
Composants
Lors de la conception des composants, nous devons décomposer l’application en un ensemble de responsabilités. Voyons d’abord à quoi ressemble notre organigramme :

Pour implémenter cela, nous pouvons introduire les quatre composants principaux suivants :
- Accepteur TCP/TLS
HTTP CONNECT
négociateur- Connecteur cible
- Relais duplex intégral
Mise en œuvre
Accepteur TCP/TLS
Lorsque nous savons à peu près comment organiser l’application, il est temps de décider quelles dépendances nous devons utiliser. Pour Rust, la meilleure bibliothèque d’E/S que je connaisse est tokio. Dans le tokio
famille, il existe de nombreuses bibliothèques dont tokio-tls
, ce qui rend les choses beaucoup plus simples. Ainsi, le code de l’accepteur TCP ressemblerait à ceci :
let mut tcp_listener = TcpListener::bind(&proxy_configuration.bind_address)
.await
.map_err(|e| {
error!(
"Error binding address {}: {}",
&proxy_configuration.bind_address, e
);
e
})?;
Et puis l’ensemble de la boucle accepteur + lancement des gestionnaires de connexion asynchrones serait :
loop {
// Asynchronously wait for an inbound socket.
let socket = tcp_listener.accept().await;
let dns_resolver_ref = dns_resolver.clone();
match socket {
Ok((stream, _)) => {
let config = config.clone();
// handle accepted connections asynchronously
tokio::spawn(async move { tunnel_stream(&config, stream, dns_resolver_ref).await });
}
Err(e) => error!("Failed TCP handshake {}", e),
}
}
Décomposons ce qui se passe ici. Nous acceptons une connexion. Si l’opération a réussi, utilisez tokio::spawn
pour créer une nouvelle tâche qui gérera cette connexion. La gestion de la mémoire/de la sécurité des threads se déroule en coulisses. La gestion des contrats à terme est masquée par le async/await
sucre de syntaxe.
Cependant, il y a une question. TcpStream
et TlsStream
sont des objets différents, mais la manipulation des deux est exactement la même. Peut-on réutiliser le même code ? Dans Rust, l’abstraction est réalisée via Traits
qui sont super pratiques :
/// Tunnel via a client connection.
async fn tunnel_stream<C: AsyncRead + AsyncWrite + Send + Unpin + 'static>(
config: &ProxyConfiguration,
client_connection: C,
dns_resolver: DnsResolver,
) -> io::Result<()> {...}
Le flux doit implémenter :
AsyncRead /Write
: nous permet de le lire/écrire de manière asynchrone.Send
: Pour pouvoir envoyer entre les threads.Unpin
: Être mobile (sinon, on ne pourra pas faireasync move
ettokio::spawn
pour créer unasync
tâche).'static
: Pour indiquer qu’il peut vivre jusqu’à l’arrêt de l’application et ne dépend pas de la destruction d’un autre objet.
Que notre TCP/TLS
flux sont exactement. Cependant, nous pouvons maintenant voir qu’il n’est pas nécessaire TCP/TLS
ruisseaux. Ce code fonctionnerait pour UDP
, QUIC
ou alors ICMP
. Par exemple, il peut envelopper n’importe quel protocole dans n’importe quel autre protocole ou lui-même.
En d’autres termes, ce code est réutilisable, extensible et prêt pour la migration, qui se produit tôt ou tard.
Négociateur de connexion HTTP et connecteur cible
Arrêtons-nous une seconde et réfléchissons à un niveau supérieur. Et si nous pouvions faire abstraction du tunnel HTTP et que nous devions implémenter un tunnel générique ?

- Nous devons établir des connexions au niveau du transport (L4).
- Négocier une cible (peu importe comment : HTTP, PPv2, etc.).
- Établissez une connexion L4 à la cible.
- Signalez le succès et commencez à relayer les données.
Une cible pourrait être, par exemple, un autre tunnel. De plus, nous pouvons prendre en charge différents protocoles. Le noyau resterait le même.
Nous avons déjà vu que le tunnel_stream
la méthode fonctionne déjà avec n’importe quel L4 Client<->Tunnel
lien.
#[async_trait]
pub trait TunnelTarget {
type Addr;
fn addr(&self) -> Self::Addr;
}
#[async_trait]
pub trait TargetConnector {
type Target: TunnelTarget + Send + Sync + Sized;
type Stream: AsyncRead + AsyncWrite + Send + Sized + 'static;
async fn connect(&mut self, target: &Self::Target) -> io::Result<Self::Stream>;
}
Ici, nous spécifions deux abstractions :
TunnelTarget
est juste quelque chose qui a unAddr
– peu importe ce que c’est.TargetConnector
– peut se connecter à celaAddr
et doit renvoyer un flux prenant en charge les E/S asynchrones.
D’accord, mais qu’en est-il de la négociation cible ? Les tokio-utils
crate a déjà une abstraction pour cela, nommée Framed
flux (avec correspondant Encoder/Decoder
traits). Nous devons les mettre en œuvre pour HTTP CONNECT
(ou tout autre protocole proxy). Vous pouvez trouver la mise en œuvre ici.
Relais
Il ne nous reste qu’un seul composant majeur – qui relaie les données une fois la négociation du tunnel terminée. tokio
fournit une méthode pour diviser un flux en deux moitiés : ReadHalf
et WriteHalf
. Nous pouvons séparer les connexions client et cible et les relayer dans les deux sens :
let (client_recv, client_send) = io::split(client);
let (target_recv, target_send) = io::split(target);
let upstream_task =
tokio::spawn(
async move {
upstream_relay.relay_data(client_recv, target_send).await
});
let downstream_task =
tokio::spawn(
async move {
downstream_relay.relay_data(target_recv, client_send).await
});
Où le relay_data(…)
la définition ne nécessite rien de plus que la mise en œuvre des abstractions mentionnées ci-dessus. Par exemple, il peut connecter deux moitiés d’un flux :
/// Relays data in a single direction. E.g.
pub async fn relay_data<R: AsyncReadExt + Sized, W: AsyncWriteExt + Sized>(
self,
mut source: ReadHalf<R>,
mut dest: WriteHalf<W>,
) -> io::Result<RelayStats> {...}
Et enfin, au lieu d’un simple tunnel HTTP, nous avons un moteur qui peut être utilisé pour construire n’importe quel type de tunnels ou une chaîne de tunnels (par exemple, pour le routage en oignon) sur n’importe quel protocole de transport et proxy :
/// A connection tunnel.
///
/// # Parameters
/// * `<H>` - proxy handshake codec for initiating a tunnel.
/// It extracts the request message, which contains the target, and, potentially policies.
/// It also takes care of encoding a response.
/// * `<C>` - a connection from from client.
/// * `<T>` - target connector. It takes result produced by the codec and...