From 6311e1099073cd412ee18897de211174dadb1a35 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Tue, 13 Jan 2026 10:29:26 +0100 Subject: [PATCH 1/9] Increase buffer size and adjust read timeout for improved performance in TunnelConnection - Updated the temporary buffer size from 8KB to 64KB to enhance performance when handling large responses. - Changed the read timeout from 100ms to 5 seconds to provide a more reasonable waiting period for data, reducing unnecessary timeouts and improving reliability in reading responses. These changes optimize the handling of network responses, ensuring better performance and user experience in the application. --- crates/localup-client/src/localup.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/localup-client/src/localup.rs b/crates/localup-client/src/localup.rs index 6cad0a1..5dfd78c 100644 --- a/crates/localup-client/src/localup.rs +++ b/crates/localup-client/src/localup.rs @@ -2017,7 +2017,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 +2131,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 +2169,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 +2197,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 +2215,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; From c4d5fc1be014de789cdcfbd360dc5aba377485d9 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Tue, 13 Jan 2026 19:56:00 +0100 Subject: [PATCH 2/9] Enhance IP filtering logging and adjust QUIC connection timeouts - Improved logging for denied connections by including the peer IP and allowed IP filter in the warning message. - Reduced the keep-alive interval from 5 seconds to 3 seconds for quicker disconnect detection. - Decreased the max idle timeout from 30 seconds to 10 seconds to facilitate faster detection of dead connections. These changes enhance monitoring of connection attempts and optimize connection management in the QUIC configuration. --- .../src-tauri/src/state/app_state.rs | 14 ++++++++++++++ crates/localup-server-https/src/server.rs | 6 ++++-- crates/localup-transport-quic/src/config.rs | 19 ++++++++++--------- 3 files changed, 28 insertions(+), 11 deletions(-) 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..7fbd857 100644 --- a/apps/localup-desktop/src-tauri/src/state/app_state.rs +++ b/apps/localup-desktop/src-tauri/src/state/app_state.rs @@ -326,6 +326,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 +448,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-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-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); } From 1cceb55c727e4052d4b24497fdedb05e2ab09147 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 24 Jan 2026 12:16:40 +0100 Subject: [PATCH 3/9] feat(tls): Add support for multiple SNI patterns including wildcards This change enables TLS tunnels to register multiple SNI hostnames/patterns for routing, including wildcard domains like *.local-abc123.myapp.dev. Key changes: - Protocol::Tls now uses sni_patterns: Vec instead of single pattern - ProtocolConfig::Tls uses sni_hostnames: Vec - CLI --custom-domain accepts multiple values for TLS tunnels - Control handler registers/unregisters multiple SNI routes per tunnel - .localup.yml config supports sni_hostnames array for TLS tunnels This is useful for desktop applications that manage their own TLS certificates and need to route traffic for multiple domains through a single tunnel connection. Tests added: - Config serialization/deserialization with multiple SNI patterns - Protocol message tests for Vec patterns - Router tests for multiple pattern registration/unregistration - Project config tests for YAML parsing with multiple hostnames --- crates/localup-api/src/handlers.rs | 8 +- crates/localup-api/src/models.rs | 6 +- crates/localup-cli/src/localup_store.rs | 1 + crates/localup-cli/src/main.rs | 73 ++++-- crates/localup-cli/src/project_config.rs | 215 +++++++++++++++++- crates/localup-cli/tests/daemon_tests.rs | 1 + crates/localup-cli/tests/integration_tests.rs | 10 +- crates/localup-client/src/config.rs | 142 +++++++++++- crates/localup-client/src/localup.rs | 9 +- crates/localup-control/src/handler.rs | 69 +++--- crates/localup-lib/tests/sni_e2e_test.rs | 17 +- crates/localup-proto/src/messages.rs | 126 +++++++++- crates/localup-router/src/sni.rs | 158 +++++++++++++ crates/localup-router/tests/sni_e2e_test.rs | 7 + examples/tls_relay.rs | 2 +- 15 files changed, 760 insertions(+), 84 deletions(-) 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-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..b58775c 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)] @@ -120,12 +122,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, @@ -664,10 +668,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 { @@ -1136,12 +1141,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,7 +1244,7 @@ fn handle_add_tunnel( protocol: String, token: Option, subdomain: Option, - custom_domain: Option, + custom_domains: Vec, relay: Option, transport: Option, remote_port: Option, @@ -1255,8 +1267,14 @@ 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, + )?; // Parse exit node let exit_node = if let Some(relay_addr) = relay { @@ -1373,11 +1391,11 @@ fn handle_list_tunnels() -> Result<()> { } ProtocolConfig::Tls { local_port, - sni_hostname, + sni_hostnames, } => { print!(" Protocol: TLS, Port: {}", local_port); - if let Some(sni) = sni_hostname { - print!(", SNI: {}", sni); + if !sni_hostnames.is_empty() { + print!(", SNI: {}", sni_hostnames.join(", ")); } println!(); } @@ -1787,28 +1805,39 @@ fn parse_protocol( protocol: &str, port: u16, subdomain: Option, - custom_domain: Option, + custom_domains: Vec, remote_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, + }) + } _ => Err(anyhow::anyhow!( "Invalid protocol: {}. Valid options: http, https, tcp, tls", protocol diff --git a/crates/localup-cli/src/project_config.rs b/crates/localup-cli/src/project_config.rs index 9852759..2d829bf 100644 --- a/crates/localup-cli/src/project_config.rs +++ b/crates/localup-cli/src/project_config.rs @@ -80,8 +80,10 @@ 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, /// Override relay server for this tunnel pub relay: Option, @@ -122,7 +124,7 @@ impl Default for ProjectTunnel { subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), relay: None, token: None, transport: None, @@ -346,7 +348,7 @@ impl ProjectTunnel { }, "tls" => ProtocolConfig::Tls { local_port: self.port, - sni_hostname: self.sni_hostname.clone(), + sni_hostnames: self.sni_hostnames.clone(), }, _ => anyhow::bail!("Unknown protocol: {}", self.protocol), }; @@ -617,12 +619,13 @@ tunnels: subdomain: Some("my-api".to_string()), custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), 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 +658,13 @@ tunnels: subdomain: None, custom_domain: None, remote_port: Some(15432), - sni_hostname: None, + sni_hostnames: Vec::new(), 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 +701,13 @@ tunnels: subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), 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 +823,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 +836,13 @@ 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 +891,13 @@ tunnels: subdomain: None, custom_domain: None, remote_port: None, - sni_hostname: None, + sni_hostnames: Vec::new(), 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 +925,187 @@ 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..47e045a 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,7 @@ 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()], }], auth_token: "test-token".to_string(), exit_node: ExitNodeConfig::Auto, @@ -325,6 +329,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 +385,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 +407,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 +441,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..b362888 100644 --- a/crates/localup-client/src/config.rs +++ b/crates/localup-client/src/config.rs @@ -14,10 +14,14 @@ 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, }, /// HTTP with host-based routing Http { @@ -248,4 +252,138 @@ 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()], + }) + .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(), 1); + assert_eq!(sni_hostnames[0], "api.example.com"); + } + _ => 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(), + ], + }) + .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(), + ], + }) + .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![], + }) + .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(), + ], + }) + .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, + } => { + 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"); + } + _ => panic!("Expected TLS protocol"), + } + } } diff --git a/crates/localup-client/src/localup.rs b/crates/localup-client/src/localup.rs index 5dfd78c..38b0aef 100644 --- a/crates/localup-client/src/localup.rs +++ b/crates/localup-client/src/localup.rs @@ -704,10 +704,15 @@ impl TunnelConnector { ProtocolConfig::Tls { local_port: _, - sni_hostname, + sni_hostnames, } => 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(); diff --git a/crates/localup-control/src/handler.rs b/crates/localup-control/src/handler.rs index 0693ff2..9418332 100644 --- a/crates/localup-control/src/handler.rs +++ b/crates/localup-control/src/handler.rs @@ -1890,18 +1890,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 +2149,32 @@ 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(), - }; + Protocol::Tls { sni_patterns, .. } => { + // Register TLS routes for all SNI patterns (supports multiple patterns including wildcards) + for sni_pattern in sni_patterns { + 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())?; + 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 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() - ); + 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() + ); + } } Ok(None) } @@ -2243,10 +2247,13 @@ 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 { + let route_key = RouteKey::TlsSni(sni_pattern.clone()); + let _ = self.route_registry.unregister(&route_key); + debug!("Unregistered TLS route for SNI pattern: {}", sni_pattern); + } } } } @@ -2505,7 +2512,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-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..3ebe0f1 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 { @@ -426,4 +430,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/examples/tls_relay.rs b/examples/tls_relay.rs index 36092a7..df34257 100644 --- a/examples/tls_relay.rs +++ b/examples/tls_relay.rs @@ -189,7 +189,7 @@ 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()], }], auth_token, exit_node: ExitNodeConfig::Custom("127.0.0.1:4443".to_string()), From e9d235b8da74d397667bcb770d3b629d0998b768 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 24 Jan 2026 13:45:43 +0100 Subject: [PATCH 4/9] fix(tls): Register wildcard SNI patterns using wildcard route registry Wildcard patterns like *.example.com were being registered as exact matches, which prevented proper pattern matching when looking up routes. Now wildcard patterns are detected using WildcardPattern::is_wildcard_pattern() and registered using register_wildcard() for proper fallback matching. Unregistration also uses unregister_wildcard() for wildcard patterns. --- crates/localup-control/src/handler.rs | 65 ++++++++++++++++++++------- 1 file changed, 48 insertions(+), 17 deletions(-) diff --git a/crates/localup-control/src/handler.rs b/crates/localup-control/src/handler.rs index 9418332..0105522 100644 --- a/crates/localup-control/src/handler.rs +++ b/crates/localup-control/src/handler.rs @@ -2152,7 +2152,6 @@ impl TunnelHandler { Protocol::Tls { sni_patterns, .. } => { // Register TLS routes for all SNI patterns (supports multiple patterns including wildcards) for sni_pattern in sni_patterns { - 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 @@ -2160,20 +2159,42 @@ impl TunnelHandler { ip_filter: ip_filter.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 pattern {} -> tunnel:{}", - sni_pattern, localup_id - ); + // 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 { - debug!( - "Registered TLS route for SNI pattern {} -> tunnel:{} (IP filter: {} entries)", - sni_pattern, localup_id, ip_filter.len() - ); + // 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) @@ -2250,9 +2271,19 @@ impl TunnelHandler { Protocol::Tls { sni_patterns, .. } => { // Unregister all SNI patterns for this tunnel for sni_pattern in sni_patterns { - let route_key = RouteKey::TlsSni(sni_pattern.clone()); - let _ = self.route_registry.unregister(&route_key); - debug!("Unregistered TLS route for SNI pattern: {}", sni_pattern); + 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); + } } } } From 7aa3620406f307ad74af4a32b1969cb620a28d27 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 24 Jan 2026 20:29:13 +0100 Subject: [PATCH 5/9] feat(logging): Enhance TLS endpoint logging with SNI pattern details - Add detailed logging for TLS endpoints showing SNI pattern count - Log individual endpoints with their public URLs - Fix clippy warning for redundant local variable in test --- crates/localup-control/src/handler.rs | 17 +++++++++++++++++ .../tests/disconnect_message_delivery_test.rs | 1 - 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/localup-control/src/handler.rs b/crates/localup-control/src/handler.rs index 0105522..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, 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(); From 9139c8e0c0ba2e9ed7ebae241d749098c33c67ab Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 24 Jan 2026 21:57:17 +0100 Subject: [PATCH 6/9] feat(tls): Add TLS/SNI passthrough support with metrics tracking - Introduced a new relay-tls target in the Makefile for starting a TLS/SNI passthrough relay. - Enhanced TlsServer to track connection metrics, including bytes received and sent, and store them in a database. - Added support for generating TLS certificates and starting TLS echo and HTTPS test servers. - Updated Cargo.toml to include new dependencies for database and metrics tracking. - Improved logging for TLS connections, including detailed information on connection status and metrics. --- Cargo.lock | 4 + Makefile | 229 +++++++++++++++++++++- crates/localup-exit-node/src/main.rs | 4 +- crates/localup-server-tls/Cargo.toml | 4 + crates/localup-server-tls/src/server.rs | 249 ++++++++++++++++++------ 5 files changed, 431 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47bb4c6..cfb62f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3737,17 +3737,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..2813891 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 tunnel tunnel-https tunnel-custom-domain tunnel-tls test test-server tls-server https-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,14 @@ 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 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 tunnel-tls - Start TLS tunnel with SNI patterns" + @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 +70,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 +86,12 @@ 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 + # Certificate paths CERT_FILE ?= localhost-cert.pem KEY_FILE ?= localhost-key.pem @@ -294,6 +311,216 @@ 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 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 TLS tunnel with SNI patterns +tunnel-tls: build + @echo "" + @echo "Starting TLS tunnel with SNI patterns..." + @echo "=========================================" + @echo " 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" + +# 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/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-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/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; From dd0eba16f6aa260bd02da9722c3a1449195afff1 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sun, 25 Jan 2026 13:02:33 +0100 Subject: [PATCH 7/9] feat(http): Add HTTP passthrough support for TLS tunnels - Introduced new configuration options for HTTP passthrough in the CLI, allowing users to specify an HTTP backend port for TLS tunnels. - Enhanced the Makefile with new targets for starting HTTP passthrough servers alongside existing TLS functionality. - Implemented an HTTP passthrough server that routes requests based on the Host header, preserving the original request/response flow. - Updated the localup CLI to handle HTTP passthrough configurations, including command-line arguments for HTTP redirect and passthrough addresses. - Added integration tests to verify the correct behavior of HTTP passthrough functionality and ensure compatibility with existing tunnel configurations. --- Cargo.lock | 1 + Makefile | 119 ++- crates/localup-cli/Cargo.toml | 1 + crates/localup-cli/src/main.rs | 154 +++- crates/localup-cli/src/project_config.rs | 14 + crates/localup-cli/tests/integration_tests.rs | 1 + crates/localup-client/src/config.rs | 42 ++ crates/localup-client/src/localup.rs | 57 +- crates/localup-lib/src/lib.rs | 4 +- .../src/http_passthrough.rs | 703 ++++++++++++++++++ crates/localup-server-tls/src/lib.rs | 5 +- examples/tls_relay.rs | 1 + 12 files changed, 1086 insertions(+), 16 deletions(-) create mode 100644 crates/localup-server-tls/src/http_passthrough.rs diff --git a/Cargo.lock b/Cargo.lock index cfb62f8..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", diff --git a/Makefile b/Makefile index 2813891..02b7a1b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for localup development -.PHONY: build build-release relay relay-https relay-http relay-tls tunnel tunnel-https tunnel-custom-domain tunnel-tls test test-server tls-server https-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 +.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: @@ -38,9 +38,12 @@ help: @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 tunnel-tls - Start TLS tunnel with SNI patterns" + @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 "" @@ -92,6 +95,10 @@ 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 @@ -357,6 +364,31 @@ relay-tls: build gen-cert-if-needed --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 "" @@ -459,12 +491,66 @@ server.serve_forever() endef export HTTPS_SERVER_SCRIPT -# Start TLS tunnel with SNI patterns +# 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 " Backend: localhost:$(TLS_BACKEND_PORT)" + @echo " TLS Backend: localhost:$(TLS_BACKEND_PORT)" @echo " Relay: $(RELAY_ADDR)" @echo " Protocol: tls" @echo " SNI Domains: $(SNI_DOMAINS)" @@ -482,6 +568,31 @@ tunnel-tls: build $$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 "" 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/main.rs b/crates/localup-cli/src/main.rs index b58775c..553e82f 100644 --- a/crates/localup-cli/src/main.rs +++ b/crates/localup-cli/src/main.rs @@ -69,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, @@ -139,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, @@ -444,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, @@ -726,6 +757,7 @@ async fn main() -> Result<()> { relay, transport, remote_port, + http_port, enabled, allow_ips, }) => handle_add_tunnel( @@ -739,6 +771,7 @@ async fn main() -> Result<()> { relay, transport, remote_port, + http_port, enabled, allow_ips, ), @@ -1248,6 +1281,7 @@ fn handle_add_tunnel( relay: Option, transport: Option, remote_port: Option, + http_port: Option, enabled: bool, allow_ips: Vec, ) -> Result<()> { @@ -1274,6 +1308,7 @@ fn handle_add_tunnel( subdomain, custom_domains, remote_port, + http_port, )?; // Parse exit node @@ -1392,8 +1427,12 @@ fn handle_list_tunnels() -> Result<()> { ProtocolConfig::Tls { local_port, sni_hostnames, + http_port, } => { print!(" Protocol: TLS, Port: {}", local_port); + if let Some(hp) = http_port { + print!(", HTTP Port: {}", hp); + } if !sni_hostnames.is_empty() { print!(", SNI: {}", sni_hostnames.join(", ")); } @@ -1528,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 @@ -1807,6 +1847,7 @@ fn parse_protocol( subdomain: Option, custom_domains: Vec, remote_port: Option, + http_port: Option, ) -> Result { match protocol.to_lowercase().as_str() { "http" => Ok(ProtocolConfig::Http { @@ -1836,6 +1877,7 @@ fn parse_protocol( Ok(ProtocolConfig::Tls { local_port: port, sni_hostnames, + http_port, }) } _ => Err(anyhow::anyhow!( @@ -2239,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, @@ -2287,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 } @@ -2341,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 } @@ -2374,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::{ @@ -2382,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; @@ -2637,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 2d829bf..e96bc42 100644 --- a/crates/localup-cli/src/project_config.rs +++ b/crates/localup-cli/src/project_config.rs @@ -85,6 +85,11 @@ pub struct ProjectTunnel { #[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, @@ -125,6 +130,7 @@ impl Default for ProjectTunnel { custom_domain: None, remote_port: None, sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, @@ -349,6 +355,7 @@ impl ProjectTunnel { "tls" => ProtocolConfig::Tls { local_port: self.port, sni_hostnames: self.sni_hostnames.clone(), + http_port: self.http_port, }, _ => anyhow::bail!("Unknown protocol: {}", self.protocol), }; @@ -620,6 +627,7 @@ tunnels: custom_domain: None, remote_port: None, sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, @@ -659,6 +667,7 @@ tunnels: custom_domain: None, remote_port: Some(15432), 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()), @@ -702,6 +711,7 @@ tunnels: custom_domain: None, remote_port: None, sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, @@ -837,6 +847,7 @@ tunnels: if let ProtocolConfig::Tls { local_port, sni_hostnames, + .. } = &tunnel_config.protocols[0] { assert_eq!(*local_port, 443); @@ -892,6 +903,7 @@ tunnels: custom_domain: None, remote_port: None, sni_hostnames: Vec::new(), + http_port: None, relay: None, token: None, transport: None, @@ -949,6 +961,7 @@ tunnels: if let ProtocolConfig::Tls { local_port, sni_hostnames, + .. } = &tunnel_config.protocols[0] { assert_eq!(*local_port, 443); @@ -982,6 +995,7 @@ tunnels: if let ProtocolConfig::Tls { local_port, sni_hostnames, + .. } = &tunnel_config.protocols[0] { assert_eq!(*local_port, 8443); diff --git a/crates/localup-cli/tests/integration_tests.rs b/crates/localup-cli/tests/integration_tests.rs index 47e045a..c0ece24 100644 --- a/crates/localup-cli/tests/integration_tests.rs +++ b/crates/localup-cli/tests/integration_tests.rs @@ -322,6 +322,7 @@ fn test_localup_store_protocol_types() { protocols: vec![ProtocolConfig::Tls { local_port: 9000, sni_hostnames: vec!["tls-test.example.com".to_string()], + http_port: None, }], auth_token: "test-token".to_string(), exit_node: ExitNodeConfig::Auto, diff --git a/crates/localup-client/src/config.rs b/crates/localup-client/src/config.rs index b362888..085fc32 100644 --- a/crates/localup-client/src/config.rs +++ b/crates/localup-client/src/config.rs @@ -22,6 +22,12 @@ pub enum ProtocolConfig { /// 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 { @@ -259,6 +265,7 @@ mod tests { .protocol(ProtocolConfig::Tls { local_port: 443, sni_hostnames: vec!["api.example.com".to_string()], + http_port: None, }) .auth_token("test-token".to_string()) .build() @@ -268,10 +275,12 @@ mod tests { 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"), } @@ -287,6 +296,7 @@ mod tests { "web.example.com".to_string(), "admin.example.com".to_string(), ], + http_port: None, }) .auth_token("test-token".to_string()) .build() @@ -296,6 +306,7 @@ mod tests { ProtocolConfig::Tls { local_port, sni_hostnames, + .. } => { assert_eq!(*local_port, 443); assert_eq!(sni_hostnames.len(), 3); @@ -317,6 +328,7 @@ mod tests { "*.local.myapp.dev".to_string(), "api.specific.com".to_string(), ], + http_port: None, }) .auth_token("test-token".to_string()) .build() @@ -340,6 +352,7 @@ mod tests { .protocol(ProtocolConfig::Tls { local_port: 443, sni_hostnames: vec![], + http_port: None, }) .auth_token("test-token".to_string()) .build() @@ -362,6 +375,7 @@ mod tests { "*.local-abc123.myapp.dev".to_string(), "api.production.com".to_string(), ], + http_port: Some(8080), }) .auth_token("test-token".to_string()) .build() @@ -377,11 +391,39 @@ mod tests { 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 38b0aef..77e1046 100644 --- a/crates/localup-client/src/localup.rs +++ b/crates/localup-client/src/localup.rs @@ -705,6 +705,7 @@ impl TunnelConnector { ProtocolConfig::Tls { local_port: _, sni_hostnames, + http_port: _, } => Protocol::Tls { port: 8443, // TLS server port (SNI-based routing) // Use all provided SNI patterns, or default to "*" if none @@ -1766,14 +1767,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 @@ -1783,14 +1789,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 }) @@ -1799,7 +1834,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(); 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-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/examples/tls_relay.rs b/examples/tls_relay.rs index df34257..8ca5a9e 100644 --- a/examples/tls_relay.rs +++ b/examples/tls_relay.rs @@ -190,6 +190,7 @@ async fn main() -> Result<(), Box> { protocols: vec![ProtocolConfig::Tls { local_port: api_port, sni_hostnames: vec!["api.localho.st".to_string()], + http_port: None, }], auth_token, exit_node: ExitNodeConfig::Custom("127.0.0.1:4443".to_string()), From 6f31e3e040b5f4bde68cee8face29a716ac1923d Mon Sep 17 00:00:00 2001 From: David Viejo Date: Mon, 2 Feb 2026 14:21:42 +0100 Subject: [PATCH 8/9] refactor(tls): Update SNI hostname handling in ProtocolConfig --- apps/localup-desktop/src-tauri/src/daemon/service.rs | 6 ++++-- apps/localup-desktop/src-tauri/src/state/app_state.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) 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 7fbd857..126baf6 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,8 @@ 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)), } From 522939e76f12b03c866f6d7dcb00c4b3fe32cf29 Mon Sep 17 00:00:00 2001 From: David Viejo Date: Sat, 7 Feb 2026 07:51:22 +0100 Subject: [PATCH 9/9] refactor(localup): Enhance localup_id generation to include protocol configurations - Updated the localup_id generation function to incorporate protocol configurations, ensuring unique IDs for different tunnel setups. - Refactored related functions and added tests to validate the new behavior, ensuring consistent ID generation based on token and protocol parameters. - Cleaned up unused imports in related files for better code clarity. --- .../src-tauri/src/state/app_state.rs | 6 +- crates/localup-cert/src/acme.rs | 2 +- crates/localup-client/src/localup.rs | 345 +++++++++++++++++- crates/localup-proto/src/messages.rs | 9 +- 4 files changed, 347 insertions(+), 15 deletions(-) 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 126baf6..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,11 @@ pub fn build_protocol_config( }), "tls" => Ok(ProtocolConfig::Tls { local_port, - sni_hostnames: config.custom_domain.clone().map(|d| vec![d]).unwrap_or_default(), + sni_hostnames: config + .custom_domain + .clone() + .map(|d| vec![d]) + .unwrap_or_default(), http_port: None, }), other => Err(format!("Unknown protocol: {}", other)), 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-client/src/localup.rs b/crates/localup-client/src/localup.rs index 77e1046..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 @@ -2513,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-proto/src/messages.rs b/crates/localup-proto/src/messages.rs index 3ebe0f1..8ace0ab 100644 --- a/crates/localup-proto/src/messages.rs +++ b/crates/localup-proto/src/messages.rs @@ -270,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 @@ -290,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 {