feat(rustproxy): add authenticated VPN route security

This commit is contained in:
2026-05-24 01:25:06 +00:00
parent c161ac664d
commit c7785d2f78
12 changed files with 310 additions and 14 deletions
@@ -275,6 +275,7 @@ mod tests {
rate_limit: None,
basic_auth: None,
jwt_auth: None,
vpn: None,
};
reg.recycle_for_security_change("r1", &security);
@@ -1,6 +1,8 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use thiserror::Error;
use rustproxy_config::VpnConnectionInfo;
#[derive(Debug, Error)]
pub enum ProxyProtocolError {
#[error("Invalid PROXY protocol header")]
@@ -19,6 +21,7 @@ pub struct ProxyProtocolHeader {
pub source_addr: SocketAddr,
pub dest_addr: SocketAddr,
pub protocol: ProxyProtocol,
pub vpn: Option<VpnConnectionInfo>,
}
/// Protocol in PROXY header.
@@ -43,6 +46,9 @@ const PROXY_V2_SIGNATURE: [u8; 12] = [
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
];
/// Custom SmartVPN metadata TLV. 0xEA sits in the PP2_TYPE_MIN_CUSTOM range.
pub const PP2_TYPE_SMARTVPN_METADATA: u8 = 0xEA;
// ===== v1 (text format) =====
/// Parse a PROXY protocol v1 header from data.
@@ -90,6 +96,7 @@ pub fn parse_v1(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
source_addr: SocketAddr::new(src_ip, src_port),
dest_addr: SocketAddr::new(dst_ip, dst_port),
protocol,
vpn: None,
};
Ok((header, line_end + 2))
@@ -173,6 +180,7 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
vpn: parse_v2_tlvs(&data[16..total_len], 0),
},
total_len,
));
@@ -193,11 +201,13 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
let vpn = parse_v2_tlvs(addr_block, 12);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp4,
vpn,
},
total_len,
))
@@ -213,11 +223,13 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
let dst_ip = Ipv4Addr::new(addr_block[4], addr_block[5], addr_block[6], addr_block[7]);
let src_port = u16::from_be_bytes([addr_block[8], addr_block[9]]);
let dst_port = u16::from_be_bytes([addr_block[10], addr_block[11]]);
let vpn = parse_v2_tlvs(addr_block, 12);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V4(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V4(dst_ip), dst_port),
protocol: ProxyProtocol::Udp4,
vpn,
},
total_len,
))
@@ -233,11 +245,13 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
let vpn = parse_v2_tlvs(addr_block, 36);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Tcp6,
vpn,
},
total_len,
))
@@ -253,11 +267,13 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_block[16..32]).unwrap());
let src_port = u16::from_be_bytes([addr_block[32], addr_block[33]]);
let dst_port = u16::from_be_bytes([addr_block[34], addr_block[35]]);
let vpn = parse_v2_tlvs(addr_block, 36);
Ok((
ProxyProtocolHeader {
source_addr: SocketAddr::new(IpAddr::V6(src_ip), src_port),
dest_addr: SocketAddr::new(IpAddr::V6(dst_ip), dst_port),
protocol: ProxyProtocol::Udp6,
vpn,
},
total_len,
))
@@ -268,6 +284,7 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
source_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
dest_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
protocol: ProxyProtocol::Unknown,
vpn: parse_v2_tlvs(addr_block, 0),
},
total_len,
)),
@@ -278,6 +295,32 @@ pub fn parse_v2(data: &[u8]) -> Result<(ProxyProtocolHeader, usize), ProxyProtoc
}
}
fn parse_v2_tlvs(addr_block: &[u8], offset: usize) -> Option<VpnConnectionInfo> {
if addr_block.len() <= offset {
return None;
}
let mut pos = offset;
while pos + 3 <= addr_block.len() {
let tlv_type = addr_block[pos];
let len = u16::from_be_bytes([addr_block[pos + 1], addr_block[pos + 2]]) as usize;
pos += 3;
if pos + len > addr_block.len() {
return None;
}
let value = &addr_block[pos..pos + len];
if tlv_type == PP2_TYPE_SMARTVPN_METADATA {
if let Ok(metadata) = serde_json::from_slice::<VpnConnectionInfo>(value) {
return Some(metadata);
}
}
pos += len;
}
None
}
/// Generate a PROXY protocol v2 binary header.
pub fn generate_v2(source: &SocketAddr, dest: &SocketAddr, transport: ProxyV2Transport) -> Vec<u8> {
let transport_nibble: u8 = match transport {
@@ -382,6 +425,27 @@ mod tests {
assert_eq!(parsed.dest_addr, dest);
}
#[test]
fn test_parse_v2_smartvpn_metadata_tlv() {
let source: SocketAddr = "198.51.100.10:54321".parse().unwrap();
let dest: SocketAddr = "203.0.113.25:8443".parse().unwrap();
let mut header = generate_v2(&source, &dest, ProxyV2Transport::Stream);
let metadata = br#"{"clientId":"alice","assignedIp":"10.8.0.2","transportType":"wireguard","remoteAddr":"198.51.100.10:51820"}"#;
header.push(PP2_TYPE_SMARTVPN_METADATA);
header.extend_from_slice(&(metadata.len() as u16).to_be_bytes());
header.extend_from_slice(metadata);
let addr_len = 12 + 3 + metadata.len();
header[14..16].copy_from_slice(&(addr_len as u16).to_be_bytes());
let (parsed, consumed) = parse_v2(&header).unwrap();
assert_eq!(consumed, header.len());
assert_eq!(parsed.source_addr, source);
let vpn = parsed.vpn.unwrap();
assert_eq!(vpn.client_id, "alice");
assert_eq!(vpn.assigned_ip, "10.8.0.2");
assert_eq!(vpn.transport_type.as_deref(), Some("wireguard"));
}
#[test]
fn test_parse_v2_udp4() {
let source: SocketAddr = "10.0.0.1:12345".parse().unwrap();
@@ -14,7 +14,7 @@ use crate::forwarder;
use crate::sni_parser;
use crate::socket_opts;
use crate::tls_handler;
use rustproxy_config::RouteActionType;
use rustproxy_config::{RouteActionType, VpnConnectionInfo};
use rustproxy_http::HttpProxyService;
use rustproxy_metrics::MetricsCollector;
use rustproxy_routing::RouteManager;
@@ -654,10 +654,11 @@ impl TcpListenerManager {
// Only parse PROXY headers from trusted proxy IPs (security).
// Non-proxy connections skip the peek entirely (no latency cost).
let mut effective_peer_addr = peer_addr;
let mut vpn_info: Option<VpnConnectionInfo> = None;
if !conn_config.proxy_ips.is_empty() && conn_config.proxy_ips.contains(&peer_addr.ip()) {
// Trusted proxy IP — peek for PROXY protocol header.
// Use stack-allocated buffers (PROXY v1 headers are max ~108 bytes).
let mut proxy_peek = [0u8; 256];
let mut proxy_peek = [0u8; 4096];
let pn = match tokio::time::timeout(
std::time::Duration::from_millis(conn_config.initial_data_timeout_ms),
stream.peek(&mut proxy_peek),
@@ -693,7 +694,8 @@ impl TcpListenerManager {
header.source_addr, header.dest_addr, header.protocol
);
effective_peer_addr = header.source_addr;
let mut discard = [0u8; 256];
vpn_info = header.vpn;
let mut discard = vec![0u8; consumed];
stream.read_exact(&mut discard[..consumed]).await?;
}
Err(e) => {
@@ -812,6 +814,14 @@ impl TcpListenerManager {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());
}
if !rustproxy_http::request_filter::RequestFilter::check_vpn_security(
security,
vpn_info.as_ref(),
None,
) {
warn!("Connection from {} blocked by VPN route security", peer_addr);
return Ok(());
}
}
metrics.connection_opened(route_id, Some(&ip_str));
@@ -1049,6 +1059,14 @@ impl TcpListenerManager {
warn!("Connection from {} blocked by route security", peer_addr);
return Ok(());
}
if !rustproxy_http::request_filter::RequestFilter::check_vpn_security(
security,
vpn_info.as_ref(),
domain.as_deref(),
) {
warn!("Connection from {} blocked by VPN route security", peer_addr);
return Ok(());
}
}
// Track connection in metrics — guard ensures connection_closed on all exit paths
@@ -1079,6 +1097,7 @@ impl TcpListenerManager {
route_id,
&conn_config,
cancel.clone(),
vpn_info.clone(),
)
.await;
} else {
@@ -1264,7 +1283,7 @@ impl TcpListenerManager {
// (e.g. H2 close, backend error, idle timeout drain).
let wrapped = rustproxy_http::shutdown_on_drop::ShutdownOnDrop::new(buf_stream);
http_proxy
.handle_io(wrapped, peer_addr, port, cancel.clone())
.handle_io_with_vpn(wrapped, peer_addr, port, cancel.clone(), vpn_info.clone())
.await;
} else {
debug!(
@@ -1375,7 +1394,7 @@ impl TcpListenerManager {
// even if hyper drops the connection without calling shutdown.
let wrapped = rustproxy_http::shutdown_on_drop::ShutdownOnDrop::new(buf_stream);
http_proxy
.handle_io(wrapped, peer_addr, port, cancel.clone())
.handle_io_with_vpn(wrapped, peer_addr, port, cancel.clone(), vpn_info.clone())
.await;
} else {
// Non-HTTP: TLS-to-TLS tunnel (existing behavior for raw TCP protocols)
@@ -1404,7 +1423,7 @@ impl TcpListenerManager {
// Plain HTTP - use HTTP proxy for request-level routing
debug!("HTTP proxy: {} on port {}", peer_addr, port);
http_proxy
.handle_connection(stream, peer_addr, port, cancel.clone())
.handle_io_with_vpn(stream, peer_addr, port, cancel.clone(), vpn_info.clone())
.await;
Ok(())
} else {
@@ -1485,6 +1504,7 @@ impl TcpListenerManager {
route_id: Option<&str>,
conn_config: &ConnectionConfig,
cancel: CancellationToken,
vpn_info: Option<VpnConnectionInfo>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
@@ -1511,6 +1531,7 @@ impl TcpListenerManager {
"localPort": port,
"isTLS": is_tls,
"domain": domain,
"vpn": vpn_info,
});
// Send metadata line (JSON + newline)