Aller au contenu

Rust & Chaos Engineering : créer un proxy TCP interactif

Cet article documente la création d'un proxy TCP interactif en Rust qui permet la simulation en temps réel de diverses conditions réseau, de l'injection de latence au blocage complet de la connexion.

Se lancer dans Rust

Pour tester la résilience d'une application dans des conditions réseau défavorables, il faut généralement modifier l'infrastructure de production ou utiliser des outils de simulation complexes. Et si vous pouviez simplement placer un proxy entre votre application et n'importe quel service, puis injecter dynamiquement des pannes, des latences ou des contraintes de bande passante ?

C'est l'objectif recherché : un proxy TCP léger doté d'un menu interactif qui vous permet de basculer à la volée entre différents comportements réseau.

L'architecture


Choix technologiques


Le proxy est construit à l'aide de :

Rust - pour la sécurité de la mémoire et les performances
Tokio 1.0 - runtime asynchrone pour les E/S simultanées
Crossterm 0.27 - manipulation de terminal multiplateforme

Ces choix n'ont pas été faits au hasard. Tokio fournit des primitives d'E/S asynchrones matures, Crossterm gère le contrôle des terminaux sur toutes les plateformes et le système de types de Rust empêche les bugs subtils qui affectent le code réseau.

Composants principaux


Le système se compose de trois composants simultanés :

  • Écouteur TCP - accepte les connexions entrantes.
  • Gestionnaires de connexion - un généré par connexion pour le transfert bidirectionnel.
  • Menu interactif - s'exécute dans un thread bloquant, actualise l'état partagé.

Tous les composants partagent l'accès au mode proxy actuel via Arc<Mutex<ProxyMode>>, ce qui permet de modifier le comportement en temps réel sans redémarrer.

Le défi bidirectionnel


Première tentative (le piège)


La mise en œuvre initiale semblait logique : lecture depuis le client, transfert vers le serveur, lecture depuis le serveur, transfert vers le client :

loop {
    // Read from client
    let n = incoming.read(&mut buf_in).await?;
    outgoing.write_all(&buf_in[..n]).await?;

    // Read from server
    let n = outgoing.read(&mut buf_out).await?;
    incoming.write_all(&buf_out[..n]).await?;
}

Ce code compile. Il semble raisonnable. Il ne fonctionne pas.

Lorsque vous le testez avec une simple requête HTTP, la connexion reste bloquée indéfiniment. Le client envoie une requête et attend une réponse, mais le proxy reste bloqué en attendant de lire PLUS du client au lieu de lire la réponse du serveur.

La réalisation


Il s'agit d'une communication semi-duplex : un seul sens fonctionne à la fois. Le protocole TCP nécessite une communication full-duplex où les deux sens fonctionnent indépendamment et simultanément.

Pensez-y comme à un appel téléphonique. Si vous ne pouviez qu'écouter OU parler (mais pas les deux en même temps), les conversations seraient impossibles. Les connexions TCP fonctionnent de la même manière : les données doivent circuler dans les deux sens simultanément.

La solution


La solution consiste à diviser chaque connexion en deux parties indépendantes, l'une pour la lecture et l'autre pour l'écriture, puis à exécuter deux tâches simultanées :

// Split into independent halves
let (mut client_read, mut client_write) = incoming.into_split();
let (mut server_read, mut server_write) = outgoing.into_split();

// Spawn concurrent forwarding tasks
let client_to_server = tokio::spawn(async move {
    transfer_data(&mut client_read, &mut server_write, mode_c2s, "client->server").await
});

let server_to_client = tokio::spawn(async move {
    transfer_data(&mut server_read, &mut client_write, mode_s2c, "server->client").await
});

// Wait for either direction to complete
tokio::select! {
    _ = client_to_server => {},
    _ = server_to_client => {},
}

Désormais, les deux directions fonctionnent simultanément. Lorsqu'un client envoie une requête HTTP, la tâche client→serveur la transfère immédiatement tandis que la tâche serveur→client attend la réponse. Véritable transfert bidirectionnel.

Modes proxy


Le proxy prend en charge cinq modes de fonctionnement :

Autoriser


Fonctionnement normal - le trafic circule de manière transparente sans modification.

ProxyMode::Allow => (0, 0, 0) // no latency, timeout, or throttle

Refuser


Bloque immédiatement toutes les connexions entrantes.

ProxyMode::Deny => {
    drop(incoming);
    return;
}

Cas d'utilisation : tester le comportement d'une application lorsque les services sont totalement indisponibles.

Latence


Ajoute un délai artificiel à chaque paquet dans les deux sens.

ProxyMode::Latency(ms) => {
    if latency > 0 {
        time::sleep(Duration::from_millis(latency)).await;
    }
    // then transfer data
}

Cas d'utilisation : simulation d'une distance géographique ou de réseaux lents (par exemple, 200 ms pour les connexions intercontinentales).

Délai d'expiration


Provoque l'expiration des connexions après une durée spécifiée.

match time::timeout(dur, reader.read(&mut buf)).await {
    Ok(Ok(n)) => n,
    Ok(Err(e)) => return Err(e),
    Err(_) => {
        println!("\r⏱ Timeout on {}", direction);
        break;
    }
}

Cas d'utilisation : tester la logique de réessai et la gestion des délais d'expiration dans les applications.

Limitation

Limite la bande passante en calculant le temps de veille requis en fonction des octets transférés.

if throttle > 0 {
    let sleep_duration = n as f64 / throttle as f64;
    time::sleep(Duration::from_secs_f64(sleep_duration)).await;
}

Cas d'utilisation : simulation de connexions mobiles lentes (par exemple, 10 Ko/s pour la 3G).

Le menu interactif


Problème de threading


Le menu nécessite un blocage des E/S pour la saisie au clavier, tandis que le proxy nécessite des E/S asynchrones pour les opérations réseau. Ces deux éléments ne sont pas compatibles.

Solution: isoler le menu dans un thread bloquant dédié à l'aide de Tokio :

let mode_clone = mode.clone();
tokio::task::spawn_blocking(move || {
    loop {
        if let Err(e) = select_mode(mode_clone.clone()) {
            eprintln!("Menu error: {}", e);
            break;
        }
    }
});

Cela empêche les opérations de blocage d'interférer avec les E/S réseau asynchrones.

Contrôle du terminal


Le menu utilise le mode brut de Crossterm pour la saisie clavier en temps réel :

enable_raw_mode()?;
execute!(stdout, cursor::Hide)?;

loop {
    // Display menu options
    execute!(stdout, cursor::MoveTo(0, 0), Clear(ClearType::FromCursorDown))?;
    write!(stdout, "Select proxy mode (arrows + Enter, Ctrl+C to quit):\r\n\r\n")?;

    for (i, option) in options.iter().enumerate() {
        if i == selected {
            write!(stdout, "> {}\r\n", option)?;
        } else {
            write!(stdout, "  {}\r\n", option)?;
        }
    }

    // Handle input
    if let Event::Key(key) = event::read()? {
        match key.code {
            KeyCode::Up => if selected > 0 { selected -= 1 },
            KeyCode::Down => if selected < options.len() - 1 { selected += 1 },
            KeyCode::Enter => {
                // Process selection
                disable_raw_mode()?;
                // ... handle mode selection
            }
        }
    }
}

Le mode brut est activé lors de l'affichage du menu et désactivé lors de la demande de saisie de valeur, ce qui empêche la corruption de l'état du terminal.

Gestion de l'état partagé


Plusieurs gestionnaires de connexion doivent lire le mode actuel pendant que le menu le met à jour. Cela nécessite un état partagé thread-safe :

let mode = Arc::new(Mutex::new(ProxyMode::Allow));

Chaque gestionnaire de connexion lit le mode actuel avant chaque transfert :

let current_mode = {
    let m = mode.lock().unwrap();
    *m
};

Lorsque l'utilisateur modifie le mode via le menu, tous les gestionnaires voient immédiatement la mise à jour lors de leur prochaine opération de lecture. Cela permet des changements de comportement dynamiques sans interrompre les connexions existantes.

Gestion des signaux


La gestion de Ctrl+C s'exécute dans une tâche asynchrone distincte afin de garantir un arrêt propre :

tokio::spawn(async move {
    signal::ctrl_c().await.unwrap();
    println!("\r\nCtrl+C detected, exiting...");
    std::process::exit(0);
});

Cela fonctionne que si l'utilisateur est dans le menu ou que le proxy gère les connexions.

Caractéristiques de performance

Utilisation de la mémoire

Surcoût de base : minimal (mode partagé dans Arc)

Par connexion : ~16 Ko (tampon de 8 Ko × 2 directions)

Évolue linéairement avec les connexions simultanées

La taille du tampon de 8 Ko équilibre le débit et l'utilisation de la mémoire. Des tampons plus grands améliorent le débit pour les transferts à haut débit, mais augmentent la consommation de mémoire.

Utilisation du processeur

Mode Allow : surcharge minimale - principalement des appels système pour la lecture/écriture.

Modes Latency/Throttle : plus élevée en raison des opérations de veille.

Tous les modes : pas d'attente active - utilise la veille asynchrone.

Concurrence

Chaque connexion génère 2 tâches asynchrones. Le planificateur de vol de travail de Tokio gère efficacement des milliers de connexions simultanées sur du matériel moderne.

Limitations

  • Mode application timing

Les modes s'appliquent aux nouvelles opérations de lecture, et non aux transferts en cours. Si un transfert de fichier volumineux est en cours lorsque vous passez du mode "Autoriser" au mode "Latence", le transfert en cours se termine à pleine vitesse. La nouvelle latence s'appliquera à la prochaine opération de lecture.

  • Précision de la limitation

La limitation utilise des délais de veille plutôt que des algorithmes de type « token bucket » ou « leaky bucket ». Cela permet une limitation approximative de la bande passante, adaptée aux tests, mais pas un contrôle précis du débit.

  • Indépendant du protocole

Le proxy fonctionne au niveau TCP. Il ne peut pas inspecter ni modifier :

    • Le trafic HTTPS/TLS (crypté) ;
    • Les en-têtes HTTP (aucune reconnaissance du protocole) ;
    • Les protocoles de la couche application.

Il s'agit à la fois d'une limitation et d'une fonctionnalité : l'indépendance du protocole signifie que le proxy fonctionne avec n'importe quel protocole basé sur TCP.

  • Fonctionnement basé sur des tampons

Utilise des tampons fixes de 8 Ko. Les messages très volumineux sont automatiquement fragmentés par TCP, mais cela n'est optimisé pour aucune limite de message de protocole spécifique.

Approche de test

Le proxy a été validé à l'aide des éléments suivants :

  • Trafic HTTP : requêtes curl via le proxy vers divers serveurs web
  • Connexions à la base de données : connexions client PostgreSQL via le proxy
  • Connexions de longue durée : connexions WebSocket pour vérifier la gestion bidirectionnelle
  • Transitions de mode : changement de mode pendant les transferts actifs
  • Charge simultanée : connexions multiples simultanées sous différents modes

Cas d'utilisation

Test de la résilience des services

tcp-proxy 127.0.0.1:9000 backend-service:8080
# Switch to Deny mode to simulate service downtime
# Verify application handles failures gracefully

Simulation de la distance géographique

tcp-proxy 127.0.0.1:8080 api.example.com:443
# Add 200ms latency to simulate intercontinental requests
# Check if UI shows loading indicators appropriately

Débogage des problèmes de délai d'expiration

tcp-proxy 127.0.0.1:5433 postgres:5432
# Use Timeout mode (100ms) to trigger timeouts
# Observe retry behavior and error handling

Test des contraintes de bande passante

tcp-proxy 127.0.0.1:8000 file-server:80
# Throttle to 10KB/s to simulate 3G connection
# Test progressive loading and user experience

Détails de la mise en œuvre


La mise en œuvre complète tient dans environ 250 lignes de code Rust :

tcp-proxy/
├── Cargo.toml
└── src/
    └── main.rs
        ├── ProxyMode enum
        ├── main() - setup and listener
        ├── select_mode() - interactive menu
        ├── handle_connection() - connection setup
        └── transfer_data() - actual forwarding logic

Idées principales


La conclusion essentielle a été de reconnaître que le proxy TCP nécessite un transfert bidirectionnel véritablement simultané, et non un traitement séquentiel des requêtes-réponses. La mise en œuvre semi-duplex initiale fonctionnerait pour des protocoles ping-pong simples, mais échouerait pour les modèles de trafic TCP réels.

L'approche du menu interactif, bien qu'elle nécessite une gestion minutieuse des threads entre les contextes bloquants et asynchrones, offre des avantages significatifs en termes de convivialité par rapport aux fichiers de configuration lors des tests manuels de résilience des applications.

Conclusion


La création de ce proxy a révélé à quel point des exigences apparemment simples, telles que « transférer le trafic TCP », cachent une complexité importante. Le défi du transfert bidirectionnel démontre pourquoi il est plus important de comprendre la sémantique du protocole sous-jacent que d'écrire du code qui compile.

Le résultat est un outil pratique pour tester la résilience du réseau qui fonctionne partout où Rust fonctionne, ne nécessite aucune configuration et vous permet d'injecter des défaillances de manière interactive. Que vous testiez la gestion des délais d'expiration, simuliez la latence géographique ou pratiquiez l'ingénierie du chaos, disposer d'un proxy programmable dans votre boîte à outils facilite ces tâches.

Ressources

GitHub - daasrattale/tcp-toggle-proxy: Interactive lightweight TCP proxy for network testing and chaos engineering. Simulate latency, timeouts, throttling, and connection failures on-the-fly without restarting. Built with Rust and Tokio for high-performance bidirectional forwarding. Perfect for testing application resilience and debugging network issues.
Interactive lightweight TCP proxy for network testing and chaos engineering. Simulate latency, timeouts, throttling, and connection failures on-the-fly without restarting. Built with Rust and Tokio…

Dernier