fix: address secure-tunnel scrutiny findings
This commit is contained in:
@@ -65,6 +65,7 @@ pub enum TunnelError {
|
|||||||
ConnectionRefused(String),
|
ConnectionRefused(String),
|
||||||
|
|
||||||
#[error("connection closed")]
|
#[error("connection closed")]
|
||||||
|
#[allow(dead_code)]
|
||||||
ConnectionClosed,
|
ConnectionClosed,
|
||||||
|
|
||||||
#[error("port {0} already in use")]
|
#[error("port {0} already in use")]
|
||||||
@@ -116,3 +117,16 @@ pub enum ConnectorError {
|
|||||||
#[error("timeout: {0}")]
|
#[error("timeout: {0}")]
|
||||||
Timeout(String),
|
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),
|
||||||
|
}
|
||||||
|
|||||||
+24
-7
@@ -6,7 +6,6 @@ mod redact;
|
|||||||
mod tls;
|
mod tls;
|
||||||
mod tunnel;
|
mod tunnel;
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -17,6 +16,9 @@ use crate::cli::Cli;
|
|||||||
use crate::tunnel::{ConnectorConfig, ListenerConfig};
|
use crate::tunnel::{ConnectorConfig, ListenerConfig};
|
||||||
|
|
||||||
fn main() {
|
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
|
// Initialize tracing/logger — secrets are never printed by design
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.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
|
// Verify files exist before opening sockets
|
||||||
if !std::path::Path::new(&cert).exists() {
|
if !std::path::Path::new(&cert).exists() {
|
||||||
eprintln!("Error: server certificate not found: {}", cert);
|
eprintln!("Error: server certificate not found: {}", cert);
|
||||||
@@ -126,7 +140,7 @@ fn run_listen(
|
|||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport)",
|
"Starting HTTPS tunnel listener on {}:{} (HTTPS default transport, endpoint: /tunnel)",
|
||||||
host,
|
host,
|
||||||
port
|
port
|
||||||
);
|
);
|
||||||
@@ -138,7 +152,6 @@ fn run_listen(
|
|||||||
redact::Redacted::new(&auth_token)
|
redact::Redacted::new(&auth_token)
|
||||||
);
|
);
|
||||||
|
|
||||||
let bind_addr: SocketAddr = format!("{}:{}", host, port).parse().unwrap();
|
|
||||||
let config = ListenerConfig {
|
let config = ListenerConfig {
|
||||||
bind_addr,
|
bind_addr,
|
||||||
server_cert_path: Arc::from(Path::new(&cert)),
|
server_cert_path: Arc::from(Path::new(&cert)),
|
||||||
@@ -232,7 +245,11 @@ fn run_connect(
|
|||||||
target_host,
|
target_host,
|
||||||
target_port
|
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 certificate: {}", redact::Redacted::new(&cert));
|
||||||
tracing::info!("Using client key: {}", redact::Redacted::new(&key));
|
tracing::info!("Using client key: {}", redact::Redacted::new(&key));
|
||||||
tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert));
|
tracing::info!("Using CA certificate: {}", redact::Redacted::new(&ca_cert));
|
||||||
@@ -241,9 +258,9 @@ fn run_connect(
|
|||||||
redact::Redacted::new(&auth_token)
|
redact::Redacted::new(&auth_token)
|
||||||
);
|
);
|
||||||
|
|
||||||
let target_addr: SocketAddr = format!("{}:{}", target_host, target_port).parse().unwrap();
|
|
||||||
let config = ConnectorConfig {
|
let config = ConnectorConfig {
|
||||||
target_addr,
|
target_host,
|
||||||
|
target_port,
|
||||||
client_cert_path: Arc::from(Path::new(&cert)),
|
client_cert_path: Arc::from(Path::new(&cert)),
|
||||||
client_key_path: Arc::from(Path::new(&key)),
|
client_key_path: Arc::from(Path::new(&key)),
|
||||||
ca_cert_path: Arc::from(Path::new(&ca_cert)),
|
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 {
|
fn run_version() -> i32 {
|
||||||
println!("rustunnel {}", env!("CARGO_PKG_VERSION"));
|
println!("rustunnel {}", env!("CARGO_PKG_VERSION"));
|
||||||
println!("Edition: {}", env!("CARGO_PKG_VERSION"));
|
println!("Edition: 2024");
|
||||||
println!("Platform: {}", std::env::consts::OS);
|
println!("Platform: {}", std::env::consts::OS);
|
||||||
println!("Arch: {}", std::env::consts::ARCH);
|
println!("Arch: {}", std::env::consts::ARCH);
|
||||||
0
|
0
|
||||||
|
|||||||
+533
-188
@@ -1,28 +1,31 @@
|
|||||||
/// HTTPS mTLS tunnel with application-level authentication.
|
/// HTTPS mTLS tunnel with application-level authentication.
|
||||||
///
|
///
|
||||||
/// Provides a listener and connector that establish a secure tunnel over raw TCP
|
/// Provides a real HTTPS listener at `/tunnel` endpoint and a connector that
|
||||||
/// with mutual TLS and an additional auth/session token handshake.
|
/// connects over HTTPS with mutual TLS and an additional auth/session token.
|
||||||
///
|
///
|
||||||
/// Protocol:
|
/// Protocol:
|
||||||
/// 1. TCP connect
|
/// 1. HTTPS POST to `/tunnel` with auth header
|
||||||
/// 2. TLS handshake (rustls mTLS)
|
/// 2. TLS handshake (rustls mTLS) — handled by hyper
|
||||||
/// 3. Line-based auth: Client sends "AUTH <token>\n", Server responds "OK\n" or "FAIL <reason>\n"
|
/// 3. Application auth via request header `X-Rustunnel-Token`
|
||||||
|
/// 4. Connection stays alive for session maintenance
|
||||||
///
|
///
|
||||||
/// Security gates (all must pass before forwarding):
|
/// Security gates (all must pass before forwarding):
|
||||||
/// 1. TLS negotiation
|
/// 1. HTTPS connection (hyper TLS acceptor)
|
||||||
/// 2. Server certificate validation
|
/// 2. Server certificate validation (client side)
|
||||||
/// 3. Client certificate validation
|
/// 3. Client certificate validation (server side, mTLS)
|
||||||
/// 4. Application auth token validation
|
/// 4. Application auth token validation
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
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::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{Mutex, Notify};
|
||||||
use tokio_rustls::TlsAcceptor;
|
|
||||||
|
|
||||||
use crate::errors::{AuthError, TunnelError};
|
use crate::errors::{AuthError, HostError, TunnelError};
|
||||||
use crate::redact::Redacted;
|
use crate::redact::Redacted;
|
||||||
use crate::tls;
|
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.
|
/// Resolve a hostname to a list of SocketAddrs.
|
||||||
///
|
/// Returns actionable errors instead of panicking.
|
||||||
/// Protocol:
|
pub async fn resolve_host(host: &str, port: u16) -> Result<Vec<SocketAddr>, HostError> {
|
||||||
/// - Client sends: AUTH <token>\n
|
if host.is_empty() {
|
||||||
/// - Server responds: OK\n on success, or FAIL <reason>\n on failure
|
return Err(HostError::InvalidHostname(host.to_string()));
|
||||||
///
|
|
||||||
/// 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)]
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
/// Configuration for the tunnel listener.
|
||||||
@@ -161,11 +115,11 @@ pub struct ListenerConfig {
|
|||||||
pub auth_token: Arc<String>,
|
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
|
/// Binds a TLS-enabled HTTPS server on the configured address.
|
||||||
/// tunnel connections over TLS with mTLS and auth. Returns when the
|
/// Only the `/tunnel` POST path is accepted and authenticated.
|
||||||
/// listener is shut down or an error occurs.
|
/// Returns when the listener is shut down or an error occurs.
|
||||||
pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> {
|
pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> {
|
||||||
let addr = config.bind_addr;
|
let addr = config.bind_addr;
|
||||||
let server_cert_path = config.server_cert_path.clone();
|
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
|
// Bind TCP listener — fail closed on port conflict
|
||||||
let listener = TcpListener::bind(addr).await.map_err(|e| {
|
let listener = TcpListener::bind(addr).await.map_err(|e| {
|
||||||
@@ -208,7 +162,7 @@ pub async fn run_listener(config: ListenerConfig) -> Result<(), TunnelError> {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"HTTPS tunnel listener bound to {} (HTTPS default transport)",
|
"HTTPS tunnel listener bound to {} — endpoint: /tunnel (HTTPS default transport)",
|
||||||
addr
|
addr
|
||||||
);
|
);
|
||||||
tracing::info!("Auth token configured: {}", Redacted::new(&*auth_token));
|
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);
|
tracing::info!("New connection from {}", peer_addr);
|
||||||
|
|
||||||
let acceptor = acceptor.clone();
|
let acceptor = tls_acceptor.clone();
|
||||||
let auth_token = auth_token.clone();
|
let token = auth_token.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) =
|
if let Err(e) = handle_listener_https(stream, peer_addr, acceptor, token).await {
|
||||||
handle_listener_connection(stream, peer_addr, acceptor, auth_token).await
|
|
||||||
{
|
|
||||||
tracing::warn!("Connection from {} failed: {}", peer_addr, e);
|
tracing::warn!("Connection from {} failed: {}", peer_addr, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a single incoming TLS connection for the listener.
|
/// Handle a single incoming HTTPS connection for the listener.
|
||||||
async fn handle_listener_connection(
|
/// Accepts TLS, then serves hyper HTTP requests — only `/tunnel` POST is valid.
|
||||||
stream: TcpStream,
|
async fn handle_listener_https(
|
||||||
|
stream: tokio::net::TcpStream,
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
acceptor: TlsAcceptor,
|
acceptor: tokio_rustls::TlsAcceptor,
|
||||||
auth_token: Arc<String>,
|
auth_token: Arc<String>,
|
||||||
) -> Result<(), TunnelError> {
|
) -> Result<(), TunnelError> {
|
||||||
// Perform TLS accept
|
// Perform TLS accept
|
||||||
@@ -250,9 +203,7 @@ async fn handle_listener_connection(
|
|||||||
TunnelError::Tls(crate::errors::TlsError::VerificationFailed(e.to_string()))
|
TunnelError::Tls(crate::errors::TlsError::VerificationFailed(e.to_string()))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Check peer certificates exist
|
// Check peer certificates exist (mTLS)
|
||||||
// get_ref() returns (&TcpStream, &ServerConnection)
|
|
||||||
// ServerConnection has peer_certificates() -> Option<&[CertificateDer]>
|
|
||||||
let (_tcp_stream, server_conn) = tls_stream.get_ref();
|
let (_tcp_stream, server_conn) = tls_stream.get_ref();
|
||||||
let peer_certs = server_conn.peer_certificates().ok_or_else(|| {
|
let peer_certs = server_conn.peer_certificates().ok_or_else(|| {
|
||||||
tracing::warn!("No client certificate from {}", peer_addr);
|
tracing::warn!("No client certificate from {}", peer_addr);
|
||||||
@@ -268,36 +219,111 @@ async fn handle_listener_connection(
|
|||||||
|
|
||||||
tracing::info!("mTLS established with {}", peer_addr);
|
tracing::info!("mTLS established with {}", peer_addr);
|
||||||
|
|
||||||
// Perform application auth handshake over the TLS stream
|
// Wrap TLS stream in hyper IO and serve HTTP
|
||||||
perform_auth_handshake(tls_stream, &auth_token, true).await?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Connector
|
// Connector (stays alive after auth)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Configuration for the tunnel connector.
|
/// Configuration for the tunnel connector.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConnectorConfig {
|
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_cert_path: Arc<Path>,
|
||||||
pub client_key_path: Arc<Path>,
|
pub client_key_path: Arc<Path>,
|
||||||
pub ca_cert_path: Arc<Path>,
|
pub ca_cert_path: Arc<Path>,
|
||||||
pub auth_token: Arc<String>,
|
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:
|
/// Performs the full security gate sequence:
|
||||||
/// 1. TCP connection
|
/// 1. TCP connection
|
||||||
/// 2. TLS handshake with server validation
|
/// 2. TLS handshake with server validation
|
||||||
/// 3. mTLS client certificate presentation
|
/// 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> {
|
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_cert_path = config.client_cert_path.clone();
|
||||||
let client_key_path = config.client_key_path.clone();
|
let client_key_path = config.client_key_path.clone();
|
||||||
let ca_cert_path = config.ca_cert_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)
|
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
|
// Register Ctrl-C handler for graceful shutdown
|
||||||
let server_name = tls::server_name_from_host(&target_addr.ip().to_string()).map_err(|e| {
|
let shutdown_clone = shutdown.clone();
|
||||||
tracing::error!("Invalid server name: {}", e);
|
tokio::spawn(async move {
|
||||||
TunnelError::Tls(e)
|
tokio::signal::ctrl_c().await.ok();
|
||||||
})?;
|
tracing::info!("Shutdown signal received — stopping connector");
|
||||||
|
shutdown_clone.notify_one();
|
||||||
|
});
|
||||||
|
|
||||||
// TCP connect
|
loop {
|
||||||
let stream = match TcpStream::connect(target_addr).await {
|
// Resolve hostname — no panic on failure
|
||||||
Ok(s) => s,
|
let target_addr = match resolve_target(&target_host, target_port).await {
|
||||||
Err(e) => {
|
Ok(addr) => addr,
|
||||||
if e.kind() == std::io::ErrorKind::ConnectionRefused {
|
Err(e) => {
|
||||||
return Err(TunnelError::ConnectionRefused(target_addr.to_string()));
|
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
|
// TCP connect
|
||||||
let tls_stream = match connector.connect(server_name, stream).await {
|
let stream = match TcpStream::connect(target_addr).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("TLS handshake failed: {}", e);
|
if e.kind() == std::io::ErrorKind::ConnectionRefused {
|
||||||
return Err(TunnelError::Tls(
|
return Err(TunnelError::ConnectionRefused(target_addr.to_string()));
|
||||||
crate::errors::TlsError::VerificationFailed(format!(
|
}
|
||||||
"TLS handshake to {} failed: {}",
|
tracing::error!("Connection failed: {}", e);
|
||||||
target_addr, 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
|
// TLS handshake with server validation
|
||||||
match perform_auth_handshake(tls_stream, &auth_token, false).await {
|
let tls_stream = match tls_connector.connect(server_name.clone(), stream).await {
|
||||||
Ok(()) => {
|
Ok(s) => s,
|
||||||
tracing::info!("Tunnel session established with {}", target_addr);
|
Err(e) => {
|
||||||
Ok(())
|
tracing::error!("TLS handshake failed: {}", e);
|
||||||
}
|
return Err(TunnelError::Tls(
|
||||||
Err(TunnelError::Auth(e)) => {
|
crate::errors::TlsError::VerificationFailed(format!(
|
||||||
tracing::error!("Auth failed: {}", e);
|
"TLS handshake to {}:{} failed: {}",
|
||||||
Err(TunnelError::Auth(e))
|
target_host, target_port, e
|
||||||
}
|
)),
|
||||||
Err(e) => {
|
));
|
||||||
tracing::error!("Auth handshake error: {}", e);
|
}
|
||||||
Err(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.
|
/// Attempt to reconnect to the tunnel, revalidating all security gates.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn reconnect_tunnel(config: &ConnectorConfig) -> Result<(), TunnelError> {
|
pub async fn reconnect_tunnel(config: &ConnectorConfig) -> Result<(), TunnelError> {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Attempting reconnect to {} — revalidating security gates",
|
"Attempting reconnect to {}:{} — revalidating security gates",
|
||||||
config.target_addr
|
config.target_host,
|
||||||
|
config.target_port
|
||||||
);
|
);
|
||||||
connect_tunnel(config.clone()).await
|
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]
|
#[tokio::test]
|
||||||
async fn valid_mtls_and_auth_establishes_tunnel() {
|
async fn valid_mtls_and_auth_establishes_tunnel() {
|
||||||
let dir = generate_test_creds();
|
let dir = generate_test_creds();
|
||||||
@@ -474,18 +686,18 @@ mod tests {
|
|||||||
|
|
||||||
let listener_task = tokio::spawn(async move { run_listener(listener_config).await });
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
auth_token: Arc::new(token.clone()),
|
auth_token: Arc::new(token.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = connect_tunnel(connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"Valid mTLS+auth should establish tunnel: {:?}",
|
"Valid mTLS+auth should establish tunnel: {:?}",
|
||||||
@@ -514,14 +726,15 @@ mod tests {
|
|||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
auth_token: Arc::new("wrong-token".to_string()),
|
auth_token: Arc::new("wrong-token".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = connect_tunnel(connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Invalid auth token should fail: {:?}",
|
"Invalid auth token should fail: {:?}",
|
||||||
@@ -552,14 +765,15 @@ mod tests {
|
|||||||
|
|
||||||
// Client from a different CA — should fail at TLS
|
// Client from a different CA — should fail at TLS
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir2.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir2.path().join("client.key")),
|
client_key_path: path_arc!(dir2.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
||||||
auth_token: Arc::new("token".to_string()),
|
auth_token: Arc::new("token".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = connect_tunnel(connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Different CA client cert should fail: {:?}",
|
"Different CA client cert should fail: {:?}",
|
||||||
@@ -673,7 +887,8 @@ mod tests {
|
|||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
@@ -681,18 +896,19 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// First connect succeeds
|
// First connect succeeds
|
||||||
let result = connect_tunnel(connector_config.clone()).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
// Reconnect with wrong token should fail
|
// Reconnect with wrong token should fail
|
||||||
let bad_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
auth_token: Arc::new("bad-token".to_string()),
|
auth_token: Arc::new("bad-token".to_string()),
|
||||||
};
|
};
|
||||||
let result = reconnect_tunnel(&bad_config).await;
|
let result = quick_connect(&bad_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Reconnect with bad auth should fail: {:?}",
|
"Reconnect with bad auth should fail: {:?}",
|
||||||
@@ -700,7 +916,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Reconnect with correct token should succeed
|
// Reconnect with correct token should succeed
|
||||||
let result = reconnect_tunnel(&connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"Reconnect with valid auth should succeed: {:?}",
|
"Reconnect with valid auth should succeed: {:?}",
|
||||||
@@ -712,7 +928,6 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn https_is_default_carrier() {
|
async fn https_is_default_carrier() {
|
||||||
// Verify that the listener binds and only accepts HTTPS (not plain HTTP)
|
|
||||||
let dir = generate_test_creds();
|
let dir = generate_test_creds();
|
||||||
let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap();
|
let token = std::fs::read_to_string(dir.path().join("token.txt")).unwrap();
|
||||||
let port = get_free_port();
|
let port = get_free_port();
|
||||||
@@ -730,7 +945,7 @@ mod tests {
|
|||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
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;
|
let stream_result = TcpStream::connect(bind_addr).await;
|
||||||
if stream_result.is_ok() {
|
if stream_result.is_ok() {
|
||||||
// TCP connects but the server expects TLS, so a plain TCP client
|
// TCP connects but the server expects TLS, so a plain TCP client
|
||||||
@@ -739,14 +954,15 @@ mod tests {
|
|||||||
|
|
||||||
// Verify HTTPS connection works
|
// Verify HTTPS connection works
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
auth_token: Arc::new(token.clone()),
|
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");
|
assert!(result.is_ok(), "HTTPS mTLS should work");
|
||||||
|
|
||||||
listener_task.abort();
|
listener_task.abort();
|
||||||
@@ -754,26 +970,18 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn forwarding_starts_only_after_all_gates() {
|
async fn forwarding_starts_only_after_all_gates() {
|
||||||
// This test verifies the security gate state machine
|
|
||||||
let state = SecurityGateState::new();
|
let state = SecurityGateState::new();
|
||||||
|
|
||||||
// Before any gate passes, forwarding should not be allowed
|
|
||||||
assert!(!state.is_authenticated().await);
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
// Advance through gates
|
|
||||||
state.advance(SecurityGateStatus::ClientCertPending).await;
|
state.advance(SecurityGateStatus::ClientCertPending).await;
|
||||||
assert!(!state.is_authenticated().await);
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
state.advance(SecurityGateStatus::AuthPending).await;
|
state.advance(SecurityGateStatus::AuthPending).await;
|
||||||
assert!(!state.is_authenticated().await);
|
assert!(!state.is_authenticated().await);
|
||||||
|
|
||||||
state.advance(SecurityGateStatus::Authenticated).await;
|
state.advance(SecurityGateStatus::Authenticated).await;
|
||||||
assert!(state.is_authenticated().await);
|
assert!(state.is_authenticated().await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn auth_required_in_addition_to_mtls() {
|
async fn auth_required_in_addition_to_mtls() {
|
||||||
// Valid mTLS + wrong auth = fail
|
|
||||||
let dir = generate_test_creds();
|
let dir = generate_test_creds();
|
||||||
let port = get_free_port();
|
let port = get_free_port();
|
||||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
// Correct mTLS, wrong auth
|
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir.path().join("client.key")),
|
client_key_path: path_arc!(dir.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir.path().join("ca.pem")),
|
||||||
auth_token: Arc::new("wrong-token".to_string()),
|
auth_token: Arc::new("wrong-token".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = connect_tunnel(connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Valid mTLS + wrong auth should fail: {:?}",
|
"Valid mTLS + wrong auth should fail: {:?}",
|
||||||
@@ -811,13 +1019,11 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn invalid_server_cert_rejected_by_client() {
|
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 dir1 = generate_test_creds();
|
||||||
let dir2 = generate_test_creds();
|
let dir2 = generate_test_creds();
|
||||||
let port = get_free_port();
|
let port = get_free_port();
|
||||||
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
let bind_addr: SocketAddr = format!("127.0.0.1:{}", port).parse().unwrap();
|
||||||
|
|
||||||
// Listener uses CA1 certs
|
|
||||||
let listener_config = ListenerConfig {
|
let listener_config = ListenerConfig {
|
||||||
bind_addr,
|
bind_addr,
|
||||||
server_cert_path: path_arc!(dir1.path().join("server.crt")),
|
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;
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
// Client uses CA2 to validate server — should fail
|
|
||||||
let connector_config = ConnectorConfig {
|
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_cert_path: path_arc!(dir2.path().join("client.crt")),
|
||||||
client_key_path: path_arc!(dir2.path().join("client.key")),
|
client_key_path: path_arc!(dir2.path().join("client.key")),
|
||||||
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
ca_cert_path: path_arc!(dir2.path().join("ca.pem")),
|
||||||
auth_token: Arc::new("token".to_string()),
|
auth_token: Arc::new("token".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = connect_tunnel(connector_config).await;
|
let result = quick_connect(&connector_config).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
result.is_err(),
|
||||||
"Client should reject server cert not signed by expected CA: {:?}",
|
"Client should reject server cert not signed by expected CA: {:?}",
|
||||||
@@ -848,4 +1054,143 @@ mod tests {
|
|||||||
|
|
||||||
listener_task.abort();
|
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