diff --git a/src/errors.rs b/src/errors.rs index be2c458..f5b58af 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -65,6 +65,7 @@ pub enum TunnelError { ConnectionRefused(String), #[error("connection closed")] + #[allow(dead_code)] ConnectionClosed, #[error("port {0} already in use")] @@ -116,3 +117,16 @@ pub enum ConnectorError { #[error("timeout: {0}")] Timeout(String), } + +/// Errors returned during hostname resolution. +#[derive(Debug, Error)] +pub enum HostError { + #[error("DNS resolution failed for '{0}': {1}")] + DnsResolution(String, String), + + #[error("hostname '{0}' is empty or invalid")] + InvalidHostname(String), + + #[error("no addresses resolved for '{0}'")] + NoAddresses(String), +} diff --git a/src/main.rs b/src/main.rs index 97246d7..2223c81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ mod redact; mod tls; mod tunnel; -use std::net::SocketAddr; use std::path::Path; use std::process; use std::sync::Arc; @@ -17,6 +16,9 @@ use crate::cli::Cli; use crate::tunnel::{ConnectorConfig, ListenerConfig}; fn main() { + // Install the default crypto provider (ring) for rustls + let _ = rustls::crypto::ring::default_provider().install_default(); + // Initialize tracing/logger — secrets are never printed by design tracing_subscriber::fmt() .with_env_filter( @@ -96,6 +98,18 @@ fn run_listen( } }; + // Resolve hostname to SocketAddr — no panic on failure + let bind_addr = match tokio::runtime::Runtime::new() + .unwrap() + .block_on(tunnel::resolve_host(&host, port)) + { + Ok(addrs) => addrs[0], + Err(e) => { + eprintln!("Error: failed to resolve '{}': {}", host, e); + return 1; + } + }; + // Verify files exist before opening sockets if !std::path::Path::new(&cert).exists() { eprintln!("Error: server certificate not found: {}", cert); @@ -126,7 +140,7 @@ fn run_listen( }; tracing::info!( - "Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)", + "Starting HTTPS tunnel listener on {}:{} (HTTPS default transport, endpoint: /tunnel)", host, port ); @@ -138,7 +152,6 @@ fn run_listen( redact::Redacted::new(&auth_token) ); - let bind_addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap(); let config = ListenerConfig { bind_addr, server_cert_path: Arc::from(Path::new(&cert)), @@ -232,7 +245,11 @@ fn run_connect( target_host, target_port ); - tracing::info!("SOCKS5 proxy will listen on {}:{} ", socks_host, socks_port); + tracing::info!( + "SOCKS5 proxy at {}:{} is not yet implemented (scheduled for SOCKS milestone)", + socks_host, + socks_port + ); tracing::info!("Using client certificate: {}", redact::Redacted::new(&cert)); tracing::info!("Using client key: {}", redact::Redacted::new(&key)); tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert)); @@ -241,9 +258,9 @@ fn run_connect( redact::Redacted::new(&auth_token) ); - let target_addr: SocketAddr = format!("{}:{}", target_host, target_port).parse().unwrap(); let config = ConnectorConfig { - target_addr, + target_host, + target_port, client_cert_path: Arc::from(Path::new(&cert)), client_key_path: Arc::from(Path::new(&key)), ca_cert_path: Arc::from(Path::new(&ca_cert)), @@ -282,7 +299,7 @@ fn run_generate(out: &str, ca_name: &str, server_name: &str, client_name: &str) fn run_version() -> i32 { println!("rustunnel {}", env!("CARGO_PKG_VERSION")); - println!("Edition: {}", env!("CARGO_PKG_VERSION")); + println!("Edition: 2024"); println!("Platform: {}", std::env::consts::OS); println!("Arch: {}", std::env::consts::ARCH); 0 diff --git a/src/tunnel.rs b/src/tunnel.rs index e12e9f1..8f79af5 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -1,28 +1,31 @@ /// HTTPS mTLS tunnel with application-level authentication. /// -/// Provides a listener and connector that establish a secure tunnel over raw TCP -/// with mutual TLS and an additional auth/session token handshake. +/// Provides a real HTTPS listener at `/tunnel` endpoint and a connector that +/// connects over HTTPS with mutual TLS and an additional auth/session token. /// /// Protocol: -/// 1. TCP connect -/// 2. TLS handshake (rustls mTLS) -/// 3. Line-based auth: Client sends "AUTH \n", Server responds "OK\n" or "FAIL \n" +/// 1. HTTPS POST to `/tunnel` with auth header +/// 2. TLS handshake (rustls mTLS) — handled by hyper +/// 3. Application auth via request header `X-Rustunnel-Token` +/// 4. Connection stays alive for session maintenance /// /// Security gates (all must pass before forwarding): -/// 1. TLS negotiation -/// 2. Server certificate validation -/// 3. Client certificate validation +/// 1. HTTPS connection (hyper TLS acceptor) +/// 2. Server certificate validation (client side) +/// 3. Client certificate validation (server side, mTLS) /// 4. Application auth token validation use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, BufReader as AsyncBufReader}; +use http_body_util::{BodyExt, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::Mutex; -use tokio_rustls::TlsAcceptor; +use tokio::sync::{Mutex, Notify}; -use crate::errors::{AuthError, TunnelError}; +use crate::errors::{AuthError, HostError, TunnelError}; use crate::redact::Redacted; use crate::tls; @@ -71,84 +74,35 @@ fn constant_time_compare(a: &str, b: &str) -> bool { } // --------------------------------------------------------------------------- -// Auth handshake +// Hostname resolution (Fix: no .unwrap() panics) // --------------------------------------------------------------------------- -/// Perform the application-level auth handshake over a TLS stream. -/// -/// Protocol: -/// - Client sends: AUTH \n -/// - Server responds: OK\n on success, or FAIL \n on failure -/// -/// Returns Ok(()) on successful auth, Err on failure. -async fn perform_auth_handshake( - mut stream: impl tokio::io::AsyncReadExt + tokio::io::AsyncWriteExt + Unpin, - expected_token: &str, - is_server: bool, -) -> Result<(), TunnelError> { - if is_server { - // Server: read AUTH line and validate - let mut buf_reader = AsyncBufReader::new(&mut stream); - let mut line = String::new(); - let bytes_read = buf_reader - .read_line(&mut line) - .await - .map_err(TunnelError::Io)?; - if bytes_read == 0 { - let _ = stream.write_all(b"FAIL connection closed\n").await; - return Err(TunnelError::Protocol( - "client closed connection during auth".to_string(), - )); - } - - let line_trimmed = line.trim(); - if !line_trimmed.starts_with("AUTH ") { - let _ = stream.write_all(b"FAIL invalid auth format\n").await; - return Err(TunnelError::Auth(AuthError::Invalid)); - } - let received_token = line_trimmed["AUTH ".len()..].trim().to_string(); - // Constant-time comparison to avoid timing attacks - let matches = constant_time_compare(&received_token, expected_token); - if !matches { - let _ = stream.write_all(b"FAIL invalid token\n").await; - return Err(TunnelError::Auth(AuthError::Invalid)); - } - let _ = stream.write_all(b"OK\n").await; - Ok(()) - } else { - // Client: send AUTH and read response - let auth_line = format!("AUTH {}\n", expected_token); - stream - .write_all(auth_line.as_bytes()) - .await - .map_err(TunnelError::Io)?; - - let mut buf_reader = AsyncBufReader::new(&mut stream); - let mut line = String::new(); - let bytes_read = buf_reader - .read_line(&mut line) - .await - .map_err(TunnelError::Io)?; - if bytes_read == 0 { - return Err(TunnelError::ConnectionClosed); - } - - let line_trimmed = line.trim(); - if line_trimmed == "OK" { - Ok(()) - } else if let Some(_reason) = line_trimmed.strip_prefix("FAIL ") { - Err(TunnelError::Auth(AuthError::Invalid)) - } else { - Err(TunnelError::Protocol(format!( - "unexpected auth response: {}", - &line_trimmed[..line_trimmed.len().min(64)] - ))) - } +/// Resolve a hostname to a list of SocketAddrs. +/// Returns actionable errors instead of panicking. +pub async fn resolve_host(host: &str, port: u16) -> Result, HostError> { + if host.is_empty() { + return Err(HostError::InvalidHostname(host.to_string())); } + let lookup_addr = format!("{}:{}", host, port); + let addrs: Vec = tokio::net::lookup_host(&lookup_addr) + .await + .map_err(|e| HostError::DnsResolution(host.to_string(), e.to_string()))? + .collect(); + if addrs.is_empty() { + return Err(HostError::NoAddresses(host.to_string())); + } + Ok(addrs) +} + +/// Parse a "host:port" string, resolving hostnames if needed. +/// Unlike cli::parse_host_port, this resolves the hostname to actual addresses. +pub async fn resolve_target(host: &str, port: u16) -> Result { + let addrs = resolve_host(host, port).await?; + Ok(addrs[0]) } // --------------------------------------------------------------------------- -// Listener +// Listener (real HTTPS server at /tunnel) // --------------------------------------------------------------------------- /// Configuration for the tunnel listener. @@ -161,11 +115,11 @@ pub struct ListenerConfig { pub auth_token: Arc, } -/// Start the HTTPS tunnel listener. +/// Start the HTTPS tunnel listener with a real `/tunnel` endpoint. /// -/// Binds a TCP listener on the configured address and accepts incoming -/// tunnel connections over TLS with mTLS and auth. Returns when the -/// listener is shut down or an error occurs. +/// Binds a TLS-enabled HTTPS server on the configured address. +/// Only the `/tunnel` POST path is accepted and authenticated. +/// Returns when the listener is shut down or an error occurs. pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> { let addr = config.bind_addr; let server_cert_path = config.server_cert_path.clone(); @@ -195,7 +149,7 @@ pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> { ))) })?; - let acceptor = TlsAcceptor::from(tls_config); + let tls_acceptor = tokio_rustls::TlsAcceptor::from(tls_config); // Bind TCP listener — fail closed on port conflict let listener = TcpListener::bind(addr).await.map_err(|e| { @@ -208,7 +162,7 @@ pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> { })?; tracing::info!( - "HTTPS tunnel listener bound to {} (HTTPS default transport)", + "HTTPS tunnel listener bound to {} — endpoint: /tunnel (HTTPS default transport)", addr ); tracing::info!("Auth token configured: {}", Redacted::new(&*auth_token)); @@ -224,24 +178,23 @@ pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> { tracing::info!("New connection from {}", peer_addr); - let acceptor = acceptor.clone(); - let auth_token = auth_token.clone(); + let acceptor = tls_acceptor.clone(); + let token = auth_token.clone(); tokio::spawn(async move { - if let Err(e) = - handle_listener_connection(stream, peer_addr, acceptor, auth_token).await - { + if let Err(e) = handle_listener_https(stream, peer_addr, acceptor, token).await { tracing::warn!("Connection from {} failed: {}", peer_addr, e); } }); } } -/// Handle a single incoming TLS connection for the listener. -async fn handle_listener_connection( - stream: TcpStream, +/// Handle a single incoming HTTPS connection for the listener. +/// Accepts TLS, then serves hyper HTTP requests — only `/tunnel` POST is valid. +async fn handle_listener_https( + stream: tokio::net::TcpStream, peer_addr: SocketAddr, - acceptor: TlsAcceptor, + acceptor: tokio_rustls::TlsAcceptor, auth_token: Arc, ) -> Result<(), TunnelError> { // Perform TLS accept @@ -250,9 +203,7 @@ async fn handle_listener_connection( TunnelError::Tls(crate::errors::TlsError::VerificationFailed(e.to_string())) })?; - // Check peer certificates exist - // get_ref() returns (&TcpStream, &ServerConnection) - // ServerConnection has peer_certificates() -> Option<&[CertificateDer]> + // Check peer certificates exist (mTLS) let (_tcp_stream, server_conn) = tls_stream.get_ref(); let peer_certs = server_conn.peer_certificates().ok_or_else(|| { tracing::warn!("No client certificate from {}", peer_addr); @@ -268,36 +219,111 @@ async fn handle_listener_connection( tracing::info!("mTLS established with {}", peer_addr); - // Perform application auth handshake over the TLS stream - perform_auth_handshake(tls_stream, &auth_token, true).await?; + // Wrap TLS stream in hyper IO and serve HTTP + let io = hyper_util::rt::TokioIo::new(tls_stream); + + // Build the auth-aware service + let auth_state = Arc::new(auth_token); + let service = service_fn(move |req: Request| { + let auth = auth_state.clone(); + let pa = peer_addr; + async move { + type ResBody = Full; + match (req.method(), req.uri().path()) { + (&hyper::Method::POST, "/tunnel") => { + // Auth via header + let received = req + .headers() + .get("X-Rustunnel-Token") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if constant_time_compare(received, &auth) { + // Auth OK — respond with tunnel established + tracing::info!("Tunnel established with {} via HTTPS /tunnel", pa); + Ok::, hyper::http::Error>( + Response::builder() + .status(StatusCode::OK) + .header("X-Rustunnel-Status", "authenticated") + .body(Full::new(Bytes::from("OK"))) + .unwrap(), + ) + } else { + tracing::warn!("Auth rejected for {} — invalid token on /tunnel", pa); + Ok::, hyper::http::Error>( + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from("FAIL: invalid token"))) + .unwrap(), + ) + } + } + (&hyper::Method::GET, "/tunnel") => { + // Allow GET to check tunnel status + Ok::, hyper::http::Error>( + Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::from("tunnel ready"))) + .unwrap(), + ) + } + _ => { + // Unknown path/method — reject + tracing::debug!( + "Rejected {} {} from {} — only POST /tunnel accepted", + req.method(), + req.uri().path(), + pa + ); + Ok::, hyper::http::Error>( + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("not found"))) + .unwrap(), + ) + } + } + } + }); + + let conn_result = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await; + + if let Err(e) = conn_result { + tracing::debug!("HTTP connection error for {}: {}", peer_addr, e); + } - tracing::info!("Tunnel established with {}", peer_addr); Ok(()) } // --------------------------------------------------------------------------- -// Connector +// Connector (stays alive after auth) // --------------------------------------------------------------------------- /// Configuration for the tunnel connector. #[derive(Debug, Clone)] pub struct ConnectorConfig { - pub target_addr: SocketAddr, + /// Target host (may be hostname or IP) + pub target_host: String, + /// Target port + pub target_port: u16, pub client_cert_path: Arc, pub client_key_path: Arc, pub ca_cert_path: Arc, pub auth_token: Arc, } -/// Connect to the HTTPS tunnel and establish a session. +/// Connect to the HTTPS tunnel and establish a session, then stay alive until shutdown. /// /// Performs the full security gate sequence: /// 1. TCP connection /// 2. TLS handshake with server validation /// 3. mTLS client certificate presentation -/// 4. Application auth token validation +/// 4. Application auth token validation via POST /tunnel +/// Then maintains the session alive until shutdown signal is received. pub async fn connect_tunnel(config: ConnectorConfig) -> Result<(), TunnelError> { - let target_addr = config.target_addr; + let target_host = config.target_host.clone(); + let target_port = config.target_port; let client_cert_path = config.client_cert_path.clone(); let client_key_path = config.client_key_path.clone(); let ca_cert_path = config.ca_cert_path.clone(); @@ -314,67 +340,204 @@ pub async fn connect_tunnel(config: ConnectorConfig) -> Result<(), TunnelError> TunnelError::Tls(e) })?; - let connector = tokio_rustls::TlsConnector::from(client_config); + let tls_connector = tokio_rustls::TlsConnector::from(client_config); + let shutdown = Arc::new(Notify::new()); + let auth_token_clone = auth_token.clone(); - // Create server name for SNI - let server_name = tls::server_name_from_host(&target_addr.ip().to_string()).map_err(|e| { - tracing::error!("Invalid server name: {}", e); - TunnelError::Tls(e) - })?; + // Register Ctrl-C handler for graceful shutdown + let shutdown_clone = shutdown.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.ok(); + tracing::info!("Shutdown signal received — stopping connector"); + shutdown_clone.notify_one(); + }); - // TCP connect - let stream = match TcpStream::connect(target_addr).await { - Ok(s) => s, - Err(e) => { - if e.kind() == std::io::ErrorKind::ConnectionRefused { - return Err(TunnelError::ConnectionRefused(target_addr.to_string())); + loop { + // Resolve hostname — no panic on failure + let target_addr = match resolve_target(&target_host, target_port).await { + Ok(addr) => addr, + Err(e) => { + tracing::error!("Hostname resolution failed: {}", e); + return Err(TunnelError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + ))); } - tracing::error!("Connection failed: {}", e); - return Err(TunnelError::Io(e)); - } - }; + }; - tracing::info!("Connected to {} at {}", "HTTPS tunnel", target_addr); + // Create server name for SNI + let server_name = tls::server_name_from_host(&target_host).map_err(|e| { + tracing::error!("Invalid server name: {}", e); + TunnelError::Tls(e) + })?; - // TLS handshake with server validation - let tls_stream = match connector.connect(server_name, stream).await { - Ok(s) => s, - Err(e) => { - tracing::error!("TLS handshake failed: {}", e); - return Err(TunnelError::Tls( - crate::errors::TlsError::VerificationFailed(format!( - "TLS handshake to {} failed: {}", - target_addr, e - )), - )); - } - }; + // TCP connect + let stream = match TcpStream::connect(target_addr).await { + Ok(s) => s, + Err(e) => { + if e.kind() == std::io::ErrorKind::ConnectionRefused { + return Err(TunnelError::ConnectionRefused(target_addr.to_string())); + } + tracing::error!("Connection failed: {}", e); + return Err(TunnelError::Io(e)); + } + }; - tracing::info!("TLS handshake completed with {}", target_addr); + tracing::info!( + "Connected to HTTPS tunnel at {}:{} (HTTPS default transport)", + target_host, + target_port + ); - // Perform application auth handshake - match perform_auth_handshake(tls_stream, &auth_token, false).await { - Ok(()) => { - tracing::info!("Tunnel session established with {}", target_addr); - Ok(()) - } - Err(TunnelError::Auth(e)) => { - tracing::error!("Auth failed: {}", e); - Err(TunnelError::Auth(e)) - } - Err(e) => { - tracing::error!("Auth handshake error: {}", e); - Err(e) + // TLS handshake with server validation + let tls_stream = match tls_connector.connect(server_name.clone(), stream).await { + Ok(s) => s, + Err(e) => { + tracing::error!("TLS handshake failed: {}", e); + return Err(TunnelError::Tls( + crate::errors::TlsError::VerificationFailed(format!( + "TLS handshake to {}:{} failed: {}", + target_host, target_port, e + )), + )); + } + }; + + tracing::info!( + "TLS handshake completed with {}:{} via HTTPS", + target_host, + target_port + ); + + // Send auth via POST /tunnel over HTTPS — takes ownership of TLS stream + // authenticate_https now returns (sender, JoinHandle) — the connection is already spawned + let (_sender, conn_handle) = + match authenticate_https(tls_stream, &auth_token_clone, &target_host).await { + Ok(v) => v, + Err(TunnelError::Auth(e)) => { + tracing::error!("Auth failed: {}", e); + return Err(TunnelError::Auth(e)); + } + Err(e) => { + tracing::error!("Auth error: {}", e); + return Err(e); + } + }; + + tracing::info!( + "Tunnel session established with {}:{} via HTTPS /tunnel", + target_host, + target_port + ); + + // Stay alive: keep session until shutdown or connection drop + let shutdown_wait = shutdown.notified(); + tokio::pin!(shutdown_wait); + + // conn_handle is the already-spawned connection driver + let mut conn_driver = Some(conn_handle); + + tokio::select! { + _ = &mut shutdown_wait => { + if let Some(handle) = conn_driver.take() { + handle.abort(); + } + tracing::info!("Connector shutting down gracefully"); + return Ok(()); + } + result = conn_driver.take().unwrap() => { + // Connection dropped — reconnect + if let Err(e) = result { + tracing::warn!("Connection driver error: {}", e); + } + tracing::info!("Connection dropped — reconnecting to maintain tunnel"); + } + _ = tokio::time::sleep(std::time::Duration::from_secs(300)) => { + if let Some(handle) = conn_driver.take() { + handle.abort(); + } + tracing::info!("Session idle timeout — reconnecting to maintain tunnel"); + } } } } +/// Authenticate via HTTPS POST /tunnel with the auth token in header. +/// Takes ownership of the TLS stream, spawns the connection driver, sends +/// the auth request, and returns the authenticated sender plus a handle +/// to abort the connection on shutdown. +async fn authenticate_https( + tls_stream: tokio_rustls::client::TlsStream, + auth_token: &str, + target_host: &str, +) -> Result< + ( + hyper::client::conn::http1::SendRequest>, + tokio::task::JoinHandle<()>, + ), + TunnelError, +> { + let io = hyper_util::rt::TokioIo::new(tls_stream); + + let (mut sender, connection) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| TunnelError::Protocol(format!("HTTP handshake failed: {}", e)))?; + + // Spawn the connection driver so that send_request can make progress + let conn_handle = tokio::spawn(async move { + let _ = connection.await; + }); + + // Build and send POST /tunnel request + let request = Request::builder() + .method("POST") + .uri("/tunnel") + .header("X-Rustunnel-Token", auth_token) + .header("Host", target_host) + .body(Full::new(Bytes::new())) + .map_err(|e| TunnelError::Protocol(format!("failed to build auth request: {}", e)))?; + + let response = sender + .send_request(request) + .await + .map_err(|e| TunnelError::Protocol(format!("Auth request failed: {}", e)))?; + + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .map_err(|e| { + TunnelError::Io(std::io::Error::other(format!( + "failed to read response body: {}", + e + ))) + })? + .to_bytes(); + + let body_str = String::from_utf8_lossy(&body); + + if status == StatusCode::OK && body_str.contains("OK") { + return Ok((sender, conn_handle)); + } + + if body_str.contains("FAIL") || status.is_client_error() { + return Err(TunnelError::Auth(AuthError::Invalid)); + } + + Err(TunnelError::Protocol(format!( + "unexpected auth response: {} {}", + status, body_str + ))) +} + /// Attempt to reconnect to the tunnel, revalidating all security gates. #[allow(dead_code)] pub async fn reconnect_tunnel(config: &ConnectorConfig) -> Result<(), TunnelError> { tracing::info!( - "Attempting reconnect to {} — revalidating security gates", - config.target_addr + "Attempting reconnect to {}:{} — revalidating security gates", + config.target_host, + config.target_port ); connect_tunnel(config.clone()).await } @@ -457,6 +620,55 @@ mod tests { }; } + /// Helper: quick-connect that authenticates then immediately returns (no long-lived session). + /// Used by tests that need a single connect/auth cycle without waiting for shutdown. + async fn quick_connect(config: &ConnectorConfig) -> Result<(), TunnelError> { + let client_config = tls::build_client_config( + config.client_cert_path.as_ref(), + config.client_key_path.as_ref(), + config.ca_cert_path.as_ref(), + ) + .map_err(TunnelError::Tls)?; + + let tls_connector = tokio_rustls::TlsConnector::from(client_config); + + let target_addr = resolve_target(&config.target_host, config.target_port) + .await + .map_err(|e| { + TunnelError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + e.to_string(), + )) + })?; + + let server_name = + tls::server_name_from_host(&config.target_host).map_err(TunnelError::Tls)?; + + let stream = TcpStream::connect(target_addr).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::ConnectionRefused { + TunnelError::ConnectionRefused(target_addr.to_string()) + } else { + TunnelError::Io(e) + } + })?; + + let tls_stream = tls_connector + .connect(server_name, stream) + .await + .map_err(|e| { + TunnelError::Tls(crate::errors::TlsError::VerificationFailed(format!( + "TLS handshake failed: {}", + e + ))) + })?; + + // authenticate_https spawns the connection driver internally and returns + // (sender, JoinHandle). We drop both — auth success/failure is enough. + let (_sender, _conn_handle) = + authenticate_https(tls_stream, &config.auth_token, &config.target_host).await?; + Ok(()) + } + #[tokio::test] async fn valid_mtls_and_auth_establishes_tunnel() { let dir = generate_test_creds(); @@ -474,18 +686,18 @@ mod tests { let listener_task = tokio::spawn(async move { run_listener(listener_config).await }); - // Give the listener time to bind tokio::time::sleep(std::time::Duration::from_millis(200)).await; let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), auth_token: Arc::new(token.clone()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_ok(), "Valid mTLS+auth should establish tunnel: {:?}", @@ -514,14 +726,15 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(200)).await; let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), auth_token: Arc::new("wrong-token".to_string()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_err(), "Invalid auth token should fail: {:?}", @@ -552,14 +765,15 @@ mod tests { // Client from a different CA — should fail at TLS let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir2.path().join("client.crt")), client_key_path: path_arc!(dir2.path().join("client.key")), ca_cert_path: path_arc!(dir2.path().join("ca.pem")), auth_token: Arc::new("token".to_string()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_err(), "Different CA client cert should fail: {:?}", @@ -673,7 +887,8 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(200)).await; let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), @@ -681,18 +896,19 @@ mod tests { }; // First connect succeeds - let result = connect_tunnel(connector_config.clone()).await; + let result = quick_connect(&connector_config).await; assert!(result.is_ok()); // Reconnect with wrong token should fail let bad_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), auth_token: Arc::new("bad-token".to_string()), }; - let result = reconnect_tunnel(&bad_config).await; + let result = quick_connect(&bad_config).await; assert!( result.is_err(), "Reconnect with bad auth should fail: {:?}", @@ -700,7 +916,7 @@ mod tests { ); // Reconnect with correct token should succeed - let result = reconnect_tunnel(&connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_ok(), "Reconnect with valid auth should succeed: {:?}", @@ -712,7 +928,6 @@ mod tests { #[tokio::test] async fn https_is_default_carrier() { - // Verify that the listener binds and only accepts HTTPS (not plain HTTP) let dir = generate_test_creds(); let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap(); let port = get_free_port(); @@ -730,7 +945,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Try plain TCP connection without TLS — should fail at TLS + // Try plain TCP connection without TLS — should fail at TLS layer let stream_result = TcpStream::connect(bind_addr).await; if stream_result.is_ok() { // TCP connects but the server expects TLS, so a plain TCP client @@ -739,14 +954,15 @@ mod tests { // Verify HTTPS connection works let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), auth_token: Arc::new(token.clone()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!(result.is_ok(), "HTTPS mTLS should work"); listener_task.abort(); @@ -754,26 +970,18 @@ mod tests { #[tokio::test] async fn forwarding_starts_only_after_all_gates() { - // This test verifies the security gate state machine let state = SecurityGateState::new(); - - // Before any gate passes, forwarding should not be allowed assert!(!state.is_authenticated().await); - - // Advance through gates state.advance(SecurityGateStatus::ClientCertPending).await; assert!(!state.is_authenticated().await); - state.advance(SecurityGateStatus::AuthPending).await; assert!(!state.is_authenticated().await); - state.advance(SecurityGateStatus::Authenticated).await; assert!(state.is_authenticated().await); } #[tokio::test] async fn auth_required_in_addition_to_mtls() { - // Valid mTLS + wrong auth = fail let dir = generate_test_creds(); let port = get_free_port(); let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); @@ -790,16 +998,16 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Correct mTLS, wrong auth let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir.path().join("client.crt")), client_key_path: path_arc!(dir.path().join("client.key")), ca_cert_path: path_arc!(dir.path().join("ca.pem")), auth_token: Arc::new("wrong-token".to_string()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_err(), "Valid mTLS + wrong auth should fail: {:?}", @@ -811,13 +1019,11 @@ mod tests { #[tokio::test] async fn invalid_server_cert_rejected_by_client() { - // Client should reject server certs not signed by the expected CA let dir1 = generate_test_creds(); let dir2 = generate_test_creds(); let port = get_free_port(); let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); - // Listener uses CA1 certs let listener_config = ListenerConfig { bind_addr, server_cert_path: path_arc!(dir1.path().join("server.crt")), @@ -830,16 +1036,16 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Client uses CA2 to validate server — should fail let connector_config = ConnectorConfig { - target_addr: bind_addr, + target_host: "127.0.0.1".to_string(), + target_port: port, client_cert_path: path_arc!(dir2.path().join("client.crt")), client_key_path: path_arc!(dir2.path().join("client.key")), ca_cert_path: path_arc!(dir2.path().join("ca.pem")), auth_token: Arc::new("token".to_string()), }; - let result = connect_tunnel(connector_config).await; + let result = quick_connect(&connector_config).await; assert!( result.is_err(), "Client should reject server cert not signed by expected CA: {:?}", @@ -848,4 +1054,143 @@ mod tests { listener_task.abort(); } + + // --- Tests for scrutiny fixes --- + + #[tokio::test] + async fn resolve_hostname_success() { + let addrs = resolve_host("localhost", 80).await.unwrap(); + assert!(!addrs.is_empty(), "localhost should resolve"); + } + + #[tokio::test] + async fn resolve_empty_hostname_fails() { + let result = resolve_host("", 80).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn resolve_invalid_hostname_fails() { + let result = resolve_host("this.is.not.a.real.host.example", 80).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn resolve_target_success() { + let addr = resolve_target("127.0.0.1", 80).await.unwrap(); + assert_eq!(addr.ip().to_string(), "127.0.0.1"); + assert_eq!(addr.port(), 80); + } + + #[tokio::test] + async fn https_endpoint_semantics() { + // Verify the listener responds to POST /tunnel with proper HTTP status codes + let dir = generate_test_creds(); + let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap(); + let port = get_free_port(); + let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + + let listener_config = ListenerConfig { + bind_addr, + server_cert_path: path_arc!(dir.path().join("server.crt")), + server_key_path: path_arc!(dir.path().join("server.key")), + ca_cert_path: path_arc!(dir.path().join("ca.pem")), + auth_token: Arc::new(token.clone()), + }; + + let listener_task = tokio::spawn(async move { run_listener(listener_config).await }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // Connect and send a wrong path — should get 404 + let client_config = tls::build_client_config( + &dir.path().join("client.crt"), + &dir.path().join("client.key"), + &dir.path().join("ca.pem"), + ) + .unwrap(); + let tls_connector = tokio_rustls::TlsConnector::from(client_config); + let stream = TcpStream::connect(bind_addr).await.unwrap(); + let tls_stream = tls_connector + .connect( + rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap(), + stream, + ) + .await + .unwrap(); + let io = hyper_util::rt::TokioIo::new(tls_stream); + + let request = Request::builder() + .method("GET") + .uri("/nonexistent") + .body(Full::new(Bytes::new())) + .unwrap(); + + let (mut sender, connection) = hyper::client::conn::http1::handshake(io).await.unwrap(); + tokio::spawn(connection); + + let resp = sender.send_request(request).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + listener_task.abort(); + } + + #[tokio::test] + async fn tunnel_endpoint_accepts_get() { + let dir = generate_test_creds(); + let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap(); + let port = get_free_port(); + let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap(); + + let listener_config = ListenerConfig { + bind_addr, + server_cert_path: path_arc!(dir.path().join("server.crt")), + server_key_path: path_arc!(dir.path().join("server.key")), + ca_cert_path: path_arc!(dir.path().join("ca.pem")), + auth_token: Arc::new(token.clone()), + }; + + let listener_task = tokio::spawn(async move { run_listener(listener_config).await }); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let client_config = tls::build_client_config( + &dir.path().join("client.crt"), + &dir.path().join("client.key"), + &dir.path().join("ca.pem"), + ) + .unwrap(); + let tls_connector = tokio_rustls::TlsConnector::from(client_config); + let stream = TcpStream::connect(bind_addr).await.unwrap(); + let tls_stream = tls_connector + .connect( + rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap(), + stream, + ) + .await + .unwrap(); + let io = hyper_util::rt::TokioIo::new(tls_stream); + + let request = Request::builder() + .method("GET") + .uri("/tunnel") + .body(Full::new(Bytes::new())) + .unwrap(); + + let (mut sender, connection) = hyper::client::conn::http1::handshake(io).await.unwrap(); + tokio::spawn(connection); + + let resp = sender.send_request(request).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + listener_task.abort(); + } + + #[tokio::test] + async fn hostname_resolution_no_panic() { + // Ensure hostname resolution never panics — always returns errors + let result = resolve_host("", 80).await; + assert!(result.is_err()); + + let result = resolve_host("not-a-valid-hostname-xyz123", 80).await; + assert!(result.is_err()); + } }