diff --git a/Cargo.lock b/Cargo.lock index 47bb4c6..820f2c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3384,6 +3384,7 @@ name = "localup-cli" version = "0.1.0" dependencies = [ "anyhow", + "axum", "chrono", "clap", "dirs 5.0.1", @@ -3737,17 +3738,21 @@ dependencies = [ name = "localup-server-tls" version = "0.1.0" dependencies = [ + "chrono", "localup-cert", "localup-control", "localup-proto", + "localup-relay-db", "localup-router", "localup-transport", "localup-transport-quic", "rustls 0.23.32", + "sea-orm", "thiserror 1.0.69", "tokio", "tokio-rustls 0.26.4", "tracing", + "uuid", ] [[package]] diff --git a/Makefile b/Makefile index 8a79a2e..02b7a1b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for localup development -.PHONY: build build-release relay relay-https relay-http tunnel tunnel-https tunnel-custom-domain test test-server test-daemon clean gen-cert gen-cert-if-needed gen-cert-custom-domain gen-token register-custom-domain list-custom-domains daemon-config daemon-start daemon-stop daemon-status daemon-tunnel-start daemon-tunnel-stop daemon-reload daemon-quick-test help +.PHONY: build build-release relay relay-https relay-http relay-tls relay-tls-http tunnel tunnel-https tunnel-custom-domain tunnel-tls tunnel-tls-http test test-server tls-server https-server http-server test-daemon clean gen-cert gen-cert-if-needed gen-cert-custom-domain gen-cert-tls gen-token register-custom-domain list-custom-domains daemon-config daemon-start daemon-stop daemon-status daemon-tunnel-start daemon-tunnel-stop daemon-reload daemon-quick-test help # Default target help: @@ -14,11 +14,13 @@ help: @echo " make relay - Start HTTPS relay with localho.st domain (default)" @echo " make relay-https - Start HTTPS relay with localho.st domain" @echo " make relay-http - Start HTTP-only relay with localho.st domain" + @echo " make relay-tls - Start TLS/SNI relay (passthrough, no termination)" @echo "" @echo "Client targets:" @echo " make tunnel - Start HTTPS tunnel client (LOCAL_PORT=8080, SUBDOMAIN=myapp)" @echo " make tunnel-https - Same as tunnel" @echo " make tunnel-custom-domain CUSTOM_DOMAIN=api.example.com - Start tunnel with custom domain" + @echo " make tunnel-tls SNI_DOMAINS=test1.example.com,test2.example.com - Start TLS tunnel with SNI patterns" @echo "" @echo "Custom Domain targets:" @echo " make gen-cert-custom-domain CUSTOM_DOMAIN=api.example.com - Generate cert for custom domain" @@ -34,6 +36,17 @@ help: @echo " make daemon-reload - Reload config via IPC" @echo " make test-daemon - Show full daemon test instructions" @echo "" + @echo "TLS/SNI Testing targets:" + @echo " make relay-tls - Start TLS/SNI passthrough relay" + @echo " make relay-tls-http - Start TLS/SNI relay with HTTP passthrough (both ports)" + @echo " make tls-server - Start TLS echo test server on port 9443" + @echo " make https-server - Start HTTPS test server on port 9443 (HTTP over TLS)" + @echo " make http-server - Start HTTP test server on port 9080 (for HTTP passthrough)" + @echo " make tunnel-tls - Start TLS tunnel (TLS → port 9443)" + @echo " make tunnel-tls-http - Start TLS tunnel with HTTP passthrough (TLS → 9443, HTTP → 9080)" + @echo " make gen-cert-tls - Generate TLS certificates for backend server" + @echo " make test-tls - Show full TLS testing instructions" + @echo "" @echo "Utility targets:" @echo " make gen-cert - Generate self-signed certificates for localho.st" @echo " make gen-token - Generate a JWT token for testing" @@ -60,6 +73,7 @@ HTTP_ADDR ?= 0.0.0.0:28080 HTTPS_ADDR ?= 0.0.0.0:28443 API_ADDR ?= 0.0.0.0:3080 DOMAIN ?= localho.st +# Log level: info, debug, trace (use LOG_LEVEL=debug make for verbose output) LOG_LEVEL ?= info ADMIN_EMAIL ?= admin@localho.st ADMIN_PASSWORD ?= admin123 @@ -75,6 +89,16 @@ USER_ID ?= 1 CUSTOM_DOMAIN ?= api.example.com CUSTOM_DOMAIN_CERT_DIR ?= ./certs +# TLS/SNI configuration +TLS_ADDR ?= 0.0.0.0:28443 +TLS_BACKEND_PORT ?= 9443 +TLS_CERT_DIR ?= ./certs/tls +SNI_DOMAINS ?= test1.example.com,test2.example.com,*.example.com + +# HTTP passthrough configuration (for TLS relay) +HTTP_PASSTHROUGH_ADDR ?= 0.0.0.0:28080 +HTTP_BACKEND_PORT ?= 9080 + # Certificate paths CERT_FILE ?= localhost-cert.pem KEY_FILE ?= localhost-key.pem @@ -294,6 +318,320 @@ test-server: print('Test server running on http://localhost:$(LOCAL_PORT)'); \ HTTPServer(('', $(LOCAL_PORT)), H).serve_forever()" +# ========================================== +# TLS/SNI Passthrough Testing Targets +# ========================================== + +# Generate TLS certificates for backend server +gen-cert-tls: + @mkdir -p $(TLS_CERT_DIR) + @echo "Generating TLS backend certificates..." + openssl genpkey -algorithm RSA -out $(TLS_CERT_DIR)/backend.key -pkeyopt rsa_keygen_bits:2048 + openssl req -new -key $(TLS_CERT_DIR)/backend.key -out $(TLS_CERT_DIR)/backend.csr \ + -subj "/CN=localhost" + @echo "basicConstraints=CA:FALSE" > $(TLS_CERT_DIR)/backend.ext + @echo "keyUsage=digitalSignature,keyEncipherment" >> $(TLS_CERT_DIR)/backend.ext + @echo "subjectAltName=DNS:localhost,DNS:test1.example.com,DNS:test2.example.com,DNS:*.example.com,IP:127.0.0.1" >> $(TLS_CERT_DIR)/backend.ext + openssl x509 -req -in $(TLS_CERT_DIR)/backend.csr -signkey $(TLS_CERT_DIR)/backend.key \ + -out $(TLS_CERT_DIR)/backend.crt -days 365 \ + -extfile $(TLS_CERT_DIR)/backend.ext + @rm -f $(TLS_CERT_DIR)/backend.csr $(TLS_CERT_DIR)/backend.ext + @echo "" + @echo "TLS backend certificates generated:" + @echo " Cert: $(TLS_CERT_DIR)/backend.crt" + @echo " Key: $(TLS_CERT_DIR)/backend.key" + +# Start TLS/SNI passthrough relay +relay-tls: build gen-cert-if-needed + @echo "" + @echo "Starting TLS/SNI passthrough relay..." + @echo "======================================" + @echo " QUIC Control: $(LOCALUP_ADDR)" + @echo " TLS Server: $(TLS_ADDR)" + @echo " API HTTP: $(API_ADDR)" + @echo " JWT Secret: $(JWT_SECRET)" + @echo "" + @echo "TLS traffic is passed through without termination." + @echo "SNI is extracted from ClientHello for routing." + @echo "" + @echo "Generate a token with: make gen-token" + @echo "======================================" + @echo "" + RUST_LOG=$(LOG_LEVEL) ./target/debug/localup relay tls \ + --localup-addr $(LOCALUP_ADDR) \ + --tls-addr $(TLS_ADDR) \ + --jwt-secret "$(JWT_SECRET)" \ + --api-http-addr $(API_ADDR) \ + --log-level $(LOG_LEVEL) + +# Start TLS/SNI relay with HTTP passthrough (serves both HTTP and HTTPS) +relay-tls-http: build gen-cert-if-needed + @echo "" + @echo "Starting TLS/SNI relay with HTTP passthrough..." + @echo "================================================" + @echo " QUIC Control: $(LOCALUP_ADDR)" + @echo " TLS Server: $(TLS_ADDR)" + @echo " HTTP Passthrough: $(HTTP_PASSTHROUGH_ADDR)" + @echo " API HTTP: $(API_ADDR)" + @echo " JWT Secret: $(JWT_SECRET)" + @echo "" + @echo "TLS traffic (port 28443): SNI-based routing, passthrough" + @echo "HTTP traffic (port 28080): Host header routing, passthrough" + @echo "" + @echo "Generate a token with: make gen-token" + @echo "================================================" + @echo "" + RUST_LOG=$(LOG_LEVEL) ./target/debug/localup relay tls \ + --localup-addr $(LOCALUP_ADDR) \ + --tls-addr $(TLS_ADDR) \ + --http-passthrough-addr $(HTTP_PASSTHROUGH_ADDR) \ + --jwt-secret "$(JWT_SECRET)" \ + --api-http-addr $(API_ADDR) \ + --log-level $(LOG_LEVEL) + +# Start TLS echo test server (Python) +tls-server: gen-cert-tls + @echo "" + @echo "Starting TLS echo server on port $(TLS_BACKEND_PORT)..." + @echo "=======================================================" + @echo " Cert: $(TLS_CERT_DIR)/backend.crt" + @echo " Key: $(TLS_CERT_DIR)/backend.key" + @echo "" + @echo "This server echoes back received data with 'TLS BACKEND RESPONSE:' prefix" + @echo "=======================================================" + @echo "" + @python3 -c "$$TLS_SERVER_SCRIPT" + +define TLS_SERVER_SCRIPT +import ssl, socket, sys, datetime +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain('$(TLS_CERT_DIR)/backend.crt', '$(TLS_CERT_DIR)/backend.key') +context.check_hostname = False +context.verify_mode = ssl.CERT_NONE +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(('127.0.0.1', $(TLS_BACKEND_PORT))) +sock.listen(5) +print('TLS Echo Server running on port $(TLS_BACKEND_PORT)', file=sys.stderr, flush=True) +ssock = context.wrap_socket(sock, server_side=True) +request_count = 0 +while True: + try: + conn, addr = ssock.accept() + request_count += 1 + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f'[{ts}] #{request_count} Connection from {addr}', file=sys.stderr, flush=True) + data = conn.recv(4096) + print(f'[{ts}] #{request_count} Received {len(data)} bytes: {data[:100]}...', file=sys.stderr, flush=True) + conn.sendall(b'TLS BACKEND RESPONSE: ' + data) + conn.close() + print(f'[{ts}] #{request_count} Response sent, connection closed', file=sys.stderr, flush=True) + except Exception as e: + print(f'[ERROR] {e}', file=sys.stderr, flush=True) +endef +export TLS_SERVER_SCRIPT + +# Start HTTPS test server (Python) - serves HTTP over TLS +https-server: gen-cert-tls + @echo "" + @echo "Starting HTTPS test server on port $(TLS_BACKEND_PORT)..." + @echo "==========================================================" + @echo " Cert: $(TLS_CERT_DIR)/backend.crt" + @echo " Key: $(TLS_CERT_DIR)/backend.key" + @echo "" + @echo "This serves HTTP responses over TLS (proper HTTPS)" + @echo "==========================================================" + @echo "" + @python3 -c "$$HTTPS_SERVER_SCRIPT" + +define HTTPS_SERVER_SCRIPT +import ssl, http.server, datetime + +class LoggingHandler(http.server.BaseHTTPRequestHandler): + request_count = 0 + + def log_message(self, format, *args): + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f'[{ts}] {self.address_string()} - {format % args}', flush=True) + + def do_GET(self): + LoggingHandler.request_count += 1 + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f'[{ts}] #{LoggingHandler.request_count} GET {self.path} from {self.address_string()}', flush=True) + print(f'[{ts}] #{LoggingHandler.request_count} Headers: {dict(self.headers)}', flush=True) + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + response = f'HTTPS Backend Response\nRequest #{LoggingHandler.request_count}\nPath: {self.path}\nTime: {ts}\n' + self.wfile.write(response.encode()) + print(f'[{ts}] #{LoggingHandler.request_count} Response sent: 200 OK', flush=True) + + def do_POST(self): + LoggingHandler.request_count += 1 + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) + print(f'[{ts}] #{LoggingHandler.request_count} POST {self.path} from {self.address_string()}', flush=True) + print(f'[{ts}] #{LoggingHandler.request_count} Headers: {dict(self.headers)}', flush=True) + print(f'[{ts}] #{LoggingHandler.request_count} Body ({content_length} bytes): {body[:200]}', flush=True) + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + response = f'HTTPS Backend Response\nRequest #{LoggingHandler.request_count}\nPath: {self.path}\nBody received: {content_length} bytes\n' + self.wfile.write(response.encode()) + print(f'[{ts}] #{LoggingHandler.request_count} Response sent: 200 OK', flush=True) + +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain('$(TLS_CERT_DIR)/backend.crt', '$(TLS_CERT_DIR)/backend.key') +server = http.server.HTTPServer(('127.0.0.1', $(TLS_BACKEND_PORT)), LoggingHandler) +server.socket = context.wrap_socket(server.socket, server_side=True) +print(f'HTTPS Server running on https://127.0.0.1:$(TLS_BACKEND_PORT)', flush=True) +print(f'Test with: curl -k https://localhost:$(TLS_BACKEND_PORT)/', flush=True) +server.serve_forever() +endef +export HTTPS_SERVER_SCRIPT + +# Start plain HTTP test server (for HTTP passthrough testing) +http-server: + @echo "" + @echo "Starting HTTP test server on port $(HTTP_BACKEND_PORT)..." + @echo "=========================================================" + @echo "This serves plain HTTP responses (for HTTP passthrough testing)" + @echo "" + @python3 -c "$$HTTP_SERVER_SCRIPT" + +define HTTP_SERVER_SCRIPT +import http.server +import datetime +import sys + +class LoggingHandler(http.server.BaseHTTPRequestHandler): + request_count = 0 + + def log_message(self, format, *args): + pass # Disable default logging, we do our own + + def do_GET(self): + LoggingHandler.request_count += 1 + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + host = self.headers.get('Host', 'unknown') + print(f'[{ts}] #{LoggingHandler.request_count} GET {self.path} Host: {host} from {self.address_string()}', flush=True) + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + response = f'HTTP Backend Response\nRequest #{LoggingHandler.request_count}\nHost: {host}\nPath: {self.path}\nTime: {ts}\n' + self.wfile.write(response.encode()) + print(f'[{ts}] #{LoggingHandler.request_count} Response sent: 200 OK', flush=True) + + def do_POST(self): + LoggingHandler.request_count += 1 + ts = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length) + host = self.headers.get('Host', 'unknown') + print(f'[{ts}] #{LoggingHandler.request_count} POST {self.path} Host: {host} from {self.address_string()}', flush=True) + print(f'[{ts}] #{LoggingHandler.request_count} Body ({content_length} bytes): {body[:200]}', flush=True) + self.send_response(200) + self.send_header('Content-Type', 'text/plain') + self.end_headers() + response = f'HTTP Backend Response\nRequest #{LoggingHandler.request_count}\nHost: {host}\nPath: {self.path}\nBody received: {content_length} bytes\n' + self.wfile.write(response.encode()) + print(f'[{ts}] #{LoggingHandler.request_count} Response sent: 200 OK', flush=True) + +server = http.server.HTTPServer(('127.0.0.1', $(HTTP_BACKEND_PORT)), LoggingHandler) +print(f'HTTP Server running on http://127.0.0.1:$(HTTP_BACKEND_PORT)', flush=True) +print(f'Test with: curl http://localhost:$(HTTP_BACKEND_PORT)/', flush=True) +server.serve_forever() +endef +export HTTP_SERVER_SCRIPT + +# Start TLS tunnel with SNI patterns (for HTTPS traffic on port 28443) +tunnel-tls: build + @echo "" + @echo "Starting TLS tunnel with SNI patterns..." + @echo "=========================================" + @echo " TLS Backend: localhost:$(TLS_BACKEND_PORT)" + @echo " Relay: $(RELAY_ADDR)" + @echo " Protocol: tls" + @echo " SNI Domains: $(SNI_DOMAINS)" + @echo "" + @echo "Incoming TLS connections matching these SNI patterns" + @echo "will be routed to localhost:$(TLS_BACKEND_PORT)" + @echo "=========================================" + @echo "" + @TOKEN=$$(./target/debug/localup generate-token --secret "$(JWT_SECRET)" --sub "tls-tunnel" --user-id "$(USER_ID)" --hours 24 --token-only); \ + CUSTOM_DOMAIN_ARGS=$$(echo "$(SNI_DOMAINS)" | tr ',' '\n' | sed 's/^/--custom-domain /' | tr '\n' ' '); \ + RUST_LOG=$(LOG_LEVEL) ./target/debug/localup \ + --port $(TLS_BACKEND_PORT) \ + --relay $(RELAY_ADDR) \ + --protocol tls \ + $$CUSTOM_DOMAIN_ARGS \ + --token "$$TOKEN" + +# Start TLS tunnel with HTTP passthrough (TLS → port 9443, HTTP → port 9080) +tunnel-tls-http: build + @echo "" + @echo "Starting TLS tunnel with HTTP passthrough..." + @echo "=============================================" + @echo " TLS Backend: localhost:$(TLS_BACKEND_PORT)" + @echo " HTTP Backend: localhost:$(HTTP_BACKEND_PORT)" + @echo " Relay: $(RELAY_ADDR)" + @echo " Protocol: tls" + @echo " SNI Domains: $(SNI_DOMAINS)" + @echo "" + @echo "TLS connections (port 28443) → localhost:$(TLS_BACKEND_PORT)" + @echo "HTTP connections (port 28080) → localhost:$(HTTP_BACKEND_PORT)" + @echo "=============================================" + @echo "" + @TOKEN=$$(./target/debug/localup generate-token --secret "$(JWT_SECRET)" --sub "tls-http-tunnel" --user-id "$(USER_ID)" --hours 24 --token-only); \ + CUSTOM_DOMAIN_ARGS=$$(echo "$(SNI_DOMAINS)" | tr ',' '\n' | sed 's/^/--custom-domain /' | tr '\n' ' '); \ + RUST_LOG=$(LOG_LEVEL) ./target/debug/localup \ + --port $(TLS_BACKEND_PORT) \ + --http-port $(HTTP_BACKEND_PORT) \ + --relay $(RELAY_ADDR) \ + --protocol tls \ + $$CUSTOM_DOMAIN_ARGS \ + --token "$$TOKEN" + +# Full TLS testing instructions +test-tls: + @echo "" + @echo "==========================================" + @echo "TLS/SNI Passthrough Testing Guide" + @echo "==========================================" + @echo "" + @echo "This tests TLS passthrough routing based on SNI (Server Name Indication)." + @echo "The relay extracts SNI from ClientHello without decrypting TLS traffic." + @echo "" + @echo "Prerequisites:" + @echo " - Add to /etc/hosts: 127.0.0.1 test1.example.com test2.example.com" + @echo "" + @echo "Test Steps:" + @echo "" + @echo " # Terminal 1: Start TLS relay" + @echo " make relay-tls" + @echo "" + @echo " # Terminal 2: Start TLS backend server" + @echo " make tls-server" + @echo "" + @echo " # Terminal 3: Start TLS tunnel" + @echo " make tunnel-tls" + @echo "" + @echo " # Terminal 4: Test TLS connection" + @echo " echo 'Hello TLS' | openssl s_client -connect localhost:28443 \\" + @echo " -servername test1.example.com -quiet 2>/dev/null" + @echo "" + @echo " # Expected output: 'TLS BACKEND RESPONSE: Hello TLS'" + @echo "" + @echo "Custom SNI domains (comma-separated):" + @echo " make tunnel-tls SNI_DOMAINS=app1.test,app2.test,*.test" + @echo "" + @echo "Verify SNI extraction in relay logs:" + @echo " Look for: 'Extracted SNI: test1.example.com'" + @echo " Look for: 'Routing SNI test1.example.com to backend'" + @echo "" + @echo "==========================================" + # ========================================== # Daemon + IPC Testing Targets # ========================================== diff --git a/apps/localup-desktop/src-tauri/src/daemon/service.rs b/apps/localup-desktop/src-tauri/src/daemon/service.rs index 108a760..28bd87e 100644 --- a/apps/localup-desktop/src-tauri/src/daemon/service.rs +++ b/apps/localup-desktop/src-tauri/src/daemon/service.rs @@ -124,7 +124,8 @@ impl DaemonService { }, "tls" => ProtocolConfig::Tls { local_port, - sni_hostname: custom_domain.clone(), + sni_hostnames: custom_domain.clone().map(|d| vec![d]).unwrap_or_default(), + http_port: None, }, other => { return DaemonResponse::Error { @@ -474,7 +475,8 @@ async fn handle_request( }, "tls" => ProtocolConfig::Tls { local_port, - sni_hostname: custom_domain.clone(), + sni_hostnames: custom_domain.clone().map(|d| vec![d]).unwrap_or_default(), + http_port: None, }, other => { return DaemonResponse::Error { diff --git a/apps/localup-desktop/src-tauri/src/state/app_state.rs b/apps/localup-desktop/src-tauri/src/state/app_state.rs index 41d4024..4bb14b4 100644 --- a/apps/localup-desktop/src-tauri/src/state/app_state.rs +++ b/apps/localup-desktop/src-tauri/src/state/app_state.rs @@ -269,7 +269,12 @@ pub fn build_protocol_config( }), "tls" => Ok(ProtocolConfig::Tls { local_port, - sni_hostname: config.custom_domain.clone(), + sni_hostnames: config + .custom_domain + .clone() + .map(|d| vec![d]) + .unwrap_or_default(), + http_port: None, }), other => Err(format!("Unknown protocol: {}", other)), } @@ -326,6 +331,12 @@ pub async fn run_tunnel( reconnect_attempt + 1 ); + // Update status to Connecting + { + let mut manager = tunnel_manager.write().await; + manager.update_status(&config_id, TunnelStatus::Connecting, None, None, None); + } + match TunnelClient::connect(config.clone()).await { Ok(client) => { reconnect_attempt = 0; @@ -442,10 +453,18 @@ pub async fn run_tunnel( // Abort metrics task when connection ends metrics_task.abort(); + // Update status to Disconnected (will change to Connecting on next loop iteration) + { + let mut manager = tunnel_manager.write().await; + manager.update_status(&config_id, TunnelStatus::Disconnected, None, None, None); + } + info!( "[{}] Connection lost, attempting to reconnect...", config_id ); + + reconnect_attempt += 1; } Err(e) => { error!("[{}] Failed to connect: {}", config_id, e); diff --git a/crates/localup-api/src/handlers.rs b/crates/localup-api/src/handlers.rs index 960b311..22e14bb 100644 --- a/crates/localup-api/src/handlers.rs +++ b/crates/localup-api/src/handlers.rs @@ -207,9 +207,9 @@ pub async fn list_tunnels( } localup_proto::Protocol::Tls { port: _, - sni_pattern, + sni_patterns, } => TunnelProtocol::Tls { - domain: sni_pattern.clone(), + domains: sni_patterns.clone(), }, }, public_url: e.public_url.clone(), @@ -342,9 +342,9 @@ pub async fn get_tunnel( } localup_proto::Protocol::Tls { port: _, - sni_pattern, + sni_patterns, } => TunnelProtocol::Tls { - domain: sni_pattern.clone(), + domains: sni_patterns.clone(), }, }, public_url: e.public_url.clone(), diff --git a/crates/localup-api/src/models.rs b/crates/localup-api/src/models.rs index 04380e7..6743ee2 100644 --- a/crates/localup-api/src/models.rs +++ b/crates/localup-api/src/models.rs @@ -21,10 +21,10 @@ pub enum TunnelProtocol { /// Local port to forward port: u16, }, - /// TLS tunnel with SNI + /// TLS tunnel with SNI (supports multiple domains/patterns) Tls { - /// Domain for SNI routing - domain: String, + /// Domains/patterns for SNI routing (can include wildcards like *.example.com) + domains: Vec, }, } diff --git a/crates/localup-cert/src/acme.rs b/crates/localup-cert/src/acme.rs index 3e57195..5e66486 100644 --- a/crates/localup-cert/src/acme.rs +++ b/crates/localup-cert/src/acme.rs @@ -15,7 +15,7 @@ use instant_acme::{ use thiserror::Error; use tokio::fs; use tokio::sync::RwLock; -use tracing::{debug, error, info}; +use tracing::{debug, info}; use crate::Certificate; diff --git a/crates/localup-cli/Cargo.toml b/crates/localup-cli/Cargo.toml index 129676a..5d93a4c 100644 --- a/crates/localup-cli/Cargo.toml +++ b/crates/localup-cli/Cargo.toml @@ -44,6 +44,7 @@ uuid = { version = "1.0", features = ["v4"] } ipnetwork = "0.20" chrono = { workspace = true } sea-orm = { workspace = true } +axum = { workspace = true } [build-dependencies] chrono = { workspace = true } diff --git a/crates/localup-cli/src/localup_store.rs b/crates/localup-cli/src/localup_store.rs index dc6ec66..132eb6d 100644 --- a/crates/localup-cli/src/localup_store.rs +++ b/crates/localup-cli/src/localup_store.rs @@ -203,6 +203,7 @@ mod tests { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, } } diff --git a/crates/localup-cli/src/main.rs b/crates/localup-cli/src/main.rs index ce6e448..553e82f 100644 --- a/crates/localup-cli/src/main.rs +++ b/crates/localup-cli/src/main.rs @@ -46,14 +46,16 @@ struct Cli { #[arg(short, long)] subdomain: Option, - /// Custom domain for HTTP/HTTPS tunnels (standalone mode only) - /// Requires DNS pointing to relay and valid TLS certificate. - /// Takes precedence over subdomain when both are set. + /// Custom domain for HTTP/HTTPS/TLS tunnels (standalone mode only) + /// For HTTP/HTTPS: Requires DNS pointing to relay and valid TLS certificate. + /// For TLS: No validation - relay routes based on SNI match (you manage certificates). + /// Can be specified multiple times for TLS tunnels. /// Supports wildcard domains (e.g., *.mycompany.com) for multi-subdomain tunnels. /// Example: --custom-domain api.mycompany.com /// Example: --custom-domain "*.mycompany.com" + /// Example (TLS multi): --custom-domain "*.local.example.com" --custom-domain "api.example.com" #[arg(long = "custom-domain")] - custom_domain: Option, + custom_domain: Vec, /// Relay server address (standalone mode only) #[arg(short, long, env)] @@ -67,6 +69,13 @@ struct Cli { #[arg(long)] remote_port: Option, + /// HTTP backend port for TLS tunnels with HTTP passthrough (standalone mode only) + /// When the relay sends plain HTTP traffic through a TLS tunnel, it will be + /// forwarded to this port instead of the main --port. + /// Example: --port 9443 --http-port 9080 (HTTPS to 9443, HTTP to 9080) + #[arg(long)] + http_port: Option, + /// Log level (trace, debug, info, warn, error) #[arg(long, default_value = "info")] log_level: String, @@ -120,12 +129,14 @@ enum Commands { /// Subdomain for HTTP/HTTPS/TLS tunnels #[arg(short, long)] subdomain: Option, - /// Custom domain for HTTP/HTTPS tunnels (requires DNS and certificate) + /// Custom domain for HTTP/HTTPS/TLS tunnels + /// For TLS: No validation - relay routes based on SNI match (you manage certificates). + /// Can be specified multiple times for TLS tunnels. /// Supports wildcard domains (e.g., *.mycompany.com) for multi-subdomain tunnels. /// Example: --custom-domain api.mycompany.com /// Example: --custom-domain "*.mycompany.com" #[arg(long = "custom-domain")] - custom_domain: Option, + custom_domain: Vec, /// Relay server address (host:port) #[arg(short, long)] relay: Option, @@ -135,6 +146,12 @@ enum Commands { /// Remote port for TCP/TLS tunnels #[arg(long)] remote_port: Option, + /// HTTP backend port for TLS tunnels with HTTP passthrough + /// When the relay sends plain HTTP traffic through a TLS tunnel, it will be + /// forwarded to this port instead of the main --port. + /// Example: --port 9443 --http-port 9080 (HTTPS to 9443, HTTP to 9080) + #[arg(long)] + http_port: Option, /// Auto-enable (start with daemon) #[arg(long)] enabled: bool, @@ -440,6 +457,24 @@ enum RelayCommands { #[arg(long, default_value = "0.0.0.0:4443")] tls_addr: String, + /// Optional HTTP server for redirecting to HTTPS + /// When set, starts an HTTP server that redirects all requests to HTTPS + /// Example: --http-redirect-addr 0.0.0.0:80 + #[arg(long)] + http_redirect_addr: Option, + + /// HTTPS port to redirect to (default: 443) + /// Used when http_redirect_addr is set + #[arg(long, default_value = "443")] + https_redirect_port: u16, + + /// Optional HTTP passthrough server for plain HTTP traffic + /// Routes HTTP requests based on Host header (no TLS) + /// Use this to serve HTTP traffic on port 80 with passthrough routing + /// Example: --http-passthrough-addr 0.0.0.0:80 + #[arg(long)] + http_passthrough_addr: Option, + /// Public domain name for this relay #[arg(long, default_value = "localhost")] domain: String, @@ -664,10 +699,11 @@ enum DaemonCommands { /// Subdomain for HTTP/HTTPS tunnels #[arg(short, long)] subdomain: Option, - /// Custom domain for HTTP/HTTPS tunnels + /// Custom domain for HTTP/HTTPS/TLS tunnels + /// Can be specified multiple times for TLS tunnels. /// Supports wildcard domains (e.g., *.mycompany.com) for multi-subdomain tunnels. #[arg(long = "custom-domain")] - custom_domain: Option, + custom_domain: Vec, }, /// Remove a tunnel from .localup.yml Remove { @@ -721,6 +757,7 @@ async fn main() -> Result<()> { relay, transport, remote_port, + http_port, enabled, allow_ips, }) => handle_add_tunnel( @@ -734,6 +771,7 @@ async fn main() -> Result<()> { relay, transport, remote_port, + http_port, enabled, allow_ips, ), @@ -1136,12 +1174,19 @@ async fn handle_daemon_command(command: DaemonCommands) -> Result<()> { } // Create new tunnel entry + // For TLS, use custom_domain as sni_hostnames; for HTTP/HTTPS use as custom_domain + let (custom_domain_opt, sni_hostnames) = if protocol == "tls" { + (None, custom_domain) + } else { + (custom_domain.into_iter().next(), Vec::new()) + }; let tunnel = TunnelEntry { name: name.clone(), port, protocol, subdomain, - custom_domain, + custom_domain: custom_domain_opt, + sni_hostnames, enabled: true, ..Default::default() }; @@ -1232,10 +1277,11 @@ fn handle_add_tunnel( protocol: String, token: Option, subdomain: Option, - custom_domain: Option, + custom_domains: Vec, relay: Option, transport: Option, remote_port: Option, + http_port: Option, enabled: bool, allow_ips: Vec, ) -> Result<()> { @@ -1255,8 +1301,15 @@ fn handle_add_tunnel( }; // Parse protocol - custom_domain takes precedence over subdomain for HTTP/HTTPS - let protocol_config = - parse_protocol(&protocol, local_port, subdomain, custom_domain, remote_port)?; + // For TLS, all custom_domains are used as SNI patterns + let protocol_config = parse_protocol( + &protocol, + local_port, + subdomain, + custom_domains, + remote_port, + http_port, + )?; // Parse exit node let exit_node = if let Some(relay_addr) = relay { @@ -1373,11 +1426,15 @@ fn handle_list_tunnels() -> Result<()> { } ProtocolConfig::Tls { local_port, - sni_hostname, + sni_hostnames, + http_port, } => { print!(" Protocol: TLS, Port: {}", local_port); - if let Some(sni) = sni_hostname { - print!(", SNI: {}", sni); + if let Some(hp) = http_port { + print!(", HTTP Port: {}", hp); + } + if !sni_hostnames.is_empty() { + print!(", SNI: {}", sni_hostnames.join(", ")); } println!(); } @@ -1510,6 +1567,7 @@ async fn run_standalone(cli: Cli) -> Result<()> { cli.subdomain.clone(), cli.custom_domain.clone(), cli.remote_port, + cli.http_port, )?; // Parse exit node configuration @@ -1787,28 +1845,41 @@ fn parse_protocol( protocol: &str, port: u16, subdomain: Option, - custom_domain: Option, + custom_domains: Vec, remote_port: Option, + http_port: Option, ) -> Result { match protocol.to_lowercase().as_str() { "http" => Ok(ProtocolConfig::Http { local_port: port, subdomain, - custom_domain, + // For HTTP, use first custom_domain if provided + custom_domain: custom_domains.into_iter().next(), }), "https" => Ok(ProtocolConfig::Https { local_port: port, subdomain, - custom_domain, + // For HTTPS, use first custom_domain if provided + custom_domain: custom_domains.into_iter().next(), }), "tcp" => Ok(ProtocolConfig::Tcp { local_port: port, remote_port, }), - "tls" => Ok(ProtocolConfig::Tls { - local_port: port, - sni_hostname: subdomain, - }), + "tls" => { + // For TLS, use all custom_domains as SNI patterns + // If no custom_domains, fall back to subdomain + let sni_hostnames = if custom_domains.is_empty() { + subdomain.into_iter().collect() + } else { + custom_domains + }; + Ok(ProtocolConfig::Tls { + local_port: port, + sni_hostnames, + http_port, + }) + } _ => Err(anyhow::anyhow!( "Invalid protocol: {}. Valid options: http, https, tcp, tls", protocol @@ -2210,12 +2281,18 @@ async fn handle_relay_subcommand(command: RelayCommands) -> Result<()> { None, // acme_email (not used for TCP) false, // acme_staging "/opt/localup/certs/acme".to_string(), // acme_cert_dir (default) + None, // http_redirect_addr (not used for TCP) + 443, // https_redirect_port (default) + None, // http_passthrough_addr (not used for TCP) ) .await } RelayCommands::Tls { localup_addr, tls_addr, + http_redirect_addr, + https_redirect_port, + http_passthrough_addr, domain, jwt_secret, log_level, @@ -2258,6 +2335,9 @@ async fn handle_relay_subcommand(command: RelayCommands) -> Result<()> { None, // acme_email (not used for TLS passthrough) false, // acme_staging "/opt/localup/certs/acme".to_string(), // acme_cert_dir (default) + http_redirect_addr, // HTTP redirect server + https_redirect_port, // HTTPS port to redirect to + http_passthrough_addr, // HTTP passthrough server (Host-based routing) ) .await } @@ -2312,6 +2392,9 @@ async fn handle_relay_subcommand(command: RelayCommands) -> Result<()> { acme_email, acme_staging, acme_cert_dir, + None, // http_redirect_addr (not used for HTTP relay) + 443, // https_redirect_port (default) + None, // http_passthrough_addr (not used for HTTP relay) ) .await } @@ -2345,6 +2428,9 @@ async fn handle_relay_command( acme_email: Option, acme_staging: bool, acme_cert_dir: String, + http_redirect_addr: Option, + https_redirect_port: u16, + http_passthrough_addr: Option, ) -> Result<()> { use localup_auth::JwtValidator; use localup_control::{ @@ -2353,7 +2439,9 @@ async fn handle_relay_command( use localup_router::RouteRegistry; use localup_server_https::{HttpsServer, HttpsServerConfig}; use localup_server_tcp::{TcpServer, TcpServerConfig}; - use localup_server_tls::{TlsServer, TlsServerConfig}; + use localup_server_tls::{ + HttpPassthroughConfig, HttpPassthroughServer, TlsServer, TlsServerConfig, + }; use localup_transport::TransportListener; use localup_transport_quic::QuicListener; use std::net::SocketAddr; @@ -2608,6 +2696,99 @@ async fn handle_relay_command( None }; + // Start HTTP redirect server for TLS (redirects HTTP to HTTPS) + let _http_redirect_handle = if let Some(ref http_redirect_addr_str) = http_redirect_addr { + use axum::{ + http::{header, Request}, + response::Redirect, + Router, + }; + + let http_redirect_addr_parsed: SocketAddr = http_redirect_addr_str.parse()?; + let https_port = https_redirect_port; + + info!( + "🔀 HTTP redirect server configured on {} -> HTTPS port {}", + http_redirect_addr_str, https_port + ); + + let app = Router::new().fallback(move |request: Request| async move { + // Extract host from Host header + let host = request + .headers() + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"); + + // Remove port from host if present + let hostname = host.split(':').next().unwrap_or(host); + + // Get the URI path and query + let path_and_query = request + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + + // Build HTTPS URL + let https_url = if https_port == 443 { + format!("https://{}{}", hostname, path_and_query) + } else { + format!("https://{}:{}{}", hostname, https_port, path_and_query) + }; + + info!("🔀 Redirecting HTTP request to {}", https_url); + Redirect::permanent(&https_url) + }); + + let http_redirect_display = http_redirect_addr_str.clone(); + Some(tokio::spawn(async move { + info!("Starting HTTP redirect server on {}", http_redirect_display); + let listener = match tokio::net::TcpListener::bind(http_redirect_addr_parsed).await { + Ok(l) => l, + Err(e) => { + error!("Failed to bind HTTP redirect server: {}", e); + return; + } + }; + if let Err(e) = axum::serve(listener, app).await { + error!("HTTP redirect server error: {}", e); + } + })) + } else { + None + }; + + // Start HTTP passthrough server (Host-based routing, no TLS) + let _http_passthrough_handle = + if let Some(ref http_passthrough_addr_str) = http_passthrough_addr { + let http_passthrough_addr_parsed: SocketAddr = http_passthrough_addr_str.parse()?; + let http_passthrough_config = HttpPassthroughConfig { + bind_addr: http_passthrough_addr_parsed, + }; + + let http_passthrough_server = + HttpPassthroughServer::new(http_passthrough_config, registry.clone()) + .with_localup_manager(localup_manager.clone()); + info!( + "✅ HTTP passthrough server configured on {} (routes based on Host header)", + http_passthrough_addr_str + ); + + let http_passthrough_display = http_passthrough_addr_str.clone(); + Some(tokio::spawn(async move { + info!( + "Starting HTTP passthrough server on {}", + http_passthrough_display + ); + if let Err(e) = http_passthrough_server.start().await { + error!("HTTP passthrough server error: {}", e); + } + })) + } else { + None + }; + // Create tunnel handler let mut localup_handler = TunnelHandler::new( localup_manager.clone(), diff --git a/crates/localup-cli/src/project_config.rs b/crates/localup-cli/src/project_config.rs index 9852759..e96bc42 100644 --- a/crates/localup-cli/src/project_config.rs +++ b/crates/localup-cli/src/project_config.rs @@ -80,8 +80,15 @@ pub struct ProjectTunnel { /// Remote port for TCP tunnels pub remote_port: Option, - /// SNI hostname for TLS tunnels - pub sni_hostname: Option, + /// SNI hostnames/patterns for TLS tunnels (supports multiple including wildcards) + /// Example: ["*.local.example.com", "api.example.com"] + #[serde(default)] + pub sni_hostnames: Vec, + + /// HTTP backend port for TLS tunnels with HTTP passthrough + /// When the relay sends plain HTTP traffic through a TLS tunnel, it will be + /// forwarded to this port instead of the main port. + pub http_port: Option, /// Override relay server for this tunnel pub relay: Option, @@ -122,7 +129,8 @@ impl Default for ProjectTunnel { subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, @@ -346,7 +354,8 @@ impl ProjectTunnel { }, "tls" => ProtocolConfig::Tls { local_port: self.port, - sni_hostname: self.sni_hostname.clone(), + sni_hostnames: self.sni_hostnames.clone(), + http_port: self.http_port, }, _ => anyhow::bail!("Unknown protocol: {}", self.protocol), }; @@ -617,12 +626,14 @@ tunnels: subdomain: Some("my-api".to_string()), custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, enabled: true, local_host: None, + ip_allowlist: Vec::new(), }; let config = tunnel.to_tunnel_config(&defaults).unwrap(); @@ -655,12 +666,14 @@ tunnels: subdomain: None, custom_domain: None, remote_port: Some(15432), - sni_hostname: None, + sni_hostnames: Vec::new(), + http_port: None, relay: Some("custom-relay:4443".to_string()), token: Some("custom-token".to_string()), transport: Some("quic".to_string()), enabled: true, local_host: Some("127.0.0.1".to_string()), + ip_allowlist: Vec::new(), }; let config = tunnel.to_tunnel_config(&defaults).unwrap(); @@ -697,12 +710,14 @@ tunnels: subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, enabled: true, local_host: None, + ip_allowlist: Vec::new(), }; let config = tunnel.to_tunnel_config(&defaults).unwrap(); @@ -818,7 +833,9 @@ tunnels: - name: secure port: 443 protocol: tls - sni_hostname: "example.com" + sni_hostnames: + - "example.com" + - "*.local.example.com" "#; let config = ProjectConfig::parse(yaml).unwrap(); let tunnel = &config.tunnels[0]; @@ -829,11 +846,14 @@ tunnels: if let ProtocolConfig::Tls { local_port, - sni_hostname, + sni_hostnames, + .. } = &tunnel_config.protocols[0] { assert_eq!(*local_port, 443); - assert_eq!(sni_hostname, &Some("example.com".to_string())); + assert_eq!(sni_hostnames.len(), 2); + assert_eq!(sni_hostnames[0], "example.com"); + assert_eq!(sni_hostnames[1], "*.local.example.com"); } else { panic!("Expected TLS protocol"); } @@ -882,12 +902,14 @@ tunnels: subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, enabled: true, local_host: None, + ip_allowlist: Vec::new(), }; let config = tunnel.to_tunnel_config(&defaults).unwrap(); @@ -915,4 +937,189 @@ tunnels: Some(TransportProtocol::Quic) ); } + + #[test] + fn test_tls_protocol_with_multiple_wildcards() { + let yaml = r#" +tunnels: + - name: multi-domain + port: 443 + protocol: tls + sni_hostnames: + - "*.local-abc123.myapp.dev" + - "*.staging.myapp.dev" + - "api.production.com" + - "web.production.com" +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + let tunnel = &config.tunnels[0]; + + let tunnel_config = tunnel + .to_tunnel_config(&ProjectDefaults::default()) + .unwrap(); + + if let ProtocolConfig::Tls { + local_port, + sni_hostnames, + .. + } = &tunnel_config.protocols[0] + { + assert_eq!(*local_port, 443); + assert_eq!(sni_hostnames.len(), 4); + assert_eq!(sni_hostnames[0], "*.local-abc123.myapp.dev"); + assert_eq!(sni_hostnames[1], "*.staging.myapp.dev"); + assert_eq!(sni_hostnames[2], "api.production.com"); + assert_eq!(sni_hostnames[3], "web.production.com"); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_with_single_hostname() { + let yaml = r#" +tunnels: + - name: single-domain + port: 8443 + protocol: tls + sni_hostnames: + - "api.example.com" +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + let tunnel = &config.tunnels[0]; + + let tunnel_config = tunnel + .to_tunnel_config(&ProjectDefaults::default()) + .unwrap(); + + if let ProtocolConfig::Tls { + local_port, + sni_hostnames, + .. + } = &tunnel_config.protocols[0] + { + assert_eq!(*local_port, 8443); + assert_eq!(sni_hostnames.len(), 1); + assert_eq!(sni_hostnames[0], "api.example.com"); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_with_empty_hostnames() { + // Empty hostnames should be valid - relay assigns default + let yaml = r#" +tunnels: + - name: no-sni + port: 443 + protocol: tls +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + let tunnel = &config.tunnels[0]; + + let tunnel_config = tunnel + .to_tunnel_config(&ProjectDefaults::default()) + .unwrap(); + + if let ProtocolConfig::Tls { sni_hostnames, .. } = &tunnel_config.protocols[0] { + assert!(sni_hostnames.is_empty()); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_mixed_wildcards_and_specific() { + let yaml = r#" +tunnels: + - name: desktop-app + port: 443 + protocol: tls + sni_hostnames: + - "*.local-rqe59t.dviejo.temps.dev" + - "api.specific-domain.com" + - "*.another-wildcard.io" +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + let tunnel = &config.tunnels[0]; + + assert_eq!(tunnel.sni_hostnames.len(), 3); + assert!(tunnel.sni_hostnames[0].starts_with("*.")); + assert!(!tunnel.sni_hostnames[1].starts_with("*.")); + assert!(tunnel.sni_hostnames[2].starts_with("*.")); + } + + #[test] + fn test_tls_protocol_config_serialization() { + let yaml = r#" +tunnels: + - name: serialization-test + port: 443 + protocol: tls + sni_hostnames: + - "*.example.com" + - "specific.example.com" +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + + // Serialize to YAML + let serialized = serde_yaml::to_string(&config).unwrap(); + + // Deserialize back + let restored: ProjectConfig = serde_yaml::from_str(&serialized).unwrap(); + + assert_eq!(restored.tunnels[0].sni_hostnames.len(), 2); + assert_eq!( + restored.tunnels[0].sni_hostnames[0], + config.tunnels[0].sni_hostnames[0] + ); + assert_eq!( + restored.tunnels[0].sni_hostnames[1], + config.tunnels[0].sni_hostnames[1] + ); + } + + #[test] + fn test_multiple_tls_tunnels_different_domains() { + let yaml = r#" +tunnels: + - name: app1 + port: 3443 + protocol: tls + sni_hostnames: + - "*.app1.example.com" + + - name: app2 + port: 4443 + protocol: tls + sni_hostnames: + - "*.app2.example.com" + - "api.app2.example.com" + + - name: app3 + port: 5443 + protocol: tls + sni_hostnames: + - "specific.domain.com" +"#; + let config = ProjectConfig::parse(yaml).unwrap(); + + assert_eq!(config.tunnels.len(), 3); + + assert_eq!(config.tunnels[0].sni_hostnames.len(), 1); + assert_eq!(config.tunnels[1].sni_hostnames.len(), 2); + assert_eq!(config.tunnels[2].sni_hostnames.len(), 1); + + // Verify each tunnel converts correctly + for tunnel in &config.tunnels { + let tunnel_config = tunnel + .to_tunnel_config(&ProjectDefaults::default()) + .unwrap(); + assert!(matches!( + &tunnel_config.protocols[0], + ProtocolConfig::Tls { .. } + )); + } + } } diff --git a/crates/localup-cli/tests/daemon_tests.rs b/crates/localup-cli/tests/daemon_tests.rs index aaa8a22..cc11c63 100644 --- a/crates/localup-cli/tests/daemon_tests.rs +++ b/crates/localup-cli/tests/daemon_tests.rs @@ -27,6 +27,7 @@ fn create_test_tunnel(name: &str, port: u16, enabled: bool) -> StoredTunnel { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, } } diff --git a/crates/localup-cli/tests/integration_tests.rs b/crates/localup-cli/tests/integration_tests.rs index 41d4676..c0ece24 100644 --- a/crates/localup-cli/tests/integration_tests.rs +++ b/crates/localup-cli/tests/integration_tests.rs @@ -24,6 +24,7 @@ fn create_test_config(name: &str, port: u16) -> StoredTunnel { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, } } @@ -264,6 +265,7 @@ fn test_localup_store_protocol_types() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&http_tunnel).unwrap(); @@ -285,6 +287,7 @@ fn test_localup_store_protocol_types() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&https_tunnel).unwrap(); @@ -305,6 +308,7 @@ fn test_localup_store_protocol_types() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&tcp_tunnel).unwrap(); @@ -317,7 +321,8 @@ fn test_localup_store_protocol_types() { local_host: "localhost".to_string(), protocols: vec![ProtocolConfig::Tls { local_port: 9000, - sni_hostname: Some("tls-test.example.com".to_string()), + sni_hostnames: vec!["tls-test.example.com".to_string()], + http_port: None, }], auth_token: "test-token".to_string(), exit_node: ExitNodeConfig::Auto, @@ -325,6 +330,7 @@ fn test_localup_store_protocol_types() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&tls_tunnel).unwrap(); @@ -380,6 +386,7 @@ fn test_localup_store_exit_node_configs() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&auto_tunnel).unwrap(); @@ -401,6 +408,7 @@ fn test_localup_store_exit_node_configs() { connection_timeout: Duration::from_secs(30), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; store.save(&custom_tunnel).unwrap(); @@ -434,6 +442,7 @@ fn test_localup_store_serialization_roundtrip() { connection_timeout: Duration::from_secs(60), preferred_transport: None, http_auth: HttpAuthConfig::None, + ip_allowlist: Vec::new(), }, }; diff --git a/crates/localup-client/src/config.rs b/crates/localup-client/src/config.rs index d97853d..085fc32 100644 --- a/crates/localup-client/src/config.rs +++ b/crates/localup-client/src/config.rs @@ -14,10 +14,20 @@ pub enum ProtocolConfig { }, /// TLS/SNI-based routing /// Routes incoming TLS connections based on Server Name Indication (SNI) + /// Supports multiple patterns including wildcards (e.g., "*.example.com") + /// No domain validation - relay simply routes based on SNI match Tls { local_port: u16, - /// SNI hostname for routing (e.g., "api.example.com") - sni_hostname: Option, + /// SNI hostnames/patterns for routing + /// Examples: "api.example.com", "*.local.example.com", "*.example.com" + #[serde(default)] + sni_hostnames: Vec, + /// Optional port for HTTP passthrough traffic + /// When the relay sends HTTP traffic (via TlsConnect with HTTP payload), + /// it will be forwarded to this port instead of local_port + /// If not set, HTTP passthrough traffic goes to local_port + #[serde(default)] + http_port: Option, }, /// HTTP with host-based routing Http { @@ -248,4 +258,174 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn test_tls_config_with_single_sni_hostname() { + let config = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: None, + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + match &config.protocols[0] { + ProtocolConfig::Tls { + local_port, + sni_hostnames, + http_port, + } => { + assert_eq!(*local_port, 443); + assert_eq!(sni_hostnames.len(), 1); + assert_eq!(sni_hostnames[0], "api.example.com"); + assert!(http_port.is_none()); + } + _ => panic!("Expected TLS protocol"), + } + } + + #[test] + fn test_tls_config_with_multiple_sni_hostnames() { + let config = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec![ + "api.example.com".to_string(), + "web.example.com".to_string(), + "admin.example.com".to_string(), + ], + http_port: None, + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + match &config.protocols[0] { + ProtocolConfig::Tls { + local_port, + sni_hostnames, + .. + } => { + assert_eq!(*local_port, 443); + assert_eq!(sni_hostnames.len(), 3); + assert_eq!(sni_hostnames[0], "api.example.com"); + assert_eq!(sni_hostnames[1], "web.example.com"); + assert_eq!(sni_hostnames[2], "admin.example.com"); + } + _ => panic!("Expected TLS protocol"), + } + } + + #[test] + fn test_tls_config_with_wildcard_patterns() { + let config = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec![ + "*.example.com".to_string(), + "*.local.myapp.dev".to_string(), + "api.specific.com".to_string(), + ], + http_port: None, + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + match &config.protocols[0] { + ProtocolConfig::Tls { sni_hostnames, .. } => { + assert_eq!(sni_hostnames.len(), 3); + assert!(sni_hostnames[0].starts_with("*.")); + assert!(sni_hostnames[1].starts_with("*.")); + assert!(!sni_hostnames[2].starts_with("*.")); + } + _ => panic!("Expected TLS protocol"), + } + } + + #[test] + fn test_tls_config_with_empty_sni_hostnames() { + // Empty hostnames should be valid - relay will assign default + let config = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec![], + http_port: None, + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + match &config.protocols[0] { + ProtocolConfig::Tls { sni_hostnames, .. } => { + assert!(sni_hostnames.is_empty()); + } + _ => panic!("Expected TLS protocol"), + } + } + + #[test] + fn test_tls_config_serialization_roundtrip() { + let original = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 8443, + sni_hostnames: vec![ + "*.local-abc123.myapp.dev".to_string(), + "api.production.com".to_string(), + ], + http_port: Some(8080), + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + // Serialize to JSON + let json = serde_json::to_string(&original).unwrap(); + + // Deserialize back + let restored: TunnelConfig = serde_json::from_str(&json).unwrap(); + + match &restored.protocols[0] { + ProtocolConfig::Tls { + local_port, + sni_hostnames, + http_port, + } => { + assert_eq!(*local_port, 8443); + assert_eq!(sni_hostnames.len(), 2); + assert_eq!(sni_hostnames[0], "*.local-abc123.myapp.dev"); + assert_eq!(sni_hostnames[1], "api.production.com"); + assert_eq!(*http_port, Some(8080)); + } + _ => panic!("Expected TLS protocol"), + } + } + + #[test] + fn test_tls_config_with_http_port() { + let config = TunnelConfig::builder() + .protocol(ProtocolConfig::Tls { + local_port: 9443, + sni_hostnames: vec!["*.example.com".to_string()], + http_port: Some(9080), + }) + .auth_token("test-token".to_string()) + .build() + .unwrap(); + + match &config.protocols[0] { + ProtocolConfig::Tls { + local_port, + sni_hostnames, + http_port, + } => { + assert_eq!(*local_port, 9443); + assert_eq!(sni_hostnames.len(), 1); + assert_eq!(*http_port, Some(9080)); + } + _ => panic!("Expected TLS protocol"), + } + } } diff --git a/crates/localup-client/src/localup.rs b/crates/localup-client/src/localup.rs index 6cad0a1..024657f 100644 --- a/crates/localup-client/src/localup.rs +++ b/crates/localup-client/src/localup.rs @@ -412,12 +412,68 @@ fn generate_short_id(stream_id: u32) -> String { format!("{:08x}", (hash as u32)) } -/// Generate a deterministic localup_id from auth token -/// This ensures the same token always gets the same localup_id (and thus same port/subdomain) -fn generate_localup_id_from_token(token: &str) -> String { +/// Generate a deterministic localup_id from auth token and protocol configs +/// This ensures the same token + protocols always gets the same localup_id (and thus same port/subdomain) +/// but different protocols with the same token get different localup_ids +/// +/// ALL protocol parameters are included in the hash to ensure every unique tunnel +/// configuration gets a unique ID. +fn generate_localup_id_from_token_and_protocols( + token: &str, + protocols: &[ProtocolConfig], +) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); token.hash(&mut hasher); + + // Include ALL protocol parameters in the hash to differentiate tunnels + // Every unique tunnel configuration should get a unique ID + for protocol in protocols { + match protocol { + ProtocolConfig::Http { + local_port, + subdomain, + custom_domain, + } => { + "http".hash(&mut hasher); + local_port.hash(&mut hasher); + subdomain.hash(&mut hasher); + custom_domain.hash(&mut hasher); + } + ProtocolConfig::Https { + local_port, + subdomain, + custom_domain, + } => { + "https".hash(&mut hasher); + local_port.hash(&mut hasher); + subdomain.hash(&mut hasher); + custom_domain.hash(&mut hasher); + } + ProtocolConfig::Tcp { + local_port, + remote_port, + } => { + "tcp".hash(&mut hasher); + local_port.hash(&mut hasher); + remote_port.hash(&mut hasher); + } + ProtocolConfig::Tls { + local_port, + sni_hostnames, + http_port, + } => { + "tls".hash(&mut hasher); + local_port.hash(&mut hasher); + http_port.hash(&mut hasher); + // Hash all SNI hostnames to differentiate TLS tunnels + for hostname in sni_hostnames { + hostname.hash(&mut hasher); + } + } + } + } + let hash = hasher.finish(); // Format as UUID-like string for compatibility @@ -666,9 +722,13 @@ impl TunnelConnector { info!("✅ Connected to relay via {:?}", discovered.protocol); - // Generate deterministic tunnel ID from auth token - // This ensures the same token always gets the same localup_id (and thus same port/subdomain) - let localup_id = generate_localup_id_from_token(&self.config.auth_token); + // Generate deterministic tunnel ID from auth token AND protocols + // This ensures the same token + protocols always gets the same localup_id (and thus same port/subdomain) + // but different protocols (e.g., different TLS SNI hostnames) get different localup_ids + let localup_id = generate_localup_id_from_token_and_protocols( + &self.config.auth_token, + &self.config.protocols, + ); info!("🎯 Using deterministic localup_id: {}", localup_id); // Convert ProtocolConfig to Protocol @@ -704,10 +764,16 @@ impl TunnelConnector { ProtocolConfig::Tls { local_port: _, - sni_hostname, + sni_hostnames, + http_port: _, } => Protocol::Tls { port: 8443, // TLS server port (SNI-based routing) - sni_pattern: sni_hostname.clone().unwrap_or_else(|| "*".to_string()), + // Use all provided SNI patterns, or default to "*" if none + sni_patterns: if sni_hostnames.is_empty() { + vec!["*".to_string()] + } else { + sni_hostnames.clone() + }, }, }) .collect(); @@ -1761,14 +1827,19 @@ impl TunnelConnection { return; } }; - // Get local TLS port from first TLS protocol - let local_port = config.protocols.first().and_then(|p| match p { - ProtocolConfig::Tls { local_port, .. } => Some(*local_port), + + // Get TLS protocol config + let tls_config = config.protocols.first().and_then(|p| match p { + ProtocolConfig::Tls { + local_port, + http_port, + .. + } => Some((*local_port, *http_port)), _ => None, }); - let local_port = match local_port { - Some(port) => port, + let (tls_port, http_port) = match tls_config { + Some(config) => config, None => { error!("No TLS protocol configured"); let _ = stream @@ -1778,14 +1849,43 @@ impl TunnelConnection { } }; - // Connect to local TLS service + // Detect if this is HTTP traffic (not TLS) + // TLS ClientHello starts with 0x16 (handshake) followed by version bytes + // HTTP requests start with method names like "GET ", "POST ", "PUT ", etc. + let is_http = !client_hello.is_empty() + && (client_hello.starts_with(b"GET ") + || client_hello.starts_with(b"POST ") + || client_hello.starts_with(b"PUT ") + || client_hello.starts_with(b"DELETE ") + || client_hello.starts_with(b"HEAD ") + || client_hello.starts_with(b"OPTIONS ") + || client_hello.starts_with(b"PATCH ") + || client_hello.starts_with(b"CONNECT ")); + + // Choose the appropriate port + let local_port = if is_http { + http_port.unwrap_or(tls_port) + } else { + tls_port + }; + + if is_http { + debug!( + "Detected HTTP traffic on TLS stream {}, routing to port {}", + stream_id, local_port + ); + } + + // Connect to local service let local_addr = format!("{}:{}", config.local_host, local_port); let local_socket = match TcpStream::connect(&local_addr).await { Ok(sock) => sock, Err(e) => { error!( - "Failed to connect to local TLS service at {}: {}", - local_addr, e + "Failed to connect to local {} service at {}: {}", + if is_http { "HTTP" } else { "TLS" }, + local_addr, + e ); let _ = stream .send_message(&TunnelMessage::TlsClose { stream_id }) @@ -1794,7 +1894,11 @@ impl TunnelConnection { } }; - debug!("Connected to local TLS service at {}", local_addr); + debug!( + "Connected to local {} service at {}", + if is_http { "HTTP" } else { "TLS" }, + local_addr + ); // Split both streams for bidirectional communication WITHOUT MUTEXES let (mut local_read, mut local_write) = local_socket.into_split(); @@ -2017,7 +2121,7 @@ impl TunnelConnection { // Read response - first read to get headers let mut response_buf = Vec::new(); - let mut temp_buf = vec![0u8; 8192]; + let mut temp_buf = vec![0u8; 65536]; // 64KB buffer for better performance with large responses // Read until we have headers (looking for \r\n\r\n or \n\n) let mut headers_complete = false; @@ -2131,10 +2235,10 @@ impl TunnelConnection { let mut chunked_data = response_buf[header_end_pos..].to_vec(); // Keep reading until connection closes or end marker - // Use a short timeout per read to avoid waiting unnecessarily after last chunk + // Use a reasonable timeout per read - 5 seconds should handle most cases loop { let read_result = tokio::time::timeout( - std::time::Duration::from_millis(100), // Short timeout - 100ms + std::time::Duration::from_secs(5), local_socket.read(&mut temp_buf), ) .await; @@ -2169,9 +2273,9 @@ impl TunnelConnection { break; } Err(_) => { - // Timeout - assume response is complete (after 100ms of no data) - debug!( - "Chunked response: read timeout, assuming complete ({} bytes)", + // Timeout after 5 seconds of no data + warn!( + "Chunked response: read timeout after 5s ({} bytes received so far)", chunked_data.len() ); break; @@ -2197,10 +2301,10 @@ impl TunnelConnection { // No Content-Length and not chunked - read until connection closes let mut body_data = response_buf[header_end_pos..].to_vec(); - // Use short timeout to avoid unnecessary waiting + // Use reasonable timeout - 5 seconds between reads loop { let read_result = tokio::time::timeout( - std::time::Duration::from_millis(100), + std::time::Duration::from_secs(5), local_socket.read(&mut temp_buf), ) .await; @@ -2215,9 +2319,9 @@ impl TunnelConnection { break; } Err(_) => { - // Timeout - assume response is complete - debug!( - "Response read timeout, assuming complete ({} bytes)", + // Timeout after 5 seconds of no data + warn!( + "Response read timeout after 5s ({} bytes received)", body_data.len() ); break; @@ -2469,3 +2573,276 @@ impl TunnelConnection { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_localup_id_same_token_same_protocol_same_id() { + let token = "test-token-123"; + let protocols = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: Some("myapp".to_string()), + custom_domain: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols); + + assert_eq!(id1, id2, "Same token and protocols should produce same ID"); + } + + #[test] + fn test_generate_localup_id_same_token_different_http_subdomain() { + let token = "test-token-123"; + + let protocols1 = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: Some("app1".to_string()), + custom_domain: None, + }]; + + let protocols2 = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: Some("app2".to_string()), + custom_domain: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same token but different subdomains should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_same_token_different_tls_sni() { + let token = "test-token-123"; + + let protocols1 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: None, + }]; + + let protocols2 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["web.example.com".to_string()], + http_port: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same token but different TLS SNI hostnames should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_same_token_different_tcp_ports() { + let token = "test-token-123"; + + let protocols1 = vec![ProtocolConfig::Tcp { + local_port: 8080, + remote_port: Some(10000), + }]; + + let protocols2 = vec![ProtocolConfig::Tcp { + local_port: 8080, + remote_port: Some(10001), + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same token but different TCP remote ports should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_different_tokens_same_protocol() { + let protocols = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: Some("myapp".to_string()), + custom_domain: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols("token-a", &protocols); + let id2 = generate_localup_id_from_token_and_protocols("token-b", &protocols); + + assert_ne!( + id1, id2, + "Different tokens should produce different IDs even with same protocol" + ); + } + + #[test] + fn test_generate_localup_id_uuid_format() { + let token = "test-token"; + let protocols = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: None, + custom_domain: None, + }]; + + let id = generate_localup_id_from_token_and_protocols(token, &protocols); + + // Should match UUID-like format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + let parts: Vec<&str> = id.split('-').collect(); + assert_eq!(parts.len(), 5, "ID should have 5 parts separated by dashes"); + assert_eq!(parts[0].len(), 8, "First part should be 8 characters"); + assert_eq!(parts[1].len(), 4, "Second part should be 4 characters"); + assert_eq!(parts[2].len(), 4, "Third part should be 4 characters"); + assert_eq!(parts[3].len(), 4, "Fourth part should be 4 characters"); + assert_eq!(parts[4].len(), 12, "Fifth part should be 12 characters"); + } + + #[test] + fn test_generate_localup_id_multiple_tls_sni_patterns() { + let token = "test-token-123"; + + // Same set of SNI patterns (just different order) - but hashing is order-dependent + // so same order should give same ID + let protocols1 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string(), "web.example.com".to_string()], + http_port: None, + }]; + + let protocols2 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string(), "web.example.com".to_string()], + http_port: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_eq!( + id1, id2, + "Same SNI patterns in same order should produce same ID" + ); + + // Different order should produce different ID + let protocols3 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["web.example.com".to_string(), "api.example.com".to_string()], + http_port: None, + }]; + + let id3 = generate_localup_id_from_token_and_protocols(token, &protocols3); + assert_ne!( + id1, id3, + "Same SNI patterns in different order should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_different_local_ports() { + let token = "test-token-123"; + + // Same protocol, different local_port + let protocols1 = vec![ProtocolConfig::Http { + local_port: 3000, + subdomain: Some("myapp".to_string()), + custom_domain: None, + }]; + + let protocols2 = vec![ProtocolConfig::Http { + local_port: 3001, + subdomain: Some("myapp".to_string()), + custom_domain: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same subdomain but different local_port should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_tls_different_local_ports() { + let token = "test-token-123"; + + // Same SNI hostname, different local_port + let protocols1 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: None, + }]; + + let protocols2 = vec![ProtocolConfig::Tls { + local_port: 8443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: None, + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same SNI but different local_port should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_tls_different_http_port() { + let token = "test-token-123"; + + // Same SNI and local_port, different http_port + let protocols1 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: Some(8080), + }]; + + let protocols2 = vec![ProtocolConfig::Tls { + local_port: 443, + sni_hostnames: vec!["api.example.com".to_string()], + http_port: Some(9090), + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same SNI/local_port but different http_port should produce different IDs" + ); + } + + #[test] + fn test_generate_localup_id_tcp_different_local_ports() { + let token = "test-token-123"; + + // Same remote_port, different local_port + let protocols1 = vec![ProtocolConfig::Tcp { + local_port: 8080, + remote_port: Some(10000), + }]; + + let protocols2 = vec![ProtocolConfig::Tcp { + local_port: 9090, + remote_port: Some(10000), + }]; + + let id1 = generate_localup_id_from_token_and_protocols(token, &protocols1); + let id2 = generate_localup_id_from_token_and_protocols(token, &protocols2); + + assert_ne!( + id1, id2, + "Same remote_port but different local_port should produce different IDs" + ); + } +} diff --git a/crates/localup-control/src/handler.rs b/crates/localup-control/src/handler.rs index 0693ff2..ce3859d 100644 --- a/crates/localup-control/src/handler.rs +++ b/crates/localup-control/src/handler.rs @@ -492,6 +492,23 @@ impl TunnelHandler { ); } + // Log endpoint details including SNI patterns for TLS + for endpoint in &endpoints { + match &endpoint.protocol { + Protocol::Tls { sni_patterns, .. } => { + info!( + " 📍 Endpoint: {} with {} SNI patterns: {:?}", + endpoint.public_url, + sni_patterns.len(), + sni_patterns + ); + } + _ => { + info!(" 📍 Endpoint: {}", endpoint.public_url); + } + } + } + info!( "✅ Tunnel registered: {} with {} endpoints", localup_id, @@ -1890,18 +1907,20 @@ impl TunnelHandler { port: Some(*port), }); } - Protocol::Tls { port, sni_pattern } => { + Protocol::Tls { port, sni_patterns } => { // TLS endpoint - use actual relay TLS port if configured, otherwise use client's requested port let actual_port = self.tls_port.unwrap_or(*port); debug!( - "Building TLS endpoint: relay_port={:?}, client_port={}, actual_port={}", - self.tls_port, port, actual_port + "Building TLS endpoint: relay_port={:?}, client_port={}, actual_port={}, patterns={:?}", + self.tls_port, port, actual_port, sni_patterns ); + // Display all SNI patterns + let patterns_display = sni_patterns.join(", "); endpoints.push(Endpoint { protocol: protocol.clone(), public_url: format!( "tls://{}:{} (SNI: {})", - self.domain, actual_port, sni_pattern + self.domain, actual_port, patterns_display ), port: Some(actual_port), }); @@ -2147,30 +2166,53 @@ impl TunnelHandler { Err("TCP tunnels not supported (no port allocator)".to_string()) } } - Protocol::Tls { sni_pattern, .. } => { - // Register TLS route based on SNI pattern - let route_key = RouteKey::TlsSni(sni_pattern.clone()); - let route_target = RouteTarget { - localup_id: localup_id.to_string(), - target_addr: format!("tunnel:{}", localup_id), // Special marker for tunnel routing - metadata: Some("via-tunnel".to_string()), - ip_filter: ip_filter.clone(), - }; - - self.route_registry - .register(route_key, route_target) - .map_err(|e| e.to_string())?; + Protocol::Tls { sni_patterns, .. } => { + // Register TLS routes for all SNI patterns (supports multiple patterns including wildcards) + for sni_pattern in sni_patterns { + let route_target = RouteTarget { + localup_id: localup_id.to_string(), + target_addr: format!("tunnel:{}", localup_id), // Special marker for tunnel routing + metadata: Some("via-tunnel".to_string()), + ip_filter: ip_filter.clone(), + }; - if ip_filter.is_empty() { - debug!( - "Registered TLS route for SNI pattern {} -> tunnel:{}", - sni_pattern, localup_id - ); - } else { - debug!( - "Registered TLS route for SNI pattern {} -> tunnel:{} (IP filter: {} entries)", - sni_pattern, localup_id, ip_filter.len() - ); + // Check if this is a wildcard pattern (e.g., *.example.com) + if WildcardPattern::is_wildcard_pattern(sni_pattern) { + // Register as wildcard for pattern matching + self.route_registry + .register_wildcard(sni_pattern, route_target) + .map_err(|e| e.to_string())?; + + if ip_filter.is_empty() { + debug!( + "Registered TLS wildcard route for SNI pattern {} -> tunnel:{}", + sni_pattern, localup_id + ); + } else { + debug!( + "Registered TLS wildcard route for SNI pattern {} -> tunnel:{} (IP filter: {} entries)", + sni_pattern, localup_id, ip_filter.len() + ); + } + } else { + // Register as exact match + let route_key = RouteKey::TlsSni(sni_pattern.clone()); + self.route_registry + .register(route_key, route_target) + .map_err(|e| e.to_string())?; + + if ip_filter.is_empty() { + debug!( + "Registered TLS route for SNI {} -> tunnel:{}", + sni_pattern, localup_id + ); + } else { + debug!( + "Registered TLS route for SNI {} -> tunnel:{} (IP filter: {} entries)", + sni_pattern, localup_id, ip_filter.len() + ); + } + } } Ok(None) } @@ -2243,10 +2285,23 @@ impl TunnelHandler { info!("Deallocated TCP port for tunnel {}", localup_id); } } - Protocol::Tls { sni_pattern, .. } => { - let route_key = RouteKey::TlsSni(sni_pattern.clone()); - let _ = self.route_registry.unregister(&route_key); - debug!("Unregistered TLS route for SNI pattern: {}", sni_pattern); + Protocol::Tls { sni_patterns, .. } => { + // Unregister all SNI patterns for this tunnel + for sni_pattern in sni_patterns { + if WildcardPattern::is_wildcard_pattern(sni_pattern) { + // Unregister wildcard pattern + let _ = self.route_registry.unregister_wildcard(sni_pattern); + debug!( + "Unregistered TLS wildcard route for SNI pattern: {}", + sni_pattern + ); + } else { + // Unregister exact match + let route_key = RouteKey::TlsSni(sni_pattern.clone()); + let _ = self.route_registry.unregister(&route_key); + debug!("Unregistered TLS route for SNI: {}", sni_pattern); + } + } } } } @@ -2505,7 +2560,7 @@ mod tests { let localup_id = "test-tunnel"; let protocols = vec![Protocol::Tls { port: 443, - sni_pattern: "*.example.com".to_string(), + sni_patterns: vec!["*.example.com".to_string()], }]; let config = TunnelConfig::default(); diff --git a/crates/localup-control/tests/disconnect_message_delivery_test.rs b/crates/localup-control/tests/disconnect_message_delivery_test.rs index b0d7999..af902bb 100644 --- a/crates/localup-control/tests/disconnect_message_delivery_test.rs +++ b/crates/localup-control/tests/disconnect_message_delivery_test.rs @@ -400,7 +400,6 @@ async fn test_disconnect_delivery_concurrent_clients() { let mut handles = Vec::new(); for i in 0..num_clients { - let server_addr = server_addr; let handle = tokio::spawn(async move { let client_config = Arc::new(QuicConfig::client_insecure()); let connector = QuicConnector::new(client_config).unwrap(); diff --git a/crates/localup-exit-node/src/main.rs b/crates/localup-exit-node/src/main.rs index 0ccc45e..854a9bc 100644 --- a/crates/localup-exit-node/src/main.rs +++ b/crates/localup-exit-node/src/main.rs @@ -405,7 +405,9 @@ async fn main() -> Result<()> { bind_addr: tls_addr, }; - let tls_server = TlsServer::new(tls_config, registry.clone()); + let tls_server = TlsServer::new(tls_config, registry.clone()) + .with_localup_manager(localup_manager.clone()) + .with_database(db.clone()); info!("✅ TLS/SNI server configured (routes based on Server Name Indication)"); Some(tokio::spawn(async move { diff --git a/crates/localup-lib/src/lib.rs b/crates/localup-lib/src/lib.rs index 2decd2c..d37866e 100644 --- a/crates/localup-lib/src/lib.rs +++ b/crates/localup-lib/src/lib.rs @@ -184,7 +184,9 @@ pub use localup_control::{ pub use localup_server_https::{HttpsServer, HttpsServerConfig}; pub use localup_server_tcp::{TcpServer, TcpServerConfig, TcpServerError}; pub use localup_server_tcp_proxy::{TcpProxyServer, TcpProxyServerConfig}; -pub use localup_server_tls::{TlsServer, TlsServerConfig}; +pub use localup_server_tls::{ + HttpPassthroughConfig, HttpPassthroughError, HttpPassthroughServer, TlsServer, TlsServerConfig, +}; // Re-export router types pub use localup_router::{RouteKey, RouteRegistry, RouteTarget}; diff --git a/crates/localup-lib/tests/sni_e2e_test.rs b/crates/localup-lib/tests/sni_e2e_test.rs index 6890a70..71f3407 100644 --- a/crates/localup-lib/tests/sni_e2e_test.rs +++ b/crates/localup-lib/tests/sni_e2e_test.rs @@ -17,6 +17,7 @@ use tokio::net::TcpListener; use tracing::info; use localup_client::ProtocolConfig; +use localup_proto::IpFilter; use localup_router::{RouteRegistry, SniRouter}; // ============================================================================ @@ -98,6 +99,7 @@ impl SniRelay { sni_hostname: sni_hostname.to_string(), localup_id: tunnel_id.to_string(), target_addr: target_addr.to_string(), + ip_filter: IpFilter::new(), }; self.router.register_route(route)?; @@ -226,19 +228,24 @@ async fn test_sni_multi_tenant_api_workflow() { info!("\n📍 Step 7: Verify TLS Protocol Configuration"); let tls_config = ProtocolConfig::Tls { local_port: 3443, - sni_hostname: Some("api-001.company.com".to_string()), + sni_hostnames: vec![ + "api-001.company.com".to_string(), + "*.local.company.com".to_string(), + ], }; match tls_config { ProtocolConfig::Tls { local_port, - sni_hostname, + sni_hostnames, } => { assert_eq!(local_port, 3443); - assert_eq!(sni_hostname, Some("api-001.company.com".to_string())); + assert_eq!(sni_hostnames.len(), 2); + assert_eq!(sni_hostnames[0], "api-001.company.com"); + assert_eq!(sni_hostnames[1], "*.local.company.com"); info!( - "✓ TLS config valid: local_port={}, sni_hostname={:?}", - local_port, sni_hostname + "✓ TLS config valid: local_port={}, sni_hostnames={:?}", + local_port, sni_hostnames ); } _ => panic!("Invalid protocol config"), diff --git a/crates/localup-proto/src/messages.rs b/crates/localup-proto/src/messages.rs index 9886c73..8ace0ab 100644 --- a/crates/localup-proto/src/messages.rs +++ b/crates/localup-proto/src/messages.rs @@ -232,8 +232,12 @@ mod serde_bytes_option { pub enum Protocol { /// TCP tunnel - port will be allocated by server if 0 Tcp { port: u16 }, - /// TLS tunnel with SNI routing - Tls { port: u16, sni_pattern: String }, + /// TLS tunnel with SNI routing (supports multiple patterns including wildcards) + /// No domain validation - relay simply routes based on SNI match + Tls { + port: u16, + sni_patterns: Vec, + }, /// HTTP tunnel - subdomain is optional (auto-generated if None) /// If custom_domain is set, it takes precedence over subdomain Http { @@ -266,9 +270,10 @@ pub struct Endpoint { /// - Basic: HTTP Basic Auth (username:password) /// - BearerToken: Validate specific header token /// - OAuth/OIDC: (future) OpenID Connect -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub enum HttpAuthConfig { /// No authentication required + #[default] None, /// HTTP Basic Authentication /// Credentials are "username:password" pairs @@ -286,12 +291,6 @@ pub enum HttpAuthConfig { // Oidc { provider_url: String, client_id: String, ... } } -impl Default for HttpAuthConfig { - fn default() -> Self { - Self::None - } -} - /// Tunnel configuration #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct TunnelConfig { @@ -426,4 +425,122 @@ mod tests { let deserialized: Protocol = bincode::deserialize(&serialized).unwrap(); assert_eq!(protocol, deserialized); } + + #[test] + fn test_tls_protocol_single_sni_pattern() { + let protocol = Protocol::Tls { + port: 443, + sni_patterns: vec!["api.example.com".to_string()], + }; + let serialized = bincode::serialize(&protocol).unwrap(); + let deserialized: Protocol = bincode::deserialize(&serialized).unwrap(); + assert_eq!(protocol, deserialized); + + if let Protocol::Tls { port, sni_patterns } = deserialized { + assert_eq!(port, 443); + assert_eq!(sni_patterns.len(), 1); + assert_eq!(sni_patterns[0], "api.example.com"); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_multiple_sni_patterns() { + let protocol = Protocol::Tls { + port: 8443, + sni_patterns: vec![ + "api.example.com".to_string(), + "web.example.com".to_string(), + "admin.example.com".to_string(), + ], + }; + let serialized = bincode::serialize(&protocol).unwrap(); + let deserialized: Protocol = bincode::deserialize(&serialized).unwrap(); + assert_eq!(protocol, deserialized); + + if let Protocol::Tls { port, sni_patterns } = deserialized { + assert_eq!(port, 8443); + assert_eq!(sni_patterns.len(), 3); + assert_eq!(sni_patterns[0], "api.example.com"); + assert_eq!(sni_patterns[1], "web.example.com"); + assert_eq!(sni_patterns[2], "admin.example.com"); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_wildcard_patterns() { + let protocol = Protocol::Tls { + port: 443, + sni_patterns: vec![ + "*.example.com".to_string(), + "*.local-abc123.myapp.dev".to_string(), + "specific.domain.com".to_string(), + ], + }; + let serialized = bincode::serialize(&protocol).unwrap(); + let deserialized: Protocol = bincode::deserialize(&serialized).unwrap(); + assert_eq!(protocol, deserialized); + + if let Protocol::Tls { sni_patterns, .. } = deserialized { + assert_eq!(sni_patterns.len(), 3); + assert!(sni_patterns[0].starts_with("*.")); + assert!(sni_patterns[1].starts_with("*.")); + assert!(!sni_patterns[2].starts_with("*.")); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_tls_protocol_empty_patterns() { + let protocol = Protocol::Tls { + port: 443, + sni_patterns: vec![], + }; + let serialized = bincode::serialize(&protocol).unwrap(); + let deserialized: Protocol = bincode::deserialize(&serialized).unwrap(); + assert_eq!(protocol, deserialized); + + if let Protocol::Tls { sni_patterns, .. } = deserialized { + assert!(sni_patterns.is_empty()); + } else { + panic!("Expected TLS protocol"); + } + } + + #[test] + fn test_connect_message_with_tls_protocol() { + let msg = TunnelMessage::Connect { + localup_id: "tunnel-123".to_string(), + auth_token: "test-token".to_string(), + protocols: vec![Protocol::Tls { + port: 443, + sni_patterns: vec![ + "*.local-rqe59t.dviejo.temps.dev".to_string(), + "api.production.com".to_string(), + ], + }], + config: TunnelConfig::default(), + }; + + let serialized = bincode::serialize(&msg).unwrap(); + let deserialized: TunnelMessage = bincode::deserialize(&serialized).unwrap(); + assert_eq!(msg, deserialized); + + if let TunnelMessage::Connect { protocols, .. } = deserialized { + assert_eq!(protocols.len(), 1); + if let Protocol::Tls { sni_patterns, .. } = &protocols[0] { + assert_eq!(sni_patterns.len(), 2); + assert_eq!(sni_patterns[0], "*.local-rqe59t.dviejo.temps.dev"); + assert_eq!(sni_patterns[1], "api.production.com"); + } else { + panic!("Expected TLS protocol"); + } + } else { + panic!("Expected Connect message"); + } + } } diff --git a/crates/localup-router/src/sni.rs b/crates/localup-router/src/sni.rs index 2613e64..aa69579 100644 --- a/crates/localup-router/src/sni.rs +++ b/crates/localup-router/src/sni.rs @@ -381,4 +381,162 @@ mod tests { let result = SniRouter::extract_sni(&client_hello); assert!(result.is_err()); } + + #[test] + fn test_multiple_sni_routes_registration() { + let registry = Arc::new(RouteRegistry::new()); + let router = SniRouter::new(registry); + + // Register multiple SNI hostnames for the same tunnel + let hostnames = vec!["api.example.com", "web.example.com", "admin.example.com"]; + + for hostname in &hostnames { + let route = SniRoute { + sni_hostname: hostname.to_string(), + localup_id: "tunnel-multi".to_string(), + target_addr: "localhost:3000".to_string(), + ip_filter: IpFilter::new(), + }; + router.register_route(route).unwrap(); + } + + // Verify all routes exist + for hostname in &hostnames { + assert!(router.has_route(hostname)); + let target = router.lookup(hostname).unwrap(); + assert_eq!(target.localup_id, "tunnel-multi"); + } + } + + #[test] + fn test_multiple_wildcard_patterns() { + let registry = Arc::new(RouteRegistry::new()); + let router = SniRouter::new(registry); + + // Register multiple wildcard patterns + let patterns = vec![ + "*.local-abc123.myapp.dev", + "*.staging.myapp.dev", + "*.production.myapp.dev", + ]; + + for pattern in &patterns { + let route = SniRoute { + sni_hostname: pattern.to_string(), + localup_id: "tunnel-wildcards".to_string(), + target_addr: "localhost:8443".to_string(), + ip_filter: IpFilter::new(), + }; + router.register_route(route).unwrap(); + } + + // Verify all patterns are registered + for pattern in &patterns { + assert!(router.has_route(pattern)); + } + } + + #[test] + fn test_mixed_specific_and_wildcard_patterns() { + let registry = Arc::new(RouteRegistry::new()); + let router = SniRouter::new(registry); + + // Register mix of specific hostnames and wildcards + let routes = vec![ + ("api.specific.com", "tunnel-api"), + ("*.wildcard.example.com", "tunnel-wildcard"), + ("web.specific.com", "tunnel-web"), + ("*.another.wildcard.io", "tunnel-another"), + ]; + + for (hostname, tunnel_id) in &routes { + let route = SniRoute { + sni_hostname: hostname.to_string(), + localup_id: tunnel_id.to_string(), + target_addr: "localhost:443".to_string(), + ip_filter: IpFilter::new(), + }; + router.register_route(route).unwrap(); + } + + // Verify each route exists with correct tunnel ID + for (hostname, tunnel_id) in &routes { + assert!(router.has_route(hostname)); + let target = router.lookup(hostname).unwrap(); + assert_eq!(target.localup_id, *tunnel_id); + } + } + + #[test] + fn test_unregister_multiple_patterns() { + let registry = Arc::new(RouteRegistry::new()); + let router = SniRouter::new(registry); + + let patterns = vec![ + "pattern1.example.com", + "pattern2.example.com", + "*.wildcard.example.com", + ]; + + // Register all + for pattern in &patterns { + let route = SniRoute { + sni_hostname: pattern.to_string(), + localup_id: "tunnel-test".to_string(), + target_addr: "localhost:443".to_string(), + ip_filter: IpFilter::new(), + }; + router.register_route(route).unwrap(); + } + + // Verify all exist + for pattern in &patterns { + assert!(router.has_route(pattern)); + } + + // Unregister one + router.unregister("pattern1.example.com").unwrap(); + assert!(!router.has_route("pattern1.example.com")); + + // Others should still exist + assert!(router.has_route("pattern2.example.com")); + assert!(router.has_route("*.wildcard.example.com")); + + // Unregister remaining + router.unregister("pattern2.example.com").unwrap(); + router.unregister("*.wildcard.example.com").unwrap(); + + assert!(!router.has_route("pattern2.example.com")); + assert!(!router.has_route("*.wildcard.example.com")); + } + + #[test] + fn test_same_tunnel_different_patterns() { + let registry = Arc::new(RouteRegistry::new()); + let router = SniRouter::new(registry); + + // Same tunnel ID can serve multiple domains + let tunnel_id = "desktop-app-tunnel"; + let patterns = vec![ + "*.local-rqe59t.dviejo.temps.dev", + "api.production.temps.dev", + "*.staging.temps.dev", + ]; + + for pattern in &patterns { + let route = SniRoute { + sni_hostname: pattern.to_string(), + localup_id: tunnel_id.to_string(), + target_addr: "localhost:443".to_string(), + ip_filter: IpFilter::new(), + }; + router.register_route(route).unwrap(); + } + + // All patterns should resolve to the same tunnel + for pattern in &patterns { + let target = router.lookup(pattern).unwrap(); + assert_eq!(target.localup_id, tunnel_id); + } + } } diff --git a/crates/localup-router/tests/sni_e2e_test.rs b/crates/localup-router/tests/sni_e2e_test.rs index 2177251..486baab 100644 --- a/crates/localup-router/tests/sni_e2e_test.rs +++ b/crates/localup-router/tests/sni_e2e_test.rs @@ -7,6 +7,7 @@ //! 4. Concurrent access to SNI router //! 5. Proper error handling for malformed ClientHellos +use localup_proto::IpFilter; use localup_router::{RouteRegistry, SniRouter}; use std::sync::Arc; @@ -31,18 +32,21 @@ fn test_sni_routing_workflow() { sni_hostname: "api.example.com".to_string(), localup_id: "tunnel-api-001".to_string(), target_addr: "127.0.0.1:3443".to_string(), + ip_filter: IpFilter::new(), }; let web_route = localup_router::sni::SniRoute { sni_hostname: "web.example.com".to_string(), localup_id: "tunnel-web-001".to_string(), target_addr: "127.0.0.1:3444".to_string(), + ip_filter: IpFilter::new(), }; let db_route = localup_router::sni::SniRoute { sni_hostname: "db.example.com".to_string(), localup_id: "tunnel-db-001".to_string(), target_addr: "127.0.0.1:3445".to_string(), + ip_filter: IpFilter::new(), }; router @@ -146,6 +150,7 @@ fn test_sni_with_certificates_on_different_domains() { sni_hostname: domain.to_string(), localup_id: format!("tunnel-{:03}", idx), target_addr: addr.to_string(), + ip_filter: IpFilter::new(), }; router .register_route(route) @@ -226,6 +231,7 @@ fn test_concurrent_sni_routing() { sni_hostname: hostname.clone(), localup_id: tunnel_id.clone(), target_addr: target_addr.clone(), + ip_filter: IpFilter::new(), }; router_clone.register_route(route).unwrap(); @@ -279,6 +285,7 @@ fn test_sni_route_persistence() { sni_hostname: "persistent.example.com".to_string(), localup_id: "tunnel-persistent".to_string(), target_addr: "127.0.0.1:3443".to_string(), + ip_filter: IpFilter::new(), }; router.register_route(route).unwrap(); diff --git a/crates/localup-server-https/src/server.rs b/crates/localup-server-https/src/server.rs index 36fd402..055ea51 100644 --- a/crates/localup-server-https/src/server.rs +++ b/crates/localup-server-https/src/server.rs @@ -561,8 +561,10 @@ impl HttpsServer { // Check IP filtering if !target.is_ip_allowed(&peer_addr) { warn!( - "Connection from {} denied by IP filter for host: {}", - peer_addr, host + "Connection f rom IP {} denied by IP filter for host: {} (allowed: {:?})", + peer_addr.ip(), + host, + target.ip_filter ); let response = b"HTTP/1.1 403 Forbidden\r\nContent-Length: 13\r\n\r\nAccess denied"; tls_stream.write_all(response).await?; diff --git a/crates/localup-server-tls/Cargo.toml b/crates/localup-server-tls/Cargo.toml index ab660b1..001864a 100644 --- a/crates/localup-server-tls/Cargo.toml +++ b/crates/localup-server-tls/Cargo.toml @@ -12,11 +12,15 @@ localup-transport = { path = "../localup-transport" } localup-transport-quic = { path = "../localup-transport-quic" } localup-control = { path = "../localup-control" } localup-cert = { path = "../localup-cert" } +localup-relay-db = { path = "../localup-relay-db" } tokio = { workspace = true } tokio-rustls = { workspace = true } rustls = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +sea-orm = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/localup-server-tls/src/http_passthrough.rs b/crates/localup-server-tls/src/http_passthrough.rs new file mode 100644 index 0000000..44e3ee8 --- /dev/null +++ b/crates/localup-server-tls/src/http_passthrough.rs @@ -0,0 +1,703 @@ +//! HTTP passthrough server with Host-based routing +//! +//! This server accepts incoming HTTP connections, extracts the Host header +//! from the first request, and routes the connection to the appropriate backend service. +//! +//! No HTTP processing is performed beyond initial Host extraction - the stream is +//! forwarded as-is to preserve the original request/response flow. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use thiserror::Error; +use tracing::{debug, error, info, warn}; + +use localup_control::TunnelConnectionManager; +use localup_proto::TunnelMessage; +use localup_router::{RouteRegistry, SniRouter}; +use localup_transport::{TransportConnection, TransportStream}; +use sea_orm::DatabaseConnection; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +#[derive(Debug, Error)] +pub enum HttpPassthroughError { + #[error("IO error: {0}")] + IoError(#[from] std::io::Error), + + #[error("Host header extraction failed")] + HostExtractionFailed, + + #[error("No route found for host: {0}")] + NoRoute(String), + + #[error("Access denied for IP: {0}")] + AccessDenied(String), + + #[error("Transport error: {0}")] + TransportError(String), + + #[error("Failed to bind to {address}: {reason}\n\nTroubleshooting:\n • Check if another process is using this port: lsof -i :{port}\n • Try using a different address or port")] + BindError { + address: String, + port: u16, + reason: String, + }, +} + +#[derive(Debug, Clone)] +pub struct HttpPassthroughConfig { + pub bind_addr: SocketAddr, +} + +impl Default for HttpPassthroughConfig { + fn default() -> Self { + Self { + bind_addr: "0.0.0.0:80".parse().unwrap(), + } + } +} + +/// Tracks metrics for an individual HTTP connection +struct HttpConnectionMetrics { + bytes_received: Arc, + bytes_sent: Arc, + connected_at: chrono::DateTime, +} + +pub struct HttpPassthroughServer { + config: HttpPassthroughConfig, + sni_router: Arc, + tunnel_manager: Option>, + db: Option, +} + +impl HttpPassthroughServer { + /// Create a new HTTP passthrough server with Host-based routing + pub fn new(config: HttpPassthroughConfig, route_registry: Arc) -> Self { + let sni_router = Arc::new(SniRouter::new(route_registry)); + Self { + config, + sni_router, + tunnel_manager: None, + db: None, + } + } + + /// Set the tunnel connection manager for forwarding to tunnels + pub fn with_localup_manager(mut self, manager: Arc) -> Self { + self.tunnel_manager = Some(manager); + self + } + + /// Set database connection for metrics tracking + pub fn with_database(mut self, db: DatabaseConnection) -> Self { + self.db = Some(db); + self + } + + /// Start the HTTP passthrough server + pub async fn start(&self) -> Result<(), HttpPassthroughError> { + info!( + "HTTP passthrough server starting on {}", + self.config.bind_addr + ); + + let listener = TcpListener::bind(self.config.bind_addr) + .await + .map_err(|e| { + let port = self.config.bind_addr.port(); + let address = self.config.bind_addr.ip().to_string(); + let reason = e.to_string(); + HttpPassthroughError::BindError { + address, + port, + reason, + } + })?; + info!( + "✅ HTTP passthrough server listening on {} (Host header routing)", + self.config.bind_addr + ); + + loop { + match listener.accept().await { + Ok((socket, peer_addr)) => { + debug!("New HTTP connection from {}", peer_addr); + + let sni_router = self.sni_router.clone(); + let tunnel_manager = self.tunnel_manager.clone(); + let db = self.db.clone(); + let http_port = self.config.bind_addr.port(); + + tokio::spawn(async move { + if let Err(e) = Self::forward_http_stream( + socket, + &sni_router, + tunnel_manager, + peer_addr, + db, + http_port, + ) + .await + { + debug!("Error forwarding HTTP stream from {}: {}", peer_addr, e); + } + }); + } + Err(e) => { + error!("HTTP listener accept error: {}", e); + } + } + } + } + + /// Extract Host header from HTTP request + fn extract_host(data: &[u8]) -> Option { + // Convert to string for parsing + let request_str = std::str::from_utf8(data).ok()?; + + // Look for Host header (case-insensitive) + for line in request_str.lines() { + if line.is_empty() { + // End of headers + break; + } + let line_lower = line.to_lowercase(); + if line_lower.starts_with("host:") { + let host_value = &line[5..]; // Skip "Host:" or "host:" + let host = host_value.trim(); + // Remove port if present + let hostname = host.split(':').next().unwrap_or(host); + return Some(hostname.to_string()); + } + } + None + } + + /// Forward HTTP stream to backend based on Host header extraction + async fn forward_http_stream( + mut client_socket: tokio::net::TcpStream, + sni_router: &Arc, + tunnel_manager: Option>, + peer_addr: SocketAddr, + db: Option, + http_port: u16, + ) -> Result<(), HttpPassthroughError> { + // Read the initial HTTP request + let mut request_buf = [0u8; 16384]; + let n = client_socket + .read(&mut request_buf) + .await + .map_err(|e| HttpPassthroughError::TransportError(e.to_string()))?; + + if n == 0 { + debug!("Client closed connection before sending request"); + return Ok(()); + } + + debug!("Received {} bytes from HTTP client", n); + + // Extract Host header from the request + let hostname = Self::extract_host(&request_buf[..n]).ok_or_else(|| { + debug!("Host header extraction failed from {}", peer_addr); + HttpPassthroughError::HostExtractionFailed + })?; + + info!( + "📥 HTTP connection from {} for Host: {}", + peer_addr, hostname + ); + + // Look up the route for this hostname (using SNI router which works for any hostname) + let route = sni_router.lookup(&hostname).map_err(|e| { + debug!( + "No route found for Host {} from {}: {}", + hostname, peer_addr, e + ); + HttpPassthroughError::NoRoute(hostname.clone()) + })?; + + // Check IP filtering + if !route.is_ip_allowed(&peer_addr) { + warn!( + "🚫 Connection from {} denied by IP filter for Host: {}", + peer_addr, hostname + ); + return Err(HttpPassthroughError::AccessDenied(peer_addr.to_string())); + } + + info!( + "🔀 Routing Host {} to backend: {} (localup: {})", + hostname, route.target_addr, route.localup_id + ); + + // Create connection metrics tracker + let connection_id = uuid::Uuid::new_v4().to_string(); + let metrics = HttpConnectionMetrics { + bytes_received: Arc::new(AtomicU64::new(n as u64)), + bytes_sent: Arc::new(AtomicU64::new(0)), + connected_at: chrono::Utc::now(), + }; + + // Save active connection to database + if let Some(ref db_conn) = db { + let active_connection = + localup_relay_db::entities::captured_tcp_connection::ActiveModel { + id: sea_orm::Set(connection_id.clone()), + localup_id: sea_orm::Set(route.localup_id.clone()), + client_addr: sea_orm::Set(format!("{}|host:{}", peer_addr, hostname)), + target_port: sea_orm::Set(http_port as i32), + bytes_received: sea_orm::Set(n as i64), + bytes_sent: sea_orm::Set(0), + connected_at: sea_orm::Set(metrics.connected_at.into()), + disconnected_at: sea_orm::NotSet, + duration_ms: sea_orm::NotSet, + disconnect_reason: sea_orm::NotSet, + }; + + use sea_orm::EntityTrait; + if let Err(e) = localup_relay_db::entities::prelude::CapturedTcpConnection::insert( + active_connection, + ) + .exec(db_conn) + .await + { + warn!( + "Failed to save active HTTP connection {}: {}", + connection_id, e + ); + } else { + debug!("Saved active HTTP connection {} to database", connection_id); + } + } + + // Check if this is a tunnel target (format: tunnel:localup_id) + let result = if route.target_addr.starts_with("tunnel:") { + let localup_id = route.target_addr.strip_prefix("tunnel:").unwrap(); + + let manager = tunnel_manager.ok_or_else(|| { + HttpPassthroughError::TransportError( + "Tunnel target requested but tunnel manager not configured".to_string(), + ) + })?; + + let connection = manager.get(localup_id).await.ok_or_else(|| { + HttpPassthroughError::TransportError(format!("Tunnel not found: {}", localup_id)) + })?; + + let backend_stream = connection.open_stream().await.map_err(|e| { + HttpPassthroughError::TransportError(format!( + "Failed to open stream to tunnel {}: {}", + localup_id, e + )) + })?; + + Self::forward_via_tunnel( + client_socket, + backend_stream, + &request_buf[..n], + peer_addr, + &hostname, + metrics.bytes_received.clone(), + metrics.bytes_sent.clone(), + ) + .await + } else { + // Direct backend connection (legacy support) + let backend_addr: SocketAddr = route.target_addr.parse().map_err(|e| { + HttpPassthroughError::TransportError(format!( + "Invalid backend address {}: {}", + route.target_addr, e + )) + })?; + + Self::forward_to_backend( + client_socket, + backend_addr, + &request_buf[..n], + metrics.bytes_received.clone(), + metrics.bytes_sent.clone(), + ) + .await + }; + + // Update database with final metrics + let disconnected_at = chrono::Utc::now(); + let duration_ms = (disconnected_at - metrics.connected_at).num_milliseconds(); + + if let Some(ref db_conn) = db { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let update = localup_relay_db::entities::captured_tcp_connection::ActiveModel { + id: sea_orm::Set(connection_id.clone()), + bytes_received: sea_orm::Set(metrics.bytes_received.load(Ordering::Relaxed) as i64), + bytes_sent: sea_orm::Set(metrics.bytes_sent.load(Ordering::Relaxed) as i64), + disconnected_at: sea_orm::Set(Some(disconnected_at.into())), + duration_ms: sea_orm::Set(Some(duration_ms as i32)), + disconnect_reason: sea_orm::Set(result.as_ref().err().map(|e| e.to_string())), + ..Default::default() + }; + + if let Err(e) = + localup_relay_db::entities::prelude::CapturedTcpConnection::update(update) + .filter( + localup_relay_db::entities::captured_tcp_connection::Column::Id + .eq(&connection_id), + ) + .exec(db_conn) + .await + { + warn!( + "Failed to update HTTP connection metrics {}: {}", + connection_id, e + ); + } + } + + info!( + "📤 HTTP connection closed: {} (duration: {}ms, received: {} bytes, sent: {} bytes)", + hostname, + duration_ms, + metrics.bytes_received.load(Ordering::Relaxed), + metrics.bytes_sent.load(Ordering::Relaxed) + ); + + result + } + + /// Forward stream via QUIC tunnel using TlsConnect/TlsData protocol + /// (Uses TLS message types so TLS tunnel clients can handle HTTP passthrough) + async fn forward_via_tunnel( + client_socket: tokio::net::TcpStream, + mut tunnel_stream: localup_transport_quic::QuicStream, + initial_data: &[u8], + peer_addr: SocketAddr, + hostname: &str, + bytes_received: Arc, + bytes_sent: Arc, + ) -> Result<(), HttpPassthroughError> { + // Generate stream ID for this connection + static STREAM_COUNTER: AtomicU32 = AtomicU32::new(1); + let stream_id = STREAM_COUNTER.fetch_add(1, Ordering::SeqCst); + + debug!( + "Opening HTTP tunnel stream {} for peer {} (Host: {})", + stream_id, peer_addr, hostname + ); + + // Send TlsConnect message with the HTTP request as "client_hello" + // This allows TLS tunnel clients to handle HTTP passthrough traffic + // The SNI field contains the Host header value for routing info + let connect_msg = TunnelMessage::TlsConnect { + stream_id, + sni: hostname.to_string(), + client_hello: initial_data.to_vec(), + }; + + tunnel_stream + .send_message(&connect_msg) + .await + .map_err(|e| { + HttpPassthroughError::TransportError(format!("Failed to send TlsConnect: {}", e)) + })?; + + debug!( + "Sent TlsConnect with {} bytes on stream {} (Host: {})", + initial_data.len(), + stream_id, + hostname + ); + + // Split both streams for bidirectional forwarding + let (mut client_read, mut client_write) = client_socket.into_split(); + let (mut tunnel_send, mut tunnel_recv) = tunnel_stream.split(); + + // Client to tunnel + let bytes_received_clone = bytes_received.clone(); + let client_to_tunnel = async move { + let mut buf = [0u8; 8192]; + loop { + match client_read.read(&mut buf).await { + Ok(0) => { + debug!("Client closed HTTP connection (stream {})", stream_id); + let close_msg = TunnelMessage::TlsClose { stream_id }; + let _ = tunnel_send.send_message(&close_msg).await; + break; + } + Ok(n) => { + bytes_received_clone.fetch_add(n as u64, Ordering::Relaxed); + let data_msg = TunnelMessage::TlsData { + stream_id, + data: buf[..n].to_vec(), + }; + if let Err(e) = tunnel_send.send_message(&data_msg).await { + debug!("Error sending HTTP data to tunnel: {}", e); + break; + } + } + Err(e) => { + debug!("Error reading from HTTP client: {}", e); + let close_msg = TunnelMessage::TlsClose { stream_id }; + let _ = tunnel_send.send_message(&close_msg).await; + break; + } + } + } + }; + + // Tunnel to client + let bytes_sent_clone = bytes_sent.clone(); + let tunnel_to_client = async move { + loop { + match tunnel_recv.recv_message().await { + Ok(Some(TunnelMessage::TlsData { + stream_id: msg_stream_id, + data, + })) => { + if msg_stream_id != stream_id { + debug!( + "Received TLS data for wrong stream: expected {}, got {}", + stream_id, msg_stream_id + ); + continue; + } + bytes_sent_clone.fetch_add(data.len() as u64, Ordering::Relaxed); + if let Err(e) = client_write.write_all(&data).await { + debug!("Error writing HTTP data to client: {}", e); + break; + } + } + Ok(Some(TunnelMessage::TlsClose { + stream_id: msg_stream_id, + })) => { + if msg_stream_id == stream_id { + debug!("Tunnel closed HTTP stream {}", stream_id); + let _ = client_write.shutdown().await; + break; + } + } + Ok(Some(_msg)) => { + debug!( + "Received unexpected message type for HTTP stream {}", + stream_id + ); + } + Ok(None) => { + debug!( + "Tunnel stream closed for HTTP connection (stream {})", + stream_id + ); + break; + } + Err(e) => { + debug!("Error receiving from tunnel: {}", e); + break; + } + } + } + }; + + // Run both tasks concurrently + tokio::select! { + _ = client_to_tunnel => {} + _ = tunnel_to_client => {} + } + + debug!("HTTP tunnel connection closed for peer: {}", peer_addr); + Ok(()) + } + + /// Forward to direct backend (legacy support) + async fn forward_to_backend( + mut client_socket: tokio::net::TcpStream, + backend_addr: SocketAddr, + initial_data: &[u8], + bytes_received: Arc, + bytes_sent: Arc, + ) -> Result<(), HttpPassthroughError> { + let mut backend_socket = + tokio::net::TcpStream::connect(backend_addr) + .await + .map_err(|e| { + HttpPassthroughError::TransportError(format!( + "Failed to connect to backend {}: {}", + backend_addr, e + )) + })?; + + // Send initial data + backend_socket + .write_all(initial_data) + .await + .map_err(|e| HttpPassthroughError::TransportError(e.to_string()))?; + + // Bidirectional forwarding + let (mut client_read, mut client_write) = client_socket.split(); + let (mut backend_read, mut backend_write) = backend_socket.split(); + + let bytes_received_clone = bytes_received.clone(); + let bytes_sent_clone = bytes_sent.clone(); + + let client_to_backend = async { + let mut buf = vec![0u8; 65536]; + loop { + match client_read.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + bytes_received_clone.fetch_add(n as u64, Ordering::Relaxed); + if backend_write.write_all(&buf[..n]).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }; + + let backend_to_client = async { + let mut buf = vec![0u8; 65536]; + loop { + match backend_read.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + bytes_sent_clone.fetch_add(n as u64, Ordering::Relaxed); + if client_write.write_all(&buf[..n]).await.is_err() { + break; + } + } + Err(_) => break, + } + } + }; + + tokio::select! { + _ = client_to_backend => {} + _ = backend_to_client => {} + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_host_basic() { + let request = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_with_port() { + let request = b"GET / HTTP/1.1\r\nHost: example.com:8080\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_lowercase() { + let request = b"GET / HTTP/1.1\r\nhost: example.com\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_mixed_case() { + let request = b"GET / HTTP/1.1\r\nHoSt: Example.COM\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("Example.COM".to_string()) + ); + } + + #[test] + fn test_extract_host_with_path() { + let request = b"GET /api/v1/users HTTP/1.1\r\nHost: api.example.com\r\nContent-Type: application/json\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("api.example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_subdomain() { + let request = b"GET / HTTP/1.1\r\nHost: sub.domain.example.com\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("sub.domain.example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_missing() { + let request = b"GET / HTTP/1.1\r\nContent-Type: text/html\r\n\r\n"; + assert_eq!(HttpPassthroughServer::extract_host(request), None); + } + + #[test] + fn test_extract_host_empty_request() { + let request = b""; + assert_eq!(HttpPassthroughServer::extract_host(request), None); + } + + #[test] + fn test_extract_host_post_request() { + let request = b"POST /submit HTTP/1.1\r\nHost: forms.example.com\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 13\r\n\r\ndata=example"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("forms.example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_with_whitespace() { + let request = b"GET / HTTP/1.1\r\nHost: example.com \r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("example.com".to_string()) + ); + } + + #[test] + fn test_extract_host_ipv4() { + let request = b"GET / HTTP/1.1\r\nHost: 192.168.1.1\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("192.168.1.1".to_string()) + ); + } + + #[test] + fn test_extract_host_ipv4_with_port() { + let request = b"GET / HTTP/1.1\r\nHost: 192.168.1.1:8080\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("192.168.1.1".to_string()) + ); + } + + #[test] + fn test_extract_host_localhost() { + let request = b"GET / HTTP/1.1\r\nHost: localhost:3000\r\n\r\n"; + assert_eq!( + HttpPassthroughServer::extract_host(request), + Some("localhost".to_string()) + ); + } + + #[test] + fn test_config_default() { + let config = HttpPassthroughConfig::default(); + assert_eq!(config.bind_addr.port(), 80); + } +} diff --git a/crates/localup-server-tls/src/lib.rs b/crates/localup-server-tls/src/lib.rs index 772a5fb..2282cfe 100644 --- a/crates/localup-server-tls/src/lib.rs +++ b/crates/localup-server-tls/src/lib.rs @@ -1,3 +1,6 @@ -//! TLS/SNI tunnel server +//! TLS/SNI tunnel server with HTTP passthrough support +pub mod http_passthrough; pub mod server; + +pub use http_passthrough::{HttpPassthroughConfig, HttpPassthroughError, HttpPassthroughServer}; pub use server::{TlsServer, TlsServerConfig}; diff --git a/crates/localup-server-tls/src/server.rs b/crates/localup-server-tls/src/server.rs index 4d80842..d27d3d1 100644 --- a/crates/localup-server-tls/src/server.rs +++ b/crates/localup-server-tls/src/server.rs @@ -6,15 +6,17 @@ //! No TLS termination is performed - the TLS stream is forwarded as-is to preserve //! end-to-end encryption between the client and backend service. use std::net::SocketAddr; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use thiserror::Error; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use localup_control::TunnelConnectionManager; use localup_proto::TunnelMessage; use localup_router::{RouteRegistry, SniRouter}; -use localup_transport::TransportConnection; +use localup_transport::{TransportConnection, TransportStream}; +use localup_transport_quic::QuicStream; +use sea_orm::DatabaseConnection; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; @@ -62,10 +64,18 @@ impl Default for TlsServerConfig { } } +/// Tracks metrics for an individual TLS connection +struct TlsConnectionMetrics { + bytes_received: Arc, + bytes_sent: Arc, + connected_at: chrono::DateTime, +} + pub struct TlsServer { config: TlsServerConfig, sni_router: Arc, tunnel_manager: Option>, + db: Option, } impl TlsServer { @@ -76,6 +86,7 @@ impl TlsServer { config, sni_router, tunnel_manager: None, + db: None, } } @@ -85,6 +96,12 @@ impl TlsServer { self } + /// Set database connection for metrics tracking + pub fn with_database(mut self, db: DatabaseConnection) -> Self { + self.db = Some(db); + self + } + /// Get reference to SNI router for registering routes pub fn sni_router(&self) -> Arc { self.sni_router.clone() @@ -122,12 +139,20 @@ impl TlsServer { let sni_router = self.sni_router.clone(); let tunnel_manager = self.tunnel_manager.clone(); + let db = self.db.clone(); + let tls_port = self.config.bind_addr.port(); tokio::spawn(async move { // Forward the raw TLS stream based on SNI extraction - if let Err(e) = - Self::forward_tls_stream(socket, &sni_router, tunnel_manager, peer_addr) - .await + if let Err(e) = Self::forward_tls_stream( + socket, + &sni_router, + tunnel_manager, + peer_addr, + db, + tls_port, + ) + .await { debug!("Error forwarding TLS stream from {}: {}", peer_addr, e); } @@ -147,6 +172,8 @@ impl TlsServer { sni_router: &Arc, tunnel_manager: Option>, peer_addr: SocketAddr, + db: Option, + tls_port: u16, ) -> Result<(), TlsServerError> { // Read the ClientHello from the incoming connection let mut client_hello_buf = [0u8; 16384]; @@ -168,7 +195,10 @@ impl TlsServer { TlsServerError::SniExtractionFailed })?; - debug!("Extracted SNI: {} from client {}", sni_hostname, peer_addr); + info!( + "📥 TLS connection from {} for SNI: {}", + peer_addr, sni_hostname + ); // Look up the route for this SNI hostname let route = sni_router.lookup(&sni_hostname).map_err(|e| { @@ -181,20 +211,60 @@ impl TlsServer { // Check IP filtering if !route.is_ip_allowed(&peer_addr) { - debug!( - "Connection from {} denied by IP filter for SNI: {}", + warn!( + "🚫 Connection from {} denied by IP filter for SNI: {}", peer_addr, sni_hostname ); return Err(TlsServerError::AccessDenied(peer_addr.to_string())); } - debug!( - "Routing SNI {} to backend: {}", - sni_hostname, route.target_addr + info!( + "🔀 Routing SNI {} to backend: {} (localup: {})", + sni_hostname, route.target_addr, route.localup_id ); + // Create connection metrics tracker + let connection_id = uuid::Uuid::new_v4().to_string(); + let metrics = TlsConnectionMetrics { + bytes_received: Arc::new(AtomicU64::new(n as u64)), // Include ClientHello bytes + bytes_sent: Arc::new(AtomicU64::new(0)), + connected_at: chrono::Utc::now(), + }; + + // Save active connection to database + if let Some(ref db_conn) = db { + let active_connection = + localup_relay_db::entities::captured_tcp_connection::ActiveModel { + id: sea_orm::Set(connection_id.clone()), + localup_id: sea_orm::Set(route.localup_id.clone()), + client_addr: sea_orm::Set(format!("{}|sni:{}", peer_addr, sni_hostname)), + target_port: sea_orm::Set(tls_port as i32), + bytes_received: sea_orm::Set(n as i64), + bytes_sent: sea_orm::Set(0), + connected_at: sea_orm::Set(metrics.connected_at.into()), + disconnected_at: sea_orm::NotSet, + duration_ms: sea_orm::NotSet, + disconnect_reason: sea_orm::NotSet, + }; + + use sea_orm::EntityTrait; + if let Err(e) = localup_relay_db::entities::prelude::CapturedTcpConnection::insert( + active_connection, + ) + .exec(db_conn) + .await + { + warn!( + "Failed to save active TLS connection {}: {}", + connection_id, e + ); + } else { + debug!("Saved active TLS connection {} to database", connection_id); + } + } + // Check if this is a tunnel target (format: tunnel:localup_id) - if route.target_addr.starts_with("tunnel:") { + let result = if route.target_addr.starts_with("tunnel:") { // Extract localup_id from "tunnel:localup_id" let localup_id = route.target_addr.strip_prefix("tunnel:").unwrap(); @@ -219,37 +289,105 @@ impl TlsServer { })?; // Forward using TransportStream methods - return Self::forward_via_transport_stream( + Self::forward_via_transport_stream( client_socket, backend_stream, &client_hello_buf[..n], peer_addr, + metrics.bytes_received.clone(), + metrics.bytes_sent.clone(), ) - .await; - } - - // Regular TCP backend connection - let mut backend_socket = tokio::net::TcpStream::connect(&route.target_addr) .await - .map_err(|e| { - TlsServerError::TransportError(format!( - "Failed to connect to backend {}: {}", - route.target_addr, e - )) - })?; + } else { + // Regular TCP backend connection + let mut backend_socket = tokio::net::TcpStream::connect(&route.target_addr) + .await + .map_err(|e| { + TlsServerError::TransportError(format!( + "Failed to connect to backend {}: {}", + route.target_addr, e + )) + })?; + + // Send the ClientHello to the backend + backend_socket + .write_all(&client_hello_buf[..n]) + .await + .map_err(|e| TlsServerError::TransportError(e.to_string()))?; + + // Bidirectionally forward data between client and backend with metrics + Self::forward_tcp_streams( + client_socket, + backend_socket, + metrics.bytes_received.clone(), + metrics.bytes_sent.clone(), + ) + .await + }; + + // Update final metrics in database + let disconnected_at = chrono::Utc::now(); + let duration_ms = (disconnected_at - metrics.connected_at).num_milliseconds() as i32; + let final_bytes_received = metrics.bytes_received.load(Ordering::Relaxed); + let final_bytes_sent = metrics.bytes_sent.load(Ordering::Relaxed); + + info!( + "📤 TLS connection closed for SNI: {} ({}ms, ↓{}B ↑{}B)", + sni_hostname, duration_ms, final_bytes_received, final_bytes_sent + ); + + if let Some(ref db_conn) = db { + let disconnect_reason = match &result { + Ok(()) => "completed".to_string(), + Err(e) => format!("error: {}", e), + }; + + let update_connection = + localup_relay_db::entities::captured_tcp_connection::ActiveModel { + id: sea_orm::Set(connection_id.clone()), + localup_id: sea_orm::Unchanged(route.localup_id), + client_addr: sea_orm::Unchanged(format!("{}|sni:{}", peer_addr, sni_hostname)), + target_port: sea_orm::Unchanged(tls_port as i32), + bytes_received: sea_orm::Set(final_bytes_received as i64), + bytes_sent: sea_orm::Set(final_bytes_sent as i64), + connected_at: sea_orm::Unchanged(metrics.connected_at.into()), + disconnected_at: sea_orm::Set(Some(disconnected_at.into())), + duration_ms: sea_orm::Set(Some(duration_ms)), + disconnect_reason: sea_orm::Set(Some(disconnect_reason)), + }; - // Send the ClientHello to the backend - backend_socket - .write_all(&client_hello_buf[..n]) + use sea_orm::EntityTrait; + if let Err(e) = localup_relay_db::entities::prelude::CapturedTcpConnection::update( + update_connection, + ) + .exec(db_conn) .await - .map_err(|e| TlsServerError::TransportError(e.to_string()))?; + { + warn!( + "Failed to update TLS connection {} metrics: {}", + connection_id, e + ); + } else { + debug!("Updated TLS connection {} final metrics", connection_id); + } + } + + result + } - // Bidirectionally forward data between client and backend + /// Forward bidirectional TCP streams with metrics tracking + async fn forward_tcp_streams( + client_socket: tokio::net::TcpStream, + backend_socket: tokio::net::TcpStream, + bytes_received: Arc, + bytes_sent: Arc, + ) -> Result<(), TlsServerError> { let (mut client_read, mut client_write) = client_socket.into_split(); let (mut backend_read, mut backend_write) = backend_socket.into_split(); - let client_to_backend = async { - let mut buf = [0u8; 4096]; + let bytes_received_clone = bytes_received.clone(); + let client_to_backend = async move { + let mut buf = [0u8; 8192]; loop { match client_read.read(&mut buf).await { Ok(0) => { @@ -258,6 +396,7 @@ impl TlsServer { break; } Ok(n) => { + bytes_received_clone.fetch_add(n as u64, Ordering::Relaxed); if let Err(e) = backend_write.write_all(&buf[..n]).await { debug!("Error writing to backend: {}", e); break; @@ -271,8 +410,9 @@ impl TlsServer { } }; - let backend_to_client = async { - let mut buf = [0u8; 4096]; + let bytes_sent_clone = bytes_sent.clone(); + let backend_to_client = async move { + let mut buf = [0u8; 8192]; loop { match backend_read.read(&mut buf).await { Ok(0) => { @@ -281,6 +421,7 @@ impl TlsServer { break; } Ok(n) => { + bytes_sent_clone.fetch_add(n as u64, Ordering::Relaxed); if let Err(e) = client_write.write_all(&buf[..n]).await { debug!("Error writing to client: {}", e); break; @@ -300,19 +441,17 @@ impl TlsServer { _ = backend_to_client => {}, } - debug!( - "TLS passthrough connection closed for SNI: {}", - sni_hostname - ); Ok(()) } /// Forward TLS stream through a QUIC tunnel using TransportStream trait - async fn forward_via_transport_stream( + async fn forward_via_transport_stream( client_socket: tokio::net::TcpStream, - mut tunnel_stream: S, + mut tunnel_stream: QuicStream, client_hello: &[u8], peer_addr: SocketAddr, + bytes_received: Arc, + bytes_sent: Arc, ) -> Result<(), TlsServerError> { // Generate stream ID for this tunnel connection static STREAM_COUNTER: AtomicU32 = AtomicU32::new(1); @@ -349,30 +488,28 @@ impl TlsServer { // Split the client socket for bidirectional forwarding let (mut client_read, mut client_write) = client_socket.into_split(); - // Wrap tunnel stream in Arc> for shared access - let tunnel_stream = Arc::new(tokio::sync::Mutex::new(tunnel_stream)); - let tunnel_send = tunnel_stream.clone(); - let tunnel_recv = tunnel_stream.clone(); + // Split the QUIC stream for concurrent send/receive without mutexes + let (mut tunnel_send, mut tunnel_recv) = tunnel_stream.split(); // Bidirectional forwarding: client to tunnel - let client_to_tunnel = async { - let mut buf = [0u8; 4096]; + let bytes_received_clone = bytes_received.clone(); + let client_to_tunnel = async move { + let mut buf = [0u8; 8192]; loop { match client_read.read(&mut buf).await { Ok(0) => { debug!("Client closed TLS connection (stream {})", stream_id); let close_msg = TunnelMessage::TlsClose { stream_id }; - let mut tunnel = tunnel_send.lock().await; - let _ = tunnel.send_message(&close_msg).await; + let _ = tunnel_send.send_message(&close_msg).await; break; } Ok(n) => { + bytes_received_clone.fetch_add(n as u64, Ordering::Relaxed); let data_msg = TunnelMessage::TlsData { stream_id, data: buf[..n].to_vec(), }; - let mut tunnel = tunnel_send.lock().await; - if let Err(e) = tunnel.send_message(&data_msg).await { + if let Err(e) = tunnel_send.send_message(&data_msg).await { debug!("Error sending TLS data to tunnel: {}", e); break; } @@ -380,8 +517,7 @@ impl TlsServer { Err(e) => { debug!("Error reading from TLS client: {}", e); let close_msg = TunnelMessage::TlsClose { stream_id }; - let mut tunnel = tunnel_send.lock().await; - let _ = tunnel.send_message(&close_msg).await; + let _ = tunnel_send.send_message(&close_msg).await; break; } } @@ -389,12 +525,10 @@ impl TlsServer { }; // Tunnel to client - let tunnel_to_client = async { + let bytes_sent_clone = bytes_sent.clone(); + let tunnel_to_client = async move { loop { - let msg = { - let mut tunnel = tunnel_recv.lock().await; - tunnel.recv_message().await - }; + let msg = tunnel_recv.recv_message().await; match msg { Ok(Some(TunnelMessage::TlsData { @@ -408,6 +542,7 @@ impl TlsServer { ); continue; } + bytes_sent_clone.fetch_add(data.len() as u64, Ordering::Relaxed); if let Err(e) = client_write.write_all(&data).await { debug!("Error writing TLS data to client: {}", e); break; diff --git a/crates/localup-transport-quic/src/config.rs b/crates/localup-transport-quic/src/config.rs index 4317ed3..ad3e622 100644 --- a/crates/localup-transport-quic/src/config.rs +++ b/crates/localup-transport-quic/src/config.rs @@ -41,8 +41,8 @@ impl QuicConfig { security: TransportSecurityConfig::default(), server_cert_path: None, server_key_path: None, - keep_alive_interval: Duration::from_secs(5), - max_idle_timeout: Duration::from_secs(30), + keep_alive_interval: Duration::from_secs(3), // Faster keep-alive for quicker disconnect detection + max_idle_timeout: Duration::from_secs(10), // Detect dead connections within 10 seconds max_concurrent_streams: 100, } } @@ -64,8 +64,8 @@ impl QuicConfig { security: TransportSecurityConfig::default(), server_cert_path: Some(cert_path.to_string()), server_key_path: Some(key_path.to_string()), - keep_alive_interval: Duration::from_secs(5), - max_idle_timeout: Duration::from_secs(30), + keep_alive_interval: Duration::from_secs(3), + max_idle_timeout: Duration::from_secs(10), max_concurrent_streams: 1000, }) } @@ -148,8 +148,8 @@ impl QuicConfig { security: TransportSecurityConfig::default(), server_cert_path: Some(cert_path.to_str().unwrap().to_string()), server_key_path: Some(key_path.to_str().unwrap().to_string()), - keep_alive_interval: Duration::from_secs(5), - max_idle_timeout: Duration::from_secs(30), + keep_alive_interval: Duration::from_secs(3), + max_idle_timeout: Duration::from_secs(10), max_concurrent_streams: 1000, }) } @@ -191,8 +191,8 @@ impl QuicConfig { security: TransportSecurityConfig::default(), server_cert_path: Some(cert_path.to_str().unwrap().to_string()), server_key_path: Some(key_path.to_str().unwrap().to_string()), - keep_alive_interval: Duration::from_secs(5), - max_idle_timeout: Duration::from_secs(30), + keep_alive_interval: Duration::from_secs(3), + max_idle_timeout: Duration::from_secs(10), max_concurrent_streams: 1000, }) } @@ -445,7 +445,8 @@ mod tests { #[test] fn test_client_config_default() { let config = QuicConfig::client_default(); - assert_eq!(config.keep_alive_interval, Duration::from_secs(5)); + assert_eq!(config.keep_alive_interval, Duration::from_secs(3)); + assert_eq!(config.max_idle_timeout, Duration::from_secs(10)); assert_eq!(config.max_concurrent_streams, 100); } diff --git a/examples/tls_relay.rs b/examples/tls_relay.rs index 36092a7..8ca5a9e 100644 --- a/examples/tls_relay.rs +++ b/examples/tls_relay.rs @@ -189,7 +189,8 @@ async fn main() -> Result<(), Box> { local_host: "127.0.0.1".to_string(), protocols: vec![ProtocolConfig::Tls { local_port: api_port, - sni_hostname: Some("api.localho.st".to_string()), + sni_hostnames: vec!["api.localho.st".to_string()], + http_port: None, }], auth_token, exit_node: ExitNodeConfig::Custom("127.0.0.1:4443".to_string()),