feat(userspace-nat): add VPN metadata to PROXY protocol forwarding

This commit is contained in:
2026-05-24 01:23:53 +00:00
parent 10f9c2e609
commit 90d7f0903b
5 changed files with 204 additions and 14 deletions
+4
View File
@@ -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
+78 -3
View File
@@ -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
View File
@@ -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 {
+97 -6
View File
@@ -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>>,
+8 -3
View File
@@ -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). */