Reverse proxy mínimo en Rust: enrutar tráfico por Host
Inicia sesión para descargarAprende a construir un reverse proxy educativo en Rust usando solo la librería estándar. El proxy recibirá peticiones HTTP, leerá el header Host y decidirá si debe enviarlas a APP o a …
Contenido del tutorial ⌄
- Qué vamos a construir
- Requisitos previos
- Terminales que vamos a usar
- Qué problema resuelve enrutar por Host
- Modelo mental del flujo
- Alcance del tutorial
- Crear el proyecto Rust
- Configurar Cargo.toml
- Crear los upstreams demo
- Probar upstreams directamente
- Crear config.rs con tabla de rutas
- Crear error.rs
- Crear response.rs con respuestas HTTP
- Crear http.rs para leer peticiones HTTP
- Crear router.rs
- Agregar tests del router
Reverse proxy mínimo en Rust: enrutar tráfico por Host
En este tutorial construiremos un reverse proxy educativo en Rust usando únicamente la librería estándar.
La idea es recibir tráfico en un solo puerto, leer el header Host y decidir a qué servidor interno
reenviar cada petición.
En un servidor real puedes tener varias aplicaciones internas: una web, una API, un panel administrativo o servicios privados. El usuario no debería conocer los puertos internos. Debería entrar por un punto común y dejar que el proxy decida el destino correcto.
Nuestro proxy escuchará en 127.0.0.1:8080. Si llega una petición con
Host: app.localhost, la enviará a APP. Si llega con Host: api.localhost, la enviará a API.
app.localhostllegará a127.0.0.1:9001.api.localhostllegará a127.0.0.1:9002.- Un Host desconocido responderá
404 Not Found. - Un upstream apagado responderá
502 Bad Gateway.
Host.
Qué vamos a construir
Construiremos un reverse proxy mínimo en Rust que escuchará en 127.0.0.1:8080 y reenviará cada petición
al servidor interno correcto según el header Host.
app.localhost responderá desde APP, api.localhost responderá desde API y un Host no
configurado devolverá 404 Not Found.
Entrada común:
127.0.0.1:8080
Rutas:
app.localhost -> 127.0.0.1:9001 -> Hola desde APP
api.localhost -> 127.0.0.1:9002 -> Hola desde API
desconocido.localhost -> 404 Not Found
El proyecto quedará separado en módulos pequeños: configuración, lectura HTTP, enrutamiento, proxy, respuestas de error
y servidor TCP. Así evitamos mezclar toda la lógica en main.rs.
reverse_proxy_minimo/
├── Cargo.toml
└── src/
├── main.rs
├── config.rs
├── error.rs
├── http.rs
├── proxy.rs
├── response.rs
├── router.rs
├── server.rs
└── bin/
├── upstream_app.rs
└── upstream_api.rs
Al final comprobaremos el funcionamiento con curl.exe, ejecutando los dos upstreams, el proxy y las pruebas
desde terminales separadas.
cargo run --bin upstream_app
cargo run --bin upstream_api
cargo run
curl.exe -i -H "Host: app.localhost" http://127.0.0.1:8080/
curl.exe -i -H "Host: api.localhost" http://127.0.0.1:8080/
curl.exe -i -H "Host: desconocido.localhost" http://127.0.0.1:8080/
Requisitos previos
Antes de escribir código, verifica que tienes las herramientas básicas para compilar el proyecto y probar peticiones HTTP desde la terminal.
- Rust instalado.
- Cargo disponible.
curl.exedisponible.- PowerShell o una terminal equivalente.
- Editor de código.
- Conocimientos básicos de terminal y HTTP.
rustc --version
cargo --version
curl.exe --version
Si los tres comandos muestran una versión instalada, tu entorno está listo para crear y probar el proyecto.
curl.exe de forma explícita para evitar confusiones con alias de PowerShell.
Terminales que vamos a usar
Para probar el proxy necesitaremos varios procesos encendidos al mismo tiempo. Por eso usaremos cuatro terminales: dos
para los servidores internos, una para el proxy y otra para enviar peticiones con curl.exe.
Terminal 1 -> servidor APP
Terminal 2 -> servidor API
Terminal 3 -> reverse proxy
Terminal 4 -> pruebas con curl.exe
La idea es simple: APP y API permanecen encendidos, el proxy recibe las peticiones en
127.0.0.1:8080 y curl.exe actúa como cliente de prueba.
APP -> 127.0.0.1:9001
API -> 127.0.0.1:9002
Proxy -> 127.0.0.1:8080
curl -> envía peticiones al proxy
Qué problema resuelve enrutar por Host
Un proxy con un único destino interno es limitado: todo lo que recibe termina en el mismo servidor, aunque la petición vaya dirigida a servicios diferentes.
pub struct Config {
pub listen_addr: String,
pub upstream_addr: String,
}
Ese diseño sirve para empezar, pero no para decidir entre una aplicación web y una API. Para eso necesitamos usar el
header Host como criterio de ruta.
Host: app.localhost -> 127.0.0.1:9001
Host: api.localhost -> 127.0.0.1:9002
El reto ya no es solo recibir conexiones TCP. Ahora el proxy debe leer una parte de HTTP para escoger el upstream correcto.
Modelo mental del flujo
Antes de escribir el router, pensemos el proxy como una cadena corta de pasos: recibe una petición, lee el
Host, busca una ruta y reenvía al servidor correcto.
Cliente curl
↓
GET / HTTP/1.1
Host: api.localhost
↓
Proxy Rust :8080
↓
extrae Host
↓
api.localhost -> 127.0.0.1:9002
↓
Servidor API
↓
Cliente recibe la respuesta
TCP mueve bytes. HTTP les da forma. El proxy usa TCP para recibir la conexión y HTTP para entender a qué servicio debe enviar la petición.
1. Leer la petición.
2. Parsear headers.
3. Obtener Host.
4. Normalizar Host.
5. Buscar ruta.
6. Reenviar al upstream.
Alcance del tutorial
Esta versión será funcional, pero intencionalmente pequeña. La idea es entender el flujo de un reverse proxy sin esconder la lógica detrás de librerías externas.
Esta versión sí soporta:
HTTP/1.1 básico
GET
POST con Content-Length
Enrutamiento por Host
Host con puerto
400 Bad Request
404 Not Found
502 Bad Gateway
Connection: close
X-Forwarded-For
X-Forwarded-Proto
Threads por conexión
Tests unitarios
Esta versión no soporta:
HTTPS
HTTP/2
WebSockets
Chunked transfer encoding
Keep-alive real
Balanceo de carga
Rate limiting
Cache
Health checks
Tokio
Hyper
Axum
Reqwest
Producción real
Crear el proyecto Rust
Ahora sí vamos a crear la base del proyecto con Cargo. Entra a la carpeta donde guardas tus proyectos y ejecuta:
cargo new reverse_proxy_minimo
cd reverse_proxy_minimo
cargo run
Hello, world!
Hello, world!, el proyecto ya compila y podemos empezar a reemplazar el código inicial.
reverse_proxy_minimo, no ejecutes otra vez cargo new. Solo entra con cd reverse_proxy_minimo.
Configurar Cargo.toml
Este proyecto usará solo la librería estándar de Rust. Por eso el archivo Cargo.toml no tendrá dependencias externas.
[package]
name = "reverse_proxy_minimo"
version = "0.1.0"
edition = "2024"
default-run = "reverse_proxy_minimo"
[dependencies]
La línea default-run indica que, cuando ejecutemos cargo run, Cargo debe iniciar el proxy principal.
cargo check
cargo check termina sin errores, el proyecto está listo para crear los servidores internos de prueba.
Tokio, Hyper, Axum ni Reqwest. La idea es aprender TCP y HTTP mínimo desde abajo.
Crear los upstreams demo
Ahora crearemos dos servidores internos sencillos. Uno representará la APP y otro la API. Cada uno escuchará en un puerto diferente y responderá un texto distinto.
mkdir src\bin
New-Item -ItemType File -Force -Path src\bin\upstream_app.rs
New-Item -ItemType File -Force -Path src\bin\upstream_api.rs
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
const UPSTREAM_ADDR: &str = "127.0.0.1:9001";
const RESPONSE_BODY: &str = "Hola desde APP\n";
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind(UPSTREAM_ADDR)?;
println!("[INFO] Upstream APP escuchando en {UPSTREAM_ADDR}");
for stream_result in listener.incoming() {
match stream_result {
Ok(stream) => {
if let Err(error) = handle_connection(stream) {
eprintln!("[ERROR] Error manejando conexion en APP: {error}");
}
}
Err(error) => {
eprintln!("[ERROR] Error aceptando conexion en APP: {error}");
}
}
}
Ok(())
}
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
let mut buffer = [0_u8; 1024];
let bytes_read = stream.read(&mut buffer)?;
println!("[INFO] APP recibio {bytes_read} bytes");
let response = build_response();
stream.write_all(response.as_bytes())?;
stream.flush()?;
Ok(())
}
fn build_response() -> String {
format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{RESPONSE_BODY}",
RESPONSE_BODY.len()
)
}
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
const UPSTREAM_ADDR: &str = "127.0.0.1:9002";
const RESPONSE_BODY: &str = "Hola desde API\n";
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind(UPSTREAM_ADDR)?;
println!("[INFO] Upstream API escuchando en {UPSTREAM_ADDR}");
for stream_result in listener.incoming() {
match stream_result {
Ok(stream) => {
if let Err(error) = handle_connection(stream) {
eprintln!("[ERROR] Error manejando conexion en API: {error}");
}
}
Err(error) => {
eprintln!("[ERROR] Error aceptando conexion en API: {error}");
}
}
}
Ok(())
}
fn handle_connection(mut stream: TcpStream) -> std::io::Result<()> {
let mut buffer = [0_u8; 1024];
let bytes_read = stream.read(&mut buffer)?;
println!("[INFO] API recibio {bytes_read} bytes");
let response = build_response();
stream.write_all(response.as_bytes())?;
stream.flush()?;
Ok(())
}
fn build_response() -> String {
format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{RESPONSE_BODY}",
RESPONSE_BODY.len()
)
}
APP escuchará en 127.0.0.1:9001 y API escuchará en 127.0.0.1:9002. En la siguiente sección los probaremos directamente con curl.exe.
Probar upstreams directamente
Antes de conectar el proxy, probemos que APP y API responden por separado.
# Terminal 1
cargo run --bin upstream_app
# Salida esperada
[INFO] Upstream APP escuchando en 127.0.0.1:9001
# Terminal 2
cargo run --bin upstream_api
# Salida esperada
[INFO] Upstream API escuchando en 127.0.0.1:9002
# Terminal 3
curl.exe -i http://127.0.0.1:9001/
curl.exe -i http://127.0.0.1:9002/
Hola desde APP
Hola desde API
Si APP responde desde 127.0.0.1:9001 y API responde desde 127.0.0.1:9002, los upstreams están listos para conectarlos al reverse proxy.
Crear config.rs con tabla de rutas
Ahora reemplazaremos la idea de un único upstream por una tabla de rutas. Cada ruta tendrá un Host y un destino interno.
New-Item -ItemType File -Force -Path src\config.rs
#[derive(Debug, Clone)]
pub struct Route {
pub host: String,
pub upstream_addr: String,
}
#[derive(Debug, Clone)]
pub struct Config {
pub listen_addr: String,
pub routes: Vec<Route>,
}
impl Config {
pub fn from_env() -> Self {
let listen_addr =
std::env::var("PROXY_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".to_string());
let routes = vec![
Route {
host: std::env::var("PROXY_ROUTE_APP_HOST")
.unwrap_or_else(|_| "app.localhost".to_string()),
upstream_addr: std::env::var("PROXY_ROUTE_APP_UPSTREAM")
.unwrap_or_else(|_| "127.0.0.1:9001".to_string()),
},
Route {
host: std::env::var("PROXY_ROUTE_API_HOST")
.unwrap_or_else(|_| "api.localhost".to_string()),
upstream_addr: std::env::var("PROXY_ROUTE_API_UPSTREAM")
.unwrap_or_else(|_| "127.0.0.1:9002".to_string()),
},
];
Self {
listen_addr,
routes,
}
}
}
Proxy escucha en:
127.0.0.1:8080
Rutas:
app.localhost -> 127.0.0.1:9001
api.localhost -> 127.0.0.1:9002
Ahora la configuración ya permite varias rutas. El proxy podrá decidir el upstream usando el header Host.
Crear error.rs
Ahora definiremos los errores propios del proxy. Esto nos permitirá diferenciar una petición inválida, un Host sin ruta y un upstream caído.
New-Item -ItemType File -Force -Path src\error.rs
use std::fmt;
#[derive(Debug)]
pub enum ProxyError {
Io(std::io::Error),
InvalidRequest(String),
RouteNotFound(String),
UpstreamUnavailable(String),
}
impl fmt::Display for ProxyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProxyError::Io(error) => write!(f, "error de IO: {error}"),
ProxyError::InvalidRequest(message) => {
write!(f, "petición inválida: {message}")
}
ProxyError::RouteNotFound(message) => {
write!(f, "ruta no encontrada: {message}")
}
ProxyError::UpstreamUnavailable(message) => {
write!(f, "upstream no disponible: {message}")
}
}
}
}
impl std::error::Error for ProxyError {}
impl From<std::io::Error> for ProxyError {
fn from(error: std::io::Error) -> Self {
ProxyError::Io(error)
}
}
Ya tenemos errores separados: InvalidRequest para 400, RouteNotFound para 404 y UpstreamUnavailable para 502.
Crear response.rs con respuestas HTTP
Ahora centralizaremos las respuestas de error del proxy. Así evitamos escribir manualmente cada respuesta HTTP en varios archivos.
New-Item -ItemType File -Force -Path src\response.rs
pub fn bad_request(message: &str) -> Vec<u8> {
build_plain_text_response("400 Bad Request", message)
}
pub fn not_found(message: &str) -> Vec<u8> {
build_plain_text_response("404 Not Found", message)
}
pub fn bad_gateway(message: &str) -> Vec<u8> {
build_plain_text_response("502 Bad Gateway", message)
}
#[allow(dead_code)]
pub fn internal_server_error(message: &str) -> Vec<u8> {
build_plain_text_response("500 Internal Server Error", message)
}
fn build_plain_text_response(status: &str, body: &str) -> Vec<u8> {
let response = format!(
"HTTP/1.1 {status}\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n\
{body}",
body.len()
);
response.into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn construye_bad_request() {
let response = bad_request("Bad Request: falta el header Host");
let response_text = String::from_utf8(response).expect("respuesta UTF-8 valida");
assert!(response_text.starts_with("HTTP/1.1 400 Bad Request"));
assert!(response_text.contains("Connection: close"));
assert!(response_text.contains("Bad Request: falta el header Host"));
}
#[test]
fn construye_not_found() {
let response = not_found("No existe una ruta para el host: desconocido.localhost");
let response_text = String::from_utf8(response).expect("respuesta UTF-8 valida");
assert!(response_text.starts_with("HTTP/1.1 404 Not Found"));
assert!(response_text.contains("No existe una ruta para el host"));
}
#[test]
fn construye_bad_gateway() {
let response = bad_gateway("Bad Gateway: upstream apagado");
let response_text = String::from_utf8(response).expect("respuesta UTF-8 valida");
assert!(response_text.starts_with("HTTP/1.1 502 Bad Gateway"));
assert!(response_text.contains("Bad Gateway: upstream apagado"));
}
}
Ya tenemos funciones para construir respuestas 400, 404, 502 y 500 con Connection: close.
Crear http.rs para leer peticiones HTTP
Este archivo leerá bytes desde TCP, separará headers y body, y nos permitirá obtener el header Host.
New-Item -ItemType File -Force -Path src\http.rs
use std::io::Read;
use std::net::TcpStream;
use crate::error::ProxyError;
const MAX_REQUEST_SIZE: usize = 1024 * 1024;
const READ_BUFFER_SIZE: usize = 1024;
#[derive(Debug, Clone)]
pub struct HttpRequest {
pub method: String,
pub path: String,
pub version: String,
pub headers: Vec<Header>,
pub body: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct Header {
pub name: String,
pub value: String,
}
pub fn read_http_request(stream: &mut TcpStream) -> Result<Vec<u8>, ProxyError> {
let mut request = Vec::new();
let mut buffer = [0_u8; READ_BUFFER_SIZE];
loop {
let bytes_read = stream.read(&mut buffer)?;
if bytes_read == 0 {
if request.is_empty() {
return Err(ProxyError::InvalidRequest(
"el cliente cerro la conexion sin enviar datos".to_string(),
));
}
break;
}
request.extend_from_slice(&buffer[..bytes_read]);
if request.len() > MAX_REQUEST_SIZE {
return Err(ProxyError::InvalidRequest(format!(
"la peticion supera el limite de {MAX_REQUEST_SIZE} bytes"
)));
}
if let Some(total_expected_size) = expected_total_request_size(&request)? {
if total_expected_size > MAX_REQUEST_SIZE {
return Err(ProxyError::InvalidRequest(format!(
"la peticion supera el limite de {MAX_REQUEST_SIZE} bytes"
)));
}
if request.len() >= total_expected_size {
break;
}
}
}
Ok(request)
}
pub fn parse_request(raw: &[u8]) -> Result<HttpRequest, ProxyError> {
let header_end = find_header_end(raw).ok_or_else(|| {
ProxyError::InvalidRequest("no se encontro el final de los headers HTTP".to_string())
})?;
let header_bytes = &raw[..header_end];
let body_start = header_end + 4;
let header_text = std::str::from_utf8(header_bytes).map_err(|_| {
ProxyError::InvalidRequest("los headers HTTP no son UTF-8 validos".to_string())
})?;
let mut lines = header_text.lines();
let request_line = lines
.next()
.ok_or_else(|| ProxyError::InvalidRequest("falta la linea inicial".to_string()))?;
let (method, path, version) = parse_request_line(request_line)?;
let header_lines: Vec<&str> = lines.collect();
let headers = parse_headers(&header_lines)?;
validate_content_length(&headers)?;
let available_body = if body_start <= raw.len() {
&raw[body_start..]
} else {
&[]
};
let content_length = find_content_length(&headers).unwrap_or(available_body.len());
if available_body.len() < content_length {
return Err(ProxyError::InvalidRequest(format!(
"body incompleto: se esperaban {content_length} bytes, llegaron {}",
available_body.len()
)));
}
let body = available_body[..content_length].to_vec();
Ok(HttpRequest {
method,
path,
version,
headers,
body,
})
}
pub fn build_http_request(request: &HttpRequest) -> Vec<u8> {
let mut raw_request = Vec::new();
let request_line = format!(
"{} {} {}\r\n",
request.method, request.path, request.version
);
raw_request.extend_from_slice(request_line.as_bytes());
for header in &request.headers {
let header_line = format!("{}: {}\r\n", header.name, header.value);
raw_request.extend_from_slice(header_line.as_bytes());
}
raw_request.extend_from_slice(b"\r\n");
raw_request.extend_from_slice(&request.body);
raw_request
}
pub fn get_header_value<'a>(request: &'a HttpRequest, name: &str) -> Option<&'a str> {
request
.headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case(name))
.map(|header| header.value.as_str())
}
fn expected_total_request_size(raw: &[u8]) -> Result<Option<usize>, ProxyError> {
let Some(header_end) = find_header_end(raw) else {
return Ok(None);
};
let header_bytes = &raw[..header_end];
let body_start = header_end + 4;
let header_text = std::str::from_utf8(header_bytes).map_err(|_| {
ProxyError::InvalidRequest("los headers HTTP no son UTF-8 validos".to_string())
})?;
let mut lines = header_text.lines();
lines
.next()
.ok_or_else(|| ProxyError::InvalidRequest("falta la linea inicial".to_string()))?;
let header_lines: Vec<&str> = lines.collect();
let headers = parse_headers(&header_lines)?;
validate_content_length(&headers)?;
let content_length = find_content_length(&headers).unwrap_or(0);
Ok(Some(body_start + content_length))
}
fn parse_request_line(line: &str) -> Result<(String, String, String), ProxyError> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 3 {
return Err(ProxyError::InvalidRequest(format!(
"linea inicial invalida: {line}"
)));
}
let method = parts[0];
let path = parts[1];
let version = parts[2];
if method.is_empty() {
return Err(ProxyError::InvalidRequest(
"el metodo HTTP esta vacio".to_string(),
));
}
if path.is_empty() {
return Err(ProxyError::InvalidRequest(
"la ruta HTTP esta vacia".to_string(),
));
}
if !version.starts_with("HTTP/") {
return Err(ProxyError::InvalidRequest(format!(
"version HTTP invalida: {version}"
)));
}
Ok((method.to_string(), path.to_string(), version.to_string()))
}
fn parse_headers(lines: &[&str]) -> Result<Vec<Header>, ProxyError> {
let mut headers = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
let (name, value) = line.split_once(':').ok_or_else(|| {
ProxyError::InvalidRequest(format!("header invalido, falta ':': {line}"))
})?;
let name = name.trim();
let value = value.trim();
if name.is_empty() {
return Err(ProxyError::InvalidRequest(
"header con nombre vacio".to_string(),
));
}
if name.chars().any(char::is_whitespace) {
return Err(ProxyError::InvalidRequest(format!(
"header con nombre invalido: {name}"
)));
}
headers.push(Header {
name: name.to_string(),
value: value.to_string(),
});
}
Ok(headers)
}
fn validate_content_length(headers: &[Header]) -> Result<(), ProxyError> {
for header in headers {
if header.name.eq_ignore_ascii_case("Content-Length") {
header.value.parse::<usize>().map_err(|_| {
ProxyError::InvalidRequest(format!("Content-Length invalido: {}", header.value))
})?;
}
}
Ok(())
}
fn find_content_length(headers: &[Header]) -> Option<usize> {
headers
.iter()
.find(|header| header.name.eq_ignore_ascii_case("Content-Length"))
.and_then(|header| header.value.parse::<usize>().ok())
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detecta_fin_de_headers_http() {
let request = b"GET /hola HTTP/1.1\r\nHost: localhost\r\n\r\n";
assert!(find_header_end(request).is_some());
}
#[test]
fn no_detecta_fin_de_headers_incompleto() {
let request = b"GET /hola HTTP/1.1\r\nHost: localhost\r\n";
assert!(find_header_end(request).is_none());
}
#[test]
fn parsea_get_simple() {
let raw = b"GET /hola HTTP/1.1\r\nHost: localhost:8080\r\nUser-Agent: curl/8.0\r\n\r\n";
let request = parse_request(raw).expect("la peticion GET debe parsearse");
assert_eq!(request.method, "GET");
assert_eq!(request.path, "/hola");
assert_eq!(request.version, "HTTP/1.1");
assert_eq!(request.headers.len(), 2);
assert_eq!(request.headers[0].name, "Host");
assert_eq!(request.headers[0].value, "localhost:8080");
assert!(request.body.is_empty());
}
#[test]
fn rechaza_request_line_invalida() {
let raw = b"HOLA_MAL\r\nHost: localhost:8080\r\n\r\n";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn rechaza_version_http_invalida() {
let raw = b"GET /hola TCP/1.1\r\nHost: localhost:8080\r\n\r\n";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn rechaza_header_sin_dos_puntos() {
let raw = b"GET /hola HTTP/1.1\r\nHost localhost:8080\r\n\r\n";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn rechaza_header_con_nombre_vacio() {
let raw = b"GET /hola HTTP/1.1\r\n: localhost:8080\r\n\r\n";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn rechaza_header_con_espacios_en_nombre() {
let raw = b"GET /hola HTTP/1.1\r\nBad Header: value\r\n\r\n";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn detecta_content_length() {
let headers = vec![
Header {
name: "Host".to_string(),
value: "localhost:8080".to_string(),
},
Header {
name: "Content-Length".to_string(),
value: "15".to_string(),
},
];
assert_eq!(find_content_length(&headers), Some(15));
}
#[test]
fn detecta_content_length_sin_importar_mayusculas() {
let headers = vec![Header {
name: "content-length".to_string(),
value: "20".to_string(),
}];
assert_eq!(find_content_length(&headers), Some(20));
}
#[test]
fn parsea_post_con_body() {
let raw = b"POST /api HTTP/1.1\r\nHost: localhost:8080\r\nContent-Type: text/plain\r\nContent-Length: 15\r\n\r\nhola desde curl";
let request = parse_request(raw).expect("la peticion POST debe parsearse");
assert_eq!(request.method, "POST");
assert_eq!(request.path, "/api");
assert_eq!(request.version, "HTTP/1.1");
assert_eq!(request.body, b"hola desde curl");
assert_eq!(request.body.len(), 15);
}
#[test]
fn parsea_post_con_content_length() {
let raw =
b"POST /form HTTP/1.1\r\nHost: localhost:8080\r\nContent-Length: 10\r\n\r\nhola mundo";
let request = parse_request(raw).expect("la peticion POST debe parsearse");
assert_eq!(request.method, "POST");
assert_eq!(request.path, "/form");
assert_eq!(request.body, b"hola mundo");
}
#[test]
fn usa_content_length_para_limitar_el_body() {
let raw =
b"POST /api HTTP/1.1\r\nHost: localhost:8080\r\nContent-Length: 4\r\n\r\nholaEXTRA";
let request = parse_request(raw).expect("la peticion POST debe parsearse");
assert_eq!(request.body, b"hola");
}
#[test]
fn rechaza_post_con_body_incompleto() {
let raw = b"POST /api HTTP/1.1\r\nHost: localhost:8080\r\nContent-Length: 20\r\n\r\nhola";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn rechaza_content_length_invalido() {
let raw = b"POST /api HTTP/1.1\r\nHost: localhost:8080\r\nContent-Length: abc\r\n\r\nhola";
let result = parse_request(raw);
assert!(result.is_err());
}
#[test]
fn reconstruye_peticion_get() {
let request = HttpRequest {
method: "GET".to_string(),
path: "/hola".to_string(),
version: "HTTP/1.1".to_string(),
headers: vec![
Header {
name: "Host".to_string(),
value: "127.0.0.1:9000".to_string(),
},
Header {
name: "Connection".to_string(),
value: "close".to_string(),
},
],
body: Vec::new(),
};
let raw = build_http_request(&request);
let raw_text = String::from_utf8(raw).expect("la peticion reconstruida debe ser UTF-8");
assert_eq!(
raw_text,
"GET /hola HTTP/1.1\r\nHost: 127.0.0.1:9000\r\nConnection: close\r\n\r\n"
);
}
#[test]
fn reconstruye_peticion_post_con_body() {
let request = HttpRequest {
method: "POST".to_string(),
path: "/api".to_string(),
version: "HTTP/1.1".to_string(),
headers: vec![
Header {
name: "Host".to_string(),
value: "127.0.0.1:9000".to_string(),
},
Header {
name: "Content-Type".to_string(),
value: "text/plain".to_string(),
},
Header {
name: "Content-Length".to_string(),
value: "15".to_string(),
},
],
body: b"hola desde curl".to_vec(),
};
let raw = build_http_request(&request);
let raw_text = String::from_utf8(raw).expect("la peticion reconstruida debe ser UTF-8");
assert_eq!(
raw_text,
"POST /api HTTP/1.1\r\nHost: 127.0.0.1:9000\r\nContent-Type: text/plain\r\nContent-Length: 15\r\n\r\nhola desde curl"
);
}
#[test]
fn reconstruye_peticion_con_headers_proxy() {
let request = HttpRequest {
method: "GET".to_string(),
path: "/hola".to_string(),
version: "HTTP/1.1".to_string(),
headers: vec![
Header {
name: "Host".to_string(),
value: "127.0.0.1:9000".to_string(),
},
Header {
name: "Connection".to_string(),
value: "close".to_string(),
},
Header {
name: "X-Forwarded-For".to_string(),
value: "127.0.0.1".to_string(),
},
Header {
name: "X-Forwarded-Proto".to_string(),
value: "http".to_string(),
},
],
body: Vec::new(),
};
let raw = build_http_request(&request);
let raw_text = String::from_utf8(raw).expect("la peticion reconstruida debe ser UTF-8");
assert!(raw_text.contains("Host: 127.0.0.1:9000\r\n"));
assert!(raw_text.contains("Connection: close\r\n"));
assert!(raw_text.contains("X-Forwarded-For: 127.0.0.1\r\n"));
assert!(raw_text.contains("X-Forwarded-Proto: http\r\n"));
}
}
Ya tenemos lectura HTTP mínima: podemos leer una petición, separar headers, manejar Content-Length y buscar el header Host.
Crear router.rs
El router tomará el header Host, lo normalizará y buscará el upstream correcto en la tabla de rutas.
New-Item -ItemType File -Force -Path src\router.rs
use crate::config::Route;
use crate::error::ProxyError;
pub fn resolve_upstream(host_header: &str, routes: &[Route]) -> Result<String, ProxyError> {
let normalized_host = normalize_host(host_header)?;
routes
.iter()
.find(|route| route.host.eq_ignore_ascii_case(&normalized_host))
.map(|route| route.upstream_addr.clone())
.ok_or_else(|| {
ProxyError::RouteNotFound(format!(
"No existe una ruta para el host: {normalized_host}"
))
})
}
fn normalize_host(host_header: &str) -> Result<String, ProxyError> {
let host = host_header.trim();
if host.is_empty() {
return Err(ProxyError::InvalidRequest(
"el header Host está vacío".to_string(),
));
}
let host_without_port = host.split(':').next().unwrap_or(host).trim();
if host_without_port.is_empty() {
return Err(ProxyError::InvalidRequest(
"el header Host está vacío".to_string(),
));
}
Ok(host_without_port.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
fn routes() -> Vec<Route> {
vec![
Route {
host: "app.localhost".to_string(),
upstream_addr: "127.0.0.1:9001".to_string(),
},
Route {
host: "api.localhost".to_string(),
upstream_addr: "127.0.0.1:9002".to_string(),
},
]
}
#[test]
fn encuentra_app_localhost() {
let upstream = resolve_upstream("app.localhost", &routes()).unwrap();
assert_eq!(upstream, "127.0.0.1:9001");
}
#[test]
fn encuentra_api_localhost() {
let upstream = resolve_upstream("api.localhost", &routes()).unwrap();
assert_eq!(upstream, "127.0.0.1:9002");
}
#[test]
fn normaliza_host_con_puerto() {
let upstream = resolve_upstream("app.localhost:8080", &routes()).unwrap();
assert_eq!(upstream, "127.0.0.1:9001");
}
#[test]
fn normaliza_host_con_mayusculas() {
let upstream = resolve_upstream("APP.localhost", &routes()).unwrap();
assert_eq!(upstream, "127.0.0.1:9001");
}
#[test]
fn rechaza_host_desconocido() {
let result = resolve_upstream("desconocido.localhost", &routes());
assert!(matches!(result, Err(ProxyError::RouteNotFound(_))));
}
#[test]
fn rechaza_host_vacio() {
let result = resolve_upstream(" ", &routes());
assert!(matches!(result, Err(ProxyError::InvalidRequest(_))));
}
}
El router ya puede convertir app.localhost:8080 en app.localhost y buscar su upstream.
Agregar tests del router
Ahora validaremos que el router encuentre APP, encuentre API, soporte Host con puerto, ignore mayúsculas y rechace Hosts inválidos.
cargo test router
# Resultado esperado
test result: ok
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!