fix: address secure-tunnel scrutiny findings
This commit is contained in:
+533
-188
@@ -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 <token>\n", Server responds "OK\n" or "FAIL <reason>\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 <token>\n
|
||||
/// - Server responds: OK\n on success, or FAIL <reason>\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<Vec<SocketAddr>, HostError> {
|
||||
if host.is_empty() {
|
||||
return Err(HostError::InvalidHostname(host.to_string()));
|
||||
}
|
||||
let lookup_addr = format!("{}:{}", host, port);
|
||||
let addrs: Vec<SocketAddr> = 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<SocketAddr, HostError> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
) -> 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<Incoming>| {
|
||||
let auth = auth_state.clone();
|
||||
let pa = peer_addr;
|
||||
async move {
|
||||
type ResBody = Full<Bytes>;
|
||||
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::<Response<ResBody>, 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::<Response<ResBody>, 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::<Response<ResBody>, 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::<Response<ResBody>, 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<Path>,
|
||||
pub client_key_path: Arc<Path>,
|
||||
pub ca_cert_path: Arc<Path>,
|
||||
pub auth_token: Arc<String>,
|
||||
}
|
||||
|
||||
/// 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<tokio::net::TcpStream>,
|
||||
auth_token: &str,
|
||||
target_host: &str,
|
||||
) -> Result<
|
||||
(
|
||||
hyper::client::conn::http1::SendRequest<Full<Bytes>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user