Compare commits

...

2 Commits

Author SHA1 Message Date
jkunz 004c9ed252 v1.20.0 2026-05-24 01:24:11 +00:00
jkunz 90d7f0903b feat(userspace-nat): add VPN metadata to PROXY protocol forwarding 2026-05-24 01:23:53 +00:00
7 changed files with 209 additions and 16 deletions
+7
View File
@@ -3,6 +3,13 @@
## Pending ## Pending
## 2026-05-24 - 1.20.0
### 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 ## 2026-05-12 - 1.19.4
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvpn", "name": "@push.rocks/smartvpn",
"version": "1.19.4", "version": "1.20.0",
"private": false, "private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon", "description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module", "type": "module",
+78 -3
View File
@@ -4,6 +4,7 @@
//! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt> //! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt>
use anyhow::Result; use anyhow::Result;
use serde::Serialize;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
use std::time::Duration; use std::time::Duration;
use tokio::io::AsyncReadExt; 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, 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. /// Parsed PROXY protocol v2 header.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ProxyHeader { 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). /// Build a PROXY protocol v2 header (for testing / proxy implementations).
pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec<u8> { 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(); let mut buf = Vec::new();
buf.extend_from_slice(&PP_V2_SIGNATURE); 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)) => { (SocketAddr::V4(s), SocketAddr::V4(d)) => {
buf.push(0x21); // version 2 | PROXY command buf.push(0x21); // version 2 | PROXY command
buf.push(0x11); // AF_INET | STREAM 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(&s.ip().octets());
buf.extend_from_slice(&d.ip().octets()); buf.extend_from_slice(&d.ip().octets());
buf.extend_from_slice(&s.port().to_be_bytes()); buf.extend_from_slice(&s.port().to_be_bytes());
buf.extend_from_slice(&d.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)) => { (SocketAddr::V6(s), SocketAddr::V6(d)) => {
buf.push(0x21); // version 2 | PROXY command buf.push(0x21); // version 2 | PROXY command
buf.push(0x21); // AF_INET6 | STREAM 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(&s.ip().octets());
buf.extend_from_slice(&d.ip().octets()); buf.extend_from_slice(&d.ip().octets());
buf.extend_from_slice(&s.port().to_be_bytes()); buf.extend_from_slice(&s.port().to_be_bytes());
buf.extend_from_slice(&d.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 buf
} }
@@ -197,6 +248,30 @@ mod tests {
assert_eq!(parsed.dst_addr, dst); 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] #[tokio::test]
async fn parse_valid_ipv6_header() { async fn parse_valid_ipv6_header() {
let src = "[2001:db8::1]:54321".parse::<SocketAddr>().unwrap(); 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. /// Server-level IP block list — applied at TCP accept, before Noise handshake.
pub connection_ip_block_list: Option<Vec<String>>, pub connection_ip_block_list: Option<Vec<String>>,
/// When true and forwarding_mode is "socket", the userspace NAT engine prepends /// 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 /// PROXY protocol v2 headers on outbound TCP connections.
/// tunnel IP as the source address.
pub socket_forward_proxy_protocol: Option<bool>, 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). /// Destination routing policy for VPN client traffic (socket mode).
pub destination_policy: Option<DestinationPolicyConfig>, pub destination_policy: Option<DestinationPolicyConfig>,
/// WireGuard: server X25519 private key (base64). Required when transport includes WG. /// 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 } => { ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx } => {
*state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx); *state.forwarding_engine.lock().await = ForwardingEngine::Socket(packet_tx);
let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false); 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( let nat_engine = crate::userspace_nat::NatEngine::new(
gateway_ip, gateway_ip,
link_mtu as usize, link_mtu as usize,
state.clone(), state.clone(),
proxy_protocol, proxy_protocol,
proxy_protocol_source,
proxy_protocol_vpn_metadata,
config.destination_policy.clone(), config.destination_policy.clone(),
); );
tokio::spawn(async move { tokio::spawn(async move {
@@ -473,11 +482,17 @@ impl VpnServer {
// Start socket (NAT) engine // Start socket (NAT) engine
let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false); 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( let nat_engine = crate::userspace_nat::NatEngine::new(
gateway_ip, gateway_ip,
link_mtu as usize, link_mtu as usize,
state.clone(), state.clone(),
proxy_protocol, proxy_protocol,
proxy_protocol_source,
proxy_protocol_vpn_metadata,
config.destination_policy.clone(), config.destination_policy.clone(),
); );
tokio::spawn(async move { 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. /// Sessions exceeding this are aborted — the client cannot keep up.
const TCP_PENDING_SEND_MAX: usize = 512 * 1024; 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 // Virtual IP device for smoltcp
// ============================================================================ // ============================================================================
@@ -208,8 +223,10 @@ pub struct NatEngine {
bridge_tx: mpsc::Sender<BridgeMessage>, bridge_tx: mpsc::Sender<BridgeMessage>,
start_time: std::time::Instant, start_time: std::time::Instant,
/// When true, outbound TCP connections prepend PROXY protocol v2 headers /// 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: bool,
proxy_protocol_source: ProxyProtocolSource,
proxy_protocol_vpn_metadata: bool,
/// Destination routing policy: forceTarget, block, or allow. /// Destination routing policy: forceTarget, block, or allow.
destination_policy: Option<DestinationPolicyConfig>, destination_policy: Option<DestinationPolicyConfig>,
} }
@@ -225,7 +242,15 @@ enum DestinationAction {
} }
impl NatEngine { 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 mut device = VirtualIpDevice::new(mtu);
let config = Config::new(HardwareAddress::Ip); let config = Config::new(HardwareAddress::Ip);
let now = smoltcp::time::Instant::from_millis(0); let now = smoltcp::time::Instant::from_millis(0);
@@ -258,6 +283,8 @@ impl NatEngine {
bridge_tx, bridge_tx,
start_time: std::time::Instant::now(), start_time: std::time::Instant::now(),
proxy_protocol, proxy_protocol,
proxy_protocol_source,
proxy_protocol_vpn_metadata,
destination_policy, destination_policy,
} }
} }
@@ -481,6 +508,9 @@ impl NatEngine {
// Start bridge tasks for sessions whose handshake just completed // Start bridge tasks for sessions whose handshake just completed
let bridge_tx_clone = self.bridge_tx.clone(); let bridge_tx_clone = self.bridge_tx.clone();
let proxy_protocol = self.proxy_protocol; 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() { for (key, session) in self.tcp_sessions.iter_mut() {
if !session.bridge_started && !session.closing { if !session.bridge_started && !session.closing {
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle); let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
@@ -492,8 +522,11 @@ impl NatEngine {
let k = key.clone(); let k = key.clone();
let addr = session.connect_addr; let addr = session.connect_addr;
let pp = proxy_protocol; 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 { 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 {}:{} -> {}:{}", debug!("NAT: TCP handshake complete, starting bridge for {}:{} -> {}:{}",
key.src_ip, key.src_port, key.dst_ip, key.dst_port); 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>>, mut data_rx: mpsc::Receiver<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>, bridge_tx: mpsc::Sender<BridgeMessage>,
proxy_protocol: bool, proxy_protocol: bool,
proxy_protocol_source: ProxyProtocolSource,
proxy_protocol_vpn_metadata: bool,
state: Arc<ServerState>,
connect_addr: SocketAddr, connect_addr: SocketAddr,
) { ) {
// Connect to resolved destination (may differ from key.dst_ip if policy rewrote it) // 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(); 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 { 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 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 { if let Err(e) = writer.write_all(&pp_header).await {
debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e); debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e);
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await; let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
@@ -818,6 +864,51 @@ async fn tcp_bridge_task(
read_task.abort(); 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( async fn udp_bridge_task(
key: SessionKey, key: SessionKey,
mut data_rx: mpsc::Receiver<Vec<u8>>, mut data_rx: mpsc::Receiver<Vec<u8>>,
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvpn', name: '@push.rocks/smartvpn',
version: '1.19.4', version: '1.20.0',
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon' description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
} }
+8 -3
View File
@@ -122,10 +122,15 @@ export interface IVpnServerConfig {
* Supports exact IPs, CIDR, wildcards, ranges. */ * Supports exact IPs, CIDR, wildcards, ranges. */
connectionIpBlockList?: string[]; connectionIpBlockList?: string[];
/** When true and forwardingMode is 'socket', the userspace NAT engine prepends /** When true and forwardingMode is 'socket', the userspace NAT engine prepends
* PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's * PROXY protocol v2 headers on outbound TCP connections. */
* 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. */
socketForwardProxyProtocol?: boolean; 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). /** Destination routing policy for VPN client traffic (socket mode).
* Controls where decrypted traffic goes: allow through, block, or redirect to a target. * Controls where decrypted traffic goes: allow through, block, or redirect to a target.
* Default: all traffic passes through (backward compatible). */ * Default: all traffic passes through (backward compatible). */