feat: implement HTTPS mTLS tunnel with auth handshake
Add HTTPS default tunnel endpoint and outbound connector using Rustls with mTLS and an additional auth/session token. - Add rustls, tokio-rustls, hyper, and related dependencies - Implement TLS config builders (server/client) with cert validation - Implement HTTPS tunnel listener with mTLS accept and auth handshake - Implement HTTPS tunnel connector with server cert validation - Add auth token loading from CLI value or file - Add security gate state machine for tracking auth progression - Fail closed for missing/invalid TLS config, cert mismatch, auth failures - Port conflicts fail without fallback - Reconnect revalidates all security gates - 67 tests covering all validation assertions
This commit is contained in:
+851
@@ -0,0 +1,851 @@
|
||||
/// 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.
|
||||
///
|
||||
/// 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"
|
||||
///
|
||||
/// Security gates (all must pass before forwarding):
|
||||
/// 1. TLS negotiation
|
||||
/// 2. Server certificate validation
|
||||
/// 3. Client certificate validation
|
||||
/// 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 tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
use crate::errors::{AuthError, TunnelError};
|
||||
use crate::redact::Redacted;
|
||||
use crate::tls;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth token handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load an auth token from a file or use the provided value.
|
||||
pub fn load_auth_token(
|
||||
token_value: Option<&str>,
|
||||
token_path: Option<&Path>,
|
||||
) -> Result<String, AuthError> {
|
||||
if let Some(path) = token_path {
|
||||
let content = std::fs::read_to_string(path).map_err(|_| AuthError::Missing)?;
|
||||
let trimmed = content.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AuthError::Missing);
|
||||
}
|
||||
Ok(trimmed)
|
||||
} else if let Some(t) = token_value {
|
||||
if t.is_empty() {
|
||||
return Err(AuthError::Missing);
|
||||
}
|
||||
Ok(t.to_string())
|
||||
} else {
|
||||
Err(AuthError::Missing)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constant-time comparison
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Constant-time string comparison to prevent timing attacks.
|
||||
fn constant_time_compare(a: &str, b: &str) -> bool {
|
||||
let a_bytes = a.as_bytes();
|
||||
let b_bytes = b.as_bytes();
|
||||
if a_bytes.len() != b_bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let mut result: u8 = 0;
|
||||
for (x, y) in a_bytes.iter().zip(b_bytes.iter()) {
|
||||
result |= x ^ y;
|
||||
}
|
||||
result == 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth handshake
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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)]
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Listener
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configuration for the tunnel listener.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListenerConfig {
|
||||
pub bind_addr: SocketAddr,
|
||||
pub server_cert_path: Arc<Path>,
|
||||
pub server_key_path: Arc<Path>,
|
||||
pub ca_cert_path: Arc<Path>,
|
||||
pub auth_token: Arc<String>,
|
||||
}
|
||||
|
||||
/// Start the HTTPS tunnel listener.
|
||||
///
|
||||
/// 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.
|
||||
pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> {
|
||||
let addr = config.bind_addr;
|
||||
let server_cert_path = config.server_cert_path.clone();
|
||||
let server_key_path = config.server_key_path.clone();
|
||||
let ca_cert_path = config.ca_cert_path.clone();
|
||||
let auth_token = config.auth_token.clone();
|
||||
|
||||
// Build TLS config — validates all cert/key files before opening sockets
|
||||
let tls_config = tls::build_server_config(
|
||||
server_cert_path.as_ref(),
|
||||
server_key_path.as_ref(),
|
||||
ca_cert_path.as_ref(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to build TLS config: {}", e);
|
||||
e
|
||||
})?;
|
||||
|
||||
// Validate server cert identity matches bind address
|
||||
let server_certs = tls::load_certs(server_cert_path.as_ref()).map_err(TunnelError::Tls)?;
|
||||
let bind_host = addr.ip().to_string();
|
||||
tls::validate_cert_identity(&server_certs[0], &bind_host).map_err(|e| {
|
||||
tracing::error!("Server cert identity mismatch: {}", e);
|
||||
TunnelError::Tls(crate::errors::TlsError::IdentityMismatch(format!(
|
||||
"server cert does not match bind address {}: {}",
|
||||
bind_host, e
|
||||
)))
|
||||
})?;
|
||||
|
||||
let acceptor = TlsAcceptor::from(tls_config);
|
||||
|
||||
// Bind TCP listener — fail closed on port conflict
|
||||
let listener = TcpListener::bind(addr).await.map_err(|e| {
|
||||
let addr_str = addr.to_string();
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
TunnelError::PortInUse(addr.port())
|
||||
} else {
|
||||
TunnelError::BindError(addr_str, e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"HTTPS tunnel listener bound to {} (HTTPS default transport)",
|
||||
addr
|
||||
);
|
||||
tracing::info!("Auth token configured: {}", Redacted::new(&*auth_token));
|
||||
|
||||
loop {
|
||||
let (stream, peer_addr) = match listener.accept().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!("Accept error: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("New connection from {}", peer_addr);
|
||||
|
||||
let acceptor = acceptor.clone();
|
||||
let auth_token = auth_token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
handle_listener_connection(stream, peer_addr, acceptor, auth_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,
|
||||
peer_addr: SocketAddr,
|
||||
acceptor: TlsAcceptor,
|
||||
auth_token: Arc<String>,
|
||||
) -> Result<(), TunnelError> {
|
||||
// Perform TLS accept
|
||||
let tls_stream = acceptor.accept(stream).await.map_err(|e| {
|
||||
tracing::warn!("TLS accept failed for {}: {}", peer_addr, e);
|
||||
TunnelError::Tls(crate::errors::TlsError::VerificationFailed(e.to_string()))
|
||||
})?;
|
||||
|
||||
// Check peer certificates exist
|
||||
// get_ref() returns (&TcpStream, &ServerConnection)
|
||||
// ServerConnection has peer_certificates() -> Option<&[CertificateDer]>
|
||||
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);
|
||||
TunnelError::Tls(crate::errors::TlsError::Missing("client certificate"))
|
||||
})?;
|
||||
|
||||
if peer_certs.is_empty() {
|
||||
tracing::warn!("Empty client certificate chain from {}", peer_addr);
|
||||
return Err(TunnelError::Tls(crate::errors::TlsError::Missing(
|
||||
"client certificate",
|
||||
)));
|
||||
}
|
||||
|
||||
tracing::info!("mTLS established with {}", peer_addr);
|
||||
|
||||
// Perform application auth handshake over the TLS stream
|
||||
perform_auth_handshake(tls_stream, &auth_token, true).await?;
|
||||
|
||||
tracing::info!("Tunnel established with {}", peer_addr);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configuration for the tunnel connector.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectorConfig {
|
||||
pub target_addr: SocketAddr,
|
||||
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.
|
||||
///
|
||||
/// 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
|
||||
pub async fn connect_tunnel(config: ConnectorConfig) -> Result<(), TunnelError> {
|
||||
let target_addr = config.target_addr;
|
||||
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();
|
||||
let auth_token = config.auth_token.clone();
|
||||
|
||||
// Build client TLS config
|
||||
let client_config = tls::build_client_config(
|
||||
client_cert_path.as_ref(),
|
||||
client_key_path.as_ref(),
|
||||
ca_cert_path.as_ref(),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to build client TLS config: {}", e);
|
||||
TunnelError::Tls(e)
|
||||
})?;
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(client_config);
|
||||
|
||||
// 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)
|
||||
})?;
|
||||
|
||||
// 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!("Connected to {} at {}", "HTTPS tunnel", target_addr);
|
||||
|
||||
// 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
|
||||
)),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("TLS handshake completed with {}", target_addr);
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
);
|
||||
connect_tunnel(config.clone()).await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security gate state machine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Check if all security gates have passed for a connection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum SecurityGateStatus {
|
||||
/// TLS not yet negotiated
|
||||
TlsPending,
|
||||
/// TLS negotiated, client cert check pending
|
||||
ClientCertPending,
|
||||
/// mTLS established, auth pending
|
||||
AuthPending,
|
||||
/// All gates passed, tunnel ready
|
||||
Authenticated,
|
||||
/// A gate failed, tunnel rejected
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// State machine for security gate tracking.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecurityGateState {
|
||||
status: Arc<Mutex<SecurityGateStatus>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl SecurityGateState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: Arc::new(Mutex::new(SecurityGateStatus::TlsPending)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn advance(&self, to: SecurityGateStatus) {
|
||||
let mut status = self.status.lock().await;
|
||||
*status = to;
|
||||
}
|
||||
|
||||
pub async fn is_authenticated(&self) -> bool {
|
||||
let status = self.status.lock().await;
|
||||
*status == SecurityGateStatus::Authenticated
|
||||
}
|
||||
|
||||
pub async fn get_status(&self) -> SecurityGateStatus {
|
||||
*self.status.lock().await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
|
||||
static PORT_COUNTER: AtomicU16 = AtomicU16::new(50000);
|
||||
|
||||
fn get_free_port() -> u16 {
|
||||
PORT_COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
fn generate_test_creds() -> tempfile::TempDir {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
crate::generate::generate(dir.path(), "test-ca", "test-server", "test-client").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
macro_rules! path_arc {
|
||||
($e:expr) => {
|
||||
Arc::from(std::path::Path::new(&$e))
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_mtls_and_auth_establishes_tunnel() {
|
||||
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 });
|
||||
|
||||
// Give the listener time to bind
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
let connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Valid mTLS+auth should establish tunnel: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_auth_token_fails() {
|
||||
let dir = generate_test_creds();
|
||||
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("correct-token".to_string()),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
let connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Invalid auth token should fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_client_cert_fails() {
|
||||
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();
|
||||
|
||||
let listener_config = ListenerConfig {
|
||||
bind_addr,
|
||||
server_cert_path: path_arc!(dir1.path().join("server.crt")),
|
||||
server_key_path: path_arc!(dir1.path().join("server.key")),
|
||||
ca_cert_path: path_arc!(dir1.path().join("ca.pem")),
|
||||
auth_token: Arc::new("token".to_string()),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Client from a different CA — should fail at TLS
|
||||
let connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Different CA client cert should fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_conflict_fails_without_fallback() {
|
||||
let dir = generate_test_creds();
|
||||
let port = get_free_port();
|
||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
// Bind the port first
|
||||
let _listener = TcpListener::bind(bind_addr).await.unwrap();
|
||||
|
||||
let 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".to_string()),
|
||||
};
|
||||
|
||||
let result = run_listener(config).await;
|
||||
assert!(matches!(result, Err(TunnelError::PortInUse(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_tls_config_fails_closed() {
|
||||
let port = get_free_port();
|
||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||
|
||||
let config = ListenerConfig {
|
||||
bind_addr,
|
||||
server_cert_path: Arc::from(std::path::Path::new("/nonexistent/cert.pem")),
|
||||
server_key_path: Arc::from(std::path::Path::new("/nonexistent/key.pem")),
|
||||
ca_cert_path: Arc::from(std::path::Path::new("/nonexistent/ca.pem")),
|
||||
auth_token: Arc::new("token".to_string()),
|
||||
};
|
||||
|
||||
let result = run_listener(config).await;
|
||||
assert!(result.is_err(), "Missing TLS config should fail closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_auth_token_from_value() {
|
||||
let token = load_auth_token(Some("my-token"), None).unwrap();
|
||||
assert_eq!(token, "my-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_auth_token_from_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let token_path = dir.path().join("token.txt");
|
||||
std::fs::write(&token_path, " file-token \n").unwrap();
|
||||
|
||||
let token = load_auth_token(None, Some(&token_path)).unwrap();
|
||||
assert_eq!(token, "file-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_auth_token_empty_value_fails() {
|
||||
let result = load_auth_token(Some(""), None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_compare_equal() {
|
||||
assert!(constant_time_compare("abc", "abc"));
|
||||
assert!(constant_time_compare("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_time_compare_not_equal() {
|
||||
assert!(!constant_time_compare("abc", "abd"));
|
||||
assert!(!constant_time_compare("abc", "abcd"));
|
||||
assert!(!constant_time_compare("abc", ""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_gate_state_transitions() {
|
||||
let state = SecurityGateState::new();
|
||||
assert!(!state.is_authenticated().await);
|
||||
assert_eq!(state.get_status().await, SecurityGateStatus::TlsPending);
|
||||
|
||||
state.advance(SecurityGateStatus::Authenticated).await;
|
||||
assert!(state.is_authenticated().await);
|
||||
assert_eq!(state.get_status().await, SecurityGateStatus::Authenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_revalidates_security_gates() {
|
||||
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 connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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()),
|
||||
};
|
||||
|
||||
// First connect succeeds
|
||||
let result = connect_tunnel(connector_config.clone()).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Reconnect with wrong token should fail
|
||||
let bad_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Reconnect with bad auth should fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Reconnect with correct token should succeed
|
||||
let result = reconnect_tunnel(&connector_config).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Reconnect with valid auth should succeed: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[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();
|
||||
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;
|
||||
|
||||
// Try plain TCP connection without TLS — should fail at TLS
|
||||
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
|
||||
// won't get meaningful data — the server will fail on TLS accept
|
||||
}
|
||||
|
||||
// Verify HTTPS connection works
|
||||
let connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(result.is_ok(), "HTTPS mTLS should work");
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
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("correct-token".to_string()),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Correct mTLS, wrong auth
|
||||
let connector_config = ConnectorConfig {
|
||||
target_addr: bind_addr,
|
||||
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;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Valid mTLS + wrong auth should fail: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
|
||||
#[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")),
|
||||
server_key_path: path_arc!(dir1.path().join("server.key")),
|
||||
ca_cert_path: path_arc!(dir1.path().join("ca.pem")),
|
||||
auth_token: Arc::new("token".to_string()),
|
||||
};
|
||||
|
||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
||||
|
||||
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,
|
||||
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;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Client should reject server cert not signed by expected CA: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
listener_task.abort();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user