feat(userspace-nat): add VPN metadata to PROXY protocol forwarding
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
//! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt>
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
@@ -17,6 +18,20 @@ const PP_V2_SIGNATURE: [u8; 12] = [
|
||||
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
|
||||
];
|
||||
|
||||
/// Custom PROXY v2 TLV type for authenticated SmartVPN connection metadata.
|
||||
/// 0xEA is in the PP2_TYPE_MIN_CUSTOM range (0xE0-0xEF).
|
||||
pub const PP2_TYPE_SMARTVPN_METADATA: u8 = 0xEA;
|
||||
|
||||
/// Authenticated VPN metadata sent to trusted downstream proxies.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VpnProxyMetadata {
|
||||
pub client_id: String,
|
||||
pub assigned_ip: String,
|
||||
pub transport_type: String,
|
||||
pub remote_addr: Option<String>,
|
||||
}
|
||||
|
||||
/// Parsed PROXY protocol v2 header.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyHeader {
|
||||
@@ -124,6 +139,26 @@ async fn read_proxy_header_inner(stream: &mut TcpStream) -> Result<ProxyHeader>
|
||||
|
||||
/// Build a PROXY protocol v2 header (for testing / proxy implementations).
|
||||
pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
||||
build_pp_v2_header_with_vpn_metadata(src, dst, None)
|
||||
}
|
||||
|
||||
/// Build a PROXY protocol v2 header with optional SmartVPN metadata TLV.
|
||||
pub fn build_pp_v2_header_with_vpn_metadata(
|
||||
src: SocketAddr,
|
||||
dst: SocketAddr,
|
||||
vpn_metadata: Option<&VpnProxyMetadata>,
|
||||
) -> Vec<u8> {
|
||||
let mut tlv_bytes = Vec::new();
|
||||
if let Some(metadata) = vpn_metadata {
|
||||
if let Ok(json) = serde_json::to_vec(metadata) {
|
||||
if json.len() <= u16::MAX as usize {
|
||||
tlv_bytes.push(PP2_TYPE_SMARTVPN_METADATA);
|
||||
tlv_bytes.extend_from_slice(&(json.len() as u16).to_be_bytes());
|
||||
tlv_bytes.extend_from_slice(&json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&PP_V2_SIGNATURE);
|
||||
|
||||
@@ -131,22 +166,38 @@ pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
||||
(SocketAddr::V4(s), SocketAddr::V4(d)) => {
|
||||
buf.push(0x21); // version 2 | PROXY command
|
||||
buf.push(0x11); // AF_INET | STREAM
|
||||
buf.extend_from_slice(&12u16.to_be_bytes()); // addr length
|
||||
buf.extend_from_slice(&((12 + tlv_bytes.len()) as u16).to_be_bytes()); // addr length
|
||||
buf.extend_from_slice(&s.ip().octets());
|
||||
buf.extend_from_slice(&d.ip().octets());
|
||||
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||
buf.extend_from_slice(&tlv_bytes);
|
||||
}
|
||||
(SocketAddr::V6(s), SocketAddr::V6(d)) => {
|
||||
buf.push(0x21); // version 2 | PROXY command
|
||||
buf.push(0x21); // AF_INET6 | STREAM
|
||||
buf.extend_from_slice(&36u16.to_be_bytes()); // addr length
|
||||
buf.extend_from_slice(&((36 + tlv_bytes.len()) as u16).to_be_bytes()); // addr length
|
||||
buf.extend_from_slice(&s.ip().octets());
|
||||
buf.extend_from_slice(&d.ip().octets());
|
||||
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||
buf.extend_from_slice(&tlv_bytes);
|
||||
}
|
||||
_ => {
|
||||
let src_v6 = match src.ip() {
|
||||
std::net::IpAddr::V4(v4) => v4.to_ipv6_mapped(),
|
||||
std::net::IpAddr::V6(v6) => v6,
|
||||
};
|
||||
let dst_v6 = match dst.ip() {
|
||||
std::net::IpAddr::V4(v4) => v4.to_ipv6_mapped(),
|
||||
std::net::IpAddr::V6(v6) => v6,
|
||||
};
|
||||
return build_pp_v2_header_with_vpn_metadata(
|
||||
SocketAddr::V6(SocketAddrV6::new(src_v6, src.port(), 0, 0)),
|
||||
SocketAddr::V6(SocketAddrV6::new(dst_v6, dst.port(), 0, 0)),
|
||||
vpn_metadata,
|
||||
);
|
||||
}
|
||||
_ => panic!("Mismatched address families"),
|
||||
}
|
||||
buf
|
||||
}
|
||||
@@ -197,6 +248,30 @@ mod tests {
|
||||
assert_eq!(parsed.dst_addr, dst);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_ipv4_header_with_smartvpn_metadata_tlv() {
|
||||
let src = "203.0.113.50:12345".parse::<SocketAddr>().unwrap();
|
||||
let dst = "10.0.0.1:443".parse::<SocketAddr>().unwrap();
|
||||
let metadata = VpnProxyMetadata {
|
||||
client_id: "alice".to_string(),
|
||||
assigned_ip: "10.8.0.2".to_string(),
|
||||
transport_type: "wireguard".to_string(),
|
||||
remote_addr: Some("203.0.113.50:51820".to_string()),
|
||||
};
|
||||
let header = build_pp_v2_header_with_vpn_metadata(src, dst, Some(&metadata));
|
||||
|
||||
let addr_len = u16::from_be_bytes([header[14], header[15]]) as usize;
|
||||
assert!(addr_len > 12);
|
||||
assert_eq!(header[28], PP2_TYPE_SMARTVPN_METADATA);
|
||||
let tlv_len = u16::from_be_bytes([header[29], header[30]]) as usize;
|
||||
let json = std::str::from_utf8(&header[31..31 + tlv_len]).unwrap();
|
||||
assert!(json.contains("\"clientId\":\"alice\""));
|
||||
|
||||
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||
assert_eq!(parsed.src_addr, src);
|
||||
assert_eq!(parsed.dst_addr, dst);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_valid_ipv6_header() {
|
||||
let src = "[2001:db8::1]:54321".parse::<SocketAddr>().unwrap();
|
||||
|
||||
+17
-2
@@ -73,9 +73,12 @@ pub struct ServerConfig {
|
||||
/// Server-level IP block list — applied at TCP accept, before Noise handshake.
|
||||
pub connection_ip_block_list: Option<Vec<String>>,
|
||||
/// When true and forwarding_mode is "socket", the userspace NAT engine prepends
|
||||
/// PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's
|
||||
/// tunnel IP as the source address.
|
||||
/// PROXY protocol v2 headers on outbound TCP connections.
|
||||
pub socket_forward_proxy_protocol: Option<bool>,
|
||||
/// Source address for outbound PROXY protocol headers: "tunnelIp" (legacy) or "remoteIp".
|
||||
pub socket_forward_proxy_protocol_source: Option<String>,
|
||||
/// Include authenticated SmartVPN metadata as a custom PROXY v2 TLV.
|
||||
pub socket_forward_proxy_protocol_vpn_metadata: Option<bool>,
|
||||
/// Destination routing policy for VPN client traffic (socket mode).
|
||||
pub destination_policy: Option<DestinationPolicyConfig>,
|
||||
/// WireGuard: server X25519 private key (base64). Required when transport includes WG.
|
||||
@@ -431,11 +434,17 @@ impl VpnServer {
|
||||
ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
|
||||
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx);
|
||||
let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false);
|
||||
let proxy_protocol_source = crate::userspace_nat::ProxyProtocolSource::from_config(
|
||||
config.socket_forward_proxy_protocol_source.as_deref(),
|
||||
);
|
||||
let proxy_protocol_vpn_metadata = config.socket_forward_proxy_protocol_vpn_metadata.unwrap_or(false);
|
||||
let nat_engine = crate::userspace_nat::NatEngine::new(
|
||||
gateway_ip,
|
||||
link_mtu as usize,
|
||||
state.clone(),
|
||||
proxy_protocol,
|
||||
proxy_protocol_source,
|
||||
proxy_protocol_vpn_metadata,
|
||||
config.destination_policy.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
@@ -473,11 +482,17 @@ impl VpnServer {
|
||||
|
||||
// Start socket (NAT) engine
|
||||
let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false);
|
||||
let proxy_protocol_source = crate::userspace_nat::ProxyProtocolSource::from_config(
|
||||
config.socket_forward_proxy_protocol_source.as_deref(),
|
||||
);
|
||||
let proxy_protocol_vpn_metadata = config.socket_forward_proxy_protocol_vpn_metadata.unwrap_or(false);
|
||||
let nat_engine = crate::userspace_nat::NatEngine::new(
|
||||
gateway_ip,
|
||||
link_mtu as usize,
|
||||
state.clone(),
|
||||
proxy_protocol,
|
||||
proxy_protocol_source,
|
||||
proxy_protocol_vpn_metadata,
|
||||
config.destination_policy.clone(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
|
||||
@@ -21,6 +21,21 @@ use crate::tunnel;
|
||||
/// Sessions exceeding this are aborted — the client cannot keep up.
|
||||
const TCP_PENDING_SEND_MAX: usize = 512 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProxyProtocolSource {
|
||||
TunnelIp,
|
||||
RemoteIp,
|
||||
}
|
||||
|
||||
impl ProxyProtocolSource {
|
||||
pub fn from_config(value: Option<&str>) -> Self {
|
||||
match value {
|
||||
Some("remoteIp") => ProxyProtocolSource::RemoteIp,
|
||||
_ => ProxyProtocolSource::TunnelIp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Virtual IP device for smoltcp
|
||||
// ============================================================================
|
||||
@@ -208,8 +223,10 @@ pub struct NatEngine {
|
||||
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||
start_time: std::time::Instant,
|
||||
/// When true, outbound TCP connections prepend PROXY protocol v2 headers
|
||||
/// with the VPN client's tunnel IP as source address.
|
||||
/// with VPN source identity.
|
||||
proxy_protocol: bool,
|
||||
proxy_protocol_source: ProxyProtocolSource,
|
||||
proxy_protocol_vpn_metadata: bool,
|
||||
/// Destination routing policy: forceTarget, block, or allow.
|
||||
destination_policy: Option<DestinationPolicyConfig>,
|
||||
}
|
||||
@@ -225,7 +242,15 @@ enum DestinationAction {
|
||||
}
|
||||
|
||||
impl NatEngine {
|
||||
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>, proxy_protocol: bool, destination_policy: Option<DestinationPolicyConfig>) -> Self {
|
||||
pub fn new(
|
||||
gateway_ip: Ipv4Addr,
|
||||
mtu: usize,
|
||||
state: Arc<ServerState>,
|
||||
proxy_protocol: bool,
|
||||
proxy_protocol_source: ProxyProtocolSource,
|
||||
proxy_protocol_vpn_metadata: bool,
|
||||
destination_policy: Option<DestinationPolicyConfig>,
|
||||
) -> Self {
|
||||
let mut device = VirtualIpDevice::new(mtu);
|
||||
let config = Config::new(HardwareAddress::Ip);
|
||||
let now = smoltcp::time::Instant::from_millis(0);
|
||||
@@ -258,6 +283,8 @@ impl NatEngine {
|
||||
bridge_tx,
|
||||
start_time: std::time::Instant::now(),
|
||||
proxy_protocol,
|
||||
proxy_protocol_source,
|
||||
proxy_protocol_vpn_metadata,
|
||||
destination_policy,
|
||||
}
|
||||
}
|
||||
@@ -481,6 +508,9 @@ impl NatEngine {
|
||||
// Start bridge tasks for sessions whose handshake just completed
|
||||
let bridge_tx_clone = self.bridge_tx.clone();
|
||||
let proxy_protocol = self.proxy_protocol;
|
||||
let proxy_protocol_source = self.proxy_protocol_source;
|
||||
let proxy_protocol_vpn_metadata = self.proxy_protocol_vpn_metadata;
|
||||
let state = Arc::clone(&self.state);
|
||||
for (key, session) in self.tcp_sessions.iter_mut() {
|
||||
if !session.bridge_started && !session.closing {
|
||||
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||
@@ -492,8 +522,11 @@ impl NatEngine {
|
||||
let k = key.clone();
|
||||
let addr = session.connect_addr;
|
||||
let pp = proxy_protocol;
|
||||
let pp_source = proxy_protocol_source;
|
||||
let pp_metadata = proxy_protocol_vpn_metadata;
|
||||
let state = Arc::clone(&state);
|
||||
tokio::spawn(async move {
|
||||
tcp_bridge_task(k, data_rx, btx, pp, addr).await;
|
||||
tcp_bridge_task(k, data_rx, btx, pp, pp_source, pp_metadata, state, addr).await;
|
||||
});
|
||||
debug!("NAT: TCP handshake complete, starting bridge for {}:{} -> {}:{}",
|
||||
key.src_ip, key.src_port, key.dst_ip, key.dst_port);
|
||||
@@ -748,6 +781,9 @@ async fn tcp_bridge_task(
|
||||
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||
proxy_protocol: bool,
|
||||
proxy_protocol_source: ProxyProtocolSource,
|
||||
proxy_protocol_vpn_metadata: bool,
|
||||
state: Arc<ServerState>,
|
||||
connect_addr: SocketAddr,
|
||||
) {
|
||||
// Connect to resolved destination (may differ from key.dst_ip if policy rewrote it)
|
||||
@@ -768,11 +804,21 @@ async fn tcp_bridge_task(
|
||||
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
|
||||
// Send PROXY protocol v2 header with VPN client's tunnel IP as source
|
||||
// Send PROXY protocol v2 header with configured client source identity.
|
||||
if proxy_protocol {
|
||||
let src = SocketAddr::new(key.src_ip.into(), key.src_port);
|
||||
let (src, metadata) = build_proxy_protocol_identity(
|
||||
&state,
|
||||
key.src_ip,
|
||||
key.src_port,
|
||||
proxy_protocol_source,
|
||||
proxy_protocol_vpn_metadata,
|
||||
).await;
|
||||
let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||
let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst);
|
||||
let pp_header = crate::proxy_protocol::build_pp_v2_header_with_vpn_metadata(
|
||||
src,
|
||||
dst,
|
||||
metadata.as_ref(),
|
||||
);
|
||||
if let Err(e) = writer.write_all(&pp_header).await {
|
||||
debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e);
|
||||
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||
@@ -818,6 +864,51 @@ async fn tcp_bridge_task(
|
||||
read_task.abort();
|
||||
}
|
||||
|
||||
async fn build_proxy_protocol_identity(
|
||||
state: &Arc<ServerState>,
|
||||
tunnel_ip: Ipv4Addr,
|
||||
tunnel_port: u16,
|
||||
proxy_protocol_source: ProxyProtocolSource,
|
||||
include_metadata: bool,
|
||||
) -> (SocketAddr, Option<crate::proxy_protocol::VpnProxyMetadata>) {
|
||||
let tunnel_addr = SocketAddr::new(tunnel_ip.into(), tunnel_port);
|
||||
|
||||
let client_id = state
|
||||
.client_registry
|
||||
.read()
|
||||
.await
|
||||
.get_by_assigned_ip(&tunnel_ip.to_string())
|
||||
.map(|entry| entry.client_id.clone());
|
||||
|
||||
let client_info = if let Some(ref client_id) = client_id {
|
||||
state.clients.read().await.get(client_id).cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let remote_addr = client_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.remote_addr.as_ref())
|
||||
.and_then(|addr| addr.parse::<SocketAddr>().ok());
|
||||
let source_addr = match proxy_protocol_source {
|
||||
ProxyProtocolSource::RemoteIp => remote_addr.unwrap_or(tunnel_addr),
|
||||
ProxyProtocolSource::TunnelIp => tunnel_addr,
|
||||
};
|
||||
|
||||
let metadata = if include_metadata {
|
||||
client_info.map(|info| crate::proxy_protocol::VpnProxyMetadata {
|
||||
client_id: info.registered_client_id,
|
||||
assigned_ip: info.assigned_ip,
|
||||
transport_type: info.transport_type,
|
||||
remote_addr: info.remote_addr,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(source_addr, metadata)
|
||||
}
|
||||
|
||||
async fn udp_bridge_task(
|
||||
key: SessionKey,
|
||||
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||
|
||||
Reference in New Issue
Block a user