diff --git a/changelog.md b/changelog.md index 35b1e78..9271fa0 100644 --- a/changelog.md +++ b/changelog.md @@ -2,7 +2,11 @@ ## Pending +### Features +- add PROXY v2 real-source forwarding with authenticated VPN metadata TLVs (userspace-nat) + - allows socket forwarding to emit the client remote IP instead of the tunnel IP when configured + - adds SmartVPN client metadata to outbound PROXY v2 headers for trusted downstream authorization ## 2026-05-12 - 1.19.4 diff --git a/rust/src/proxy_protocol.rs b/rust/src/proxy_protocol.rs index 325f3d2..3e84f26 100644 --- a/rust/src/proxy_protocol.rs +++ b/rust/src/proxy_protocol.rs @@ -4,6 +4,7 @@ //! Spec: 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, +} + /// 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 /// Build a PROXY protocol v2 header (for testing / proxy implementations). pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec { + 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 { + 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 { (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::().unwrap(); + let dst = "10.0.0.1:443".parse::().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::().unwrap(); diff --git a/rust/src/server.rs b/rust/src/server.rs index 10c0aab..0f69859 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -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>, /// 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, + /// Source address for outbound PROXY protocol headers: "tunnelIp" (legacy) or "remoteIp". + pub socket_forward_proxy_protocol_source: Option, + /// Include authenticated SmartVPN metadata as a custom PROXY v2 TLV. + pub socket_forward_proxy_protocol_vpn_metadata: Option, /// Destination routing policy for VPN client traffic (socket mode). pub destination_policy: Option, /// 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 { diff --git a/rust/src/userspace_nat.rs b/rust/src/userspace_nat.rs index f5fa587..5fb421d 100644 --- a/rust/src/userspace_nat.rs +++ b/rust/src/userspace_nat.rs @@ -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, 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, } @@ -225,7 +242,15 @@ enum DestinationAction { } impl NatEngine { - pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc, proxy_protocol: bool, destination_policy: Option) -> Self { + pub fn new( + gateway_ip: Ipv4Addr, + mtu: usize, + state: Arc, + proxy_protocol: bool, + proxy_protocol_source: ProxyProtocolSource, + proxy_protocol_vpn_metadata: bool, + destination_policy: Option, + ) -> 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::(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>, bridge_tx: mpsc::Sender, proxy_protocol: bool, + proxy_protocol_source: ProxyProtocolSource, + proxy_protocol_vpn_metadata: bool, + state: Arc, 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, + tunnel_ip: Ipv4Addr, + tunnel_port: u16, + proxy_protocol_source: ProxyProtocolSource, + include_metadata: bool, +) -> (SocketAddr, Option) { + 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::().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>, diff --git a/ts/smartvpn.interfaces.ts b/ts/smartvpn.interfaces.ts index 49ad225..6548350 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -122,10 +122,15 @@ export interface IVpnServerConfig { * Supports exact IPs, CIDR, wildcards, ranges. */ connectionIpBlockList?: string[]; /** When true and forwardingMode 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. This allows downstream services (e.g. SmartProxy) - * to see the real VPN client identity instead of 127.0.0.1. */ + * PROXY protocol v2 headers on outbound TCP connections. */ socketForwardProxyProtocol?: boolean; + /** Source address to place into outbound PROXY v2 headers. + * 'tunnelIp' preserves legacy behavior. 'remoteIp' exposes the VPN client's + * real connecting IP when known, with tunnel IP fallback. */ + socketForwardProxyProtocolSource?: 'tunnelIp' | 'remoteIp'; + /** When true, outbound PROXY v2 headers include authenticated SmartVPN metadata + * in a vendor TLV: clientId, assignedIp, transportType, and remoteAddr. */ + socketForwardProxyProtocolVpnMetadata?: boolean; /** Destination routing policy for VPN client traffic (socket mode). * Controls where decrypted traffic goes: allow through, block, or redirect to a target. * Default: all traffic passes through (backward compatible). */