diff --git a/changelog.md b/changelog.md index 1388930..7dbbdfe 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-30 - 1.14.0 - feat(nat) +add destination routing policy support for socket-mode VPN traffic + +- introduce configurable destinationPolicy settings in server and TypeScript interfaces +- apply allow, block, and forceTarget routing decisions when creating TCP and UDP NAT sessions +- export ACL IP matching helper for destination policy evaluation + ## 2026-03-30 - 1.13.0 - feat(client-registry) separate trusted server-defined client tags from client-reported tags with legacy tag compatibility diff --git a/rust/src/acl.rs b/rust/src/acl.rs index de9af86..cee853d 100644 --- a/rust/src/acl.rs +++ b/rust/src/acl.rs @@ -78,7 +78,7 @@ pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) /// Check if `ip` matches any pattern in the list. /// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*), /// and IP ranges (192.168.1.1-192.168.1.100). -fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool { +pub fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool { for pattern in patterns { if ip_matches(ip, pattern) { return true; diff --git a/rust/src/server.rs b/rust/src/server.rs index a5b8ca8..1fd2076 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -24,6 +24,20 @@ use crate::tunnel::{self, TunConfig}; /// Dead-peer timeout: 3x max keepalive interval (Healthy=60s). const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180); +/// Destination routing policy for VPN client traffic. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DestinationPolicyConfig { + /// Default action: "forceTarget", "block", or "allow". + pub default: String, + /// Target IP for "forceTarget" mode (e.g. "127.0.0.1"). + pub target: Option, + /// Destinations that pass through directly (not rewritten, not blocked). + pub allow_list: Option>, + /// Destinations always blocked (overrides allowList, deny wins). + pub block_list: Option>, +} + /// Server configuration (matches TS IVpnServerConfig). #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -62,6 +76,8 @@ pub struct ServerConfig { /// PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's /// tunnel IP as the source address. pub socket_forward_proxy_protocol: 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. pub wg_private_key: Option, /// WireGuard: UDP listen port (default: 51820). @@ -261,6 +277,7 @@ impl VpnServer { link_mtu as usize, state.clone(), proxy_protocol, + config.destination_policy.clone(), ); tokio::spawn(async move { if let Err(e) = nat_engine.run(packet_rx, shutdown_rx).await { diff --git a/rust/src/userspace_nat.rs b/rust/src/userspace_nat.rs index 16fee48..34bd9c9 100644 --- a/rust/src/userspace_nat.rs +++ b/rust/src/userspace_nat.rs @@ -13,7 +13,8 @@ use tokio::net::{TcpStream, UdpSocket}; use tokio::sync::mpsc; use tracing::{debug, info, warn}; -use crate::server::ServerState; +use crate::acl; +use crate::server::{DestinationPolicyConfig, ServerState}; use crate::tunnel; // ============================================================================ @@ -194,10 +195,22 @@ pub struct NatEngine { /// When true, outbound TCP connections prepend PROXY protocol v2 headers /// with the VPN client's tunnel IP as source address. proxy_protocol: bool, + /// Destination routing policy: forceTarget, block, or allow. + destination_policy: Option, +} + +/// Result of destination policy evaluation. +enum DestinationAction { + /// Connect to the original destination. + PassThrough(SocketAddr), + /// Redirect to a target IP, preserving original port. + ForceTarget(SocketAddr), + /// Drop the packet silently. + Drop, } impl NatEngine { - pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc, proxy_protocol: bool) -> Self { + pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc, proxy_protocol: 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); @@ -230,6 +243,7 @@ impl NatEngine { bridge_tx, start_time: std::time::Instant::now(), proxy_protocol, + destination_policy, } } @@ -237,6 +251,40 @@ impl NatEngine { smoltcp::time::Instant::from_millis(self.start_time.elapsed().as_millis() as i64) } + /// Evaluate destination policy for a packet's destination IP. + fn evaluate_destination(&self, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction { + let policy = match &self.destination_policy { + Some(p) => p, + None => return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)), + }; + + // 1. Block list wins (deny overrides allow) + if let Some(ref block_list) = policy.block_list { + if !block_list.is_empty() && acl::ip_matches_any(dst_ip, block_list) { + return DestinationAction::Drop; + } + } + + // 2. Allow list — pass through directly + if let Some(ref allow_list) = policy.allow_list { + if !allow_list.is_empty() && acl::ip_matches_any(dst_ip, allow_list) { + return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)); + } + } + + // 3. Default action + match policy.default.as_str() { + "forceTarget" => { + let target_ip = policy.target.as_deref() + .and_then(|t| t.parse::().ok()) + .unwrap_or(Ipv4Addr::LOCALHOST); + DestinationAction::ForceTarget(SocketAddr::new(target_ip.into(), dst_port)) + } + "block" => DestinationAction::Drop, + _ => DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)), + } + } + /// Inject a raw IP packet from a VPN client and handle new session creation. fn inject_packet(&mut self, packet: Vec) { let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else { @@ -261,7 +309,14 @@ impl NatEngine { // SYN without ACK = new connection let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0; if is_syn && !self.tcp_sessions.contains_key(&key) { - self.create_tcp_session(&key); + match self.evaluate_destination(dst_ip, dst_port) { + DestinationAction::Drop => { + debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port); + return; + } + DestinationAction::PassThrough(addr) => self.create_tcp_session(&key, addr), + DestinationAction::ForceTarget(addr) => self.create_tcp_session(&key, addr), + } } } 17 => { @@ -278,7 +333,14 @@ impl NatEngine { }; if !self.udp_sessions.contains_key(&key) { - self.create_udp_session(&key); + match self.evaluate_destination(dst_ip, dst_port) { + DestinationAction::Drop => { + debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port); + return; + } + DestinationAction::PassThrough(addr) => self.create_udp_session(&key, addr), + DestinationAction::ForceTarget(addr) => self.create_udp_session(&key, addr), + } } // Update last_activity for existing sessions @@ -295,7 +357,7 @@ impl NatEngine { self.device.inject_packet(packet); } - fn create_tcp_session(&mut self, key: &SessionKey) { + fn create_tcp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) { // Create smoltcp TCP socket let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]); let tcp_tx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]); @@ -323,12 +385,12 @@ impl NatEngine { }; self.tcp_sessions.insert(key.clone(), session); - // Spawn bridge task that connects to the real destination + // Spawn bridge task that connects to the resolved destination let bridge_tx = self.bridge_tx.clone(); let key_clone = key.clone(); let proxy_protocol = self.proxy_protocol; tokio::spawn(async move { - tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol).await; + tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol, connect_addr).await; }); debug!( @@ -337,7 +399,7 @@ impl NatEngine { ); } - fn create_udp_session(&mut self, key: &SessionKey) { + fn create_udp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) { // Create smoltcp UDP socket let udp_rx_buf = udp::PacketBuffer::new( vec![udp::PacketMetadata::EMPTY; 32], @@ -373,7 +435,7 @@ impl NatEngine { let bridge_tx = self.bridge_tx.clone(); let key_clone = key.clone(); tokio::spawn(async move { - udp_bridge_task(key_clone, data_rx, bridge_tx).await; + udp_bridge_task(key_clone, data_rx, bridge_tx, connect_addr).await; }); debug!( @@ -537,20 +599,19 @@ async fn tcp_bridge_task( mut data_rx: mpsc::Receiver>, bridge_tx: mpsc::Sender, proxy_protocol: bool, + connect_addr: SocketAddr, ) { - let addr = SocketAddr::new(key.dst_ip.into(), key.dst_port); - - // Connect to real destination with timeout - let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(addr)).await + // Connect to resolved destination (may differ from key.dst_ip if policy rewrote it) + let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(connect_addr)).await { Ok(Ok(s)) => s, Ok(Err(e)) => { - debug!("NAT TCP connect to {} failed: {}", addr, e); + debug!("NAT TCP connect to {} failed: {}", connect_addr, e); let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await; return; } Err(_) => { - debug!("NAT TCP connect to {} timed out", addr); + debug!("NAT TCP connect to {} timed out", connect_addr); let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await; return; } @@ -564,7 +625,7 @@ async fn tcp_bridge_task( let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port); let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst); if let Err(e) = writer.write_all(&pp_header).await { - debug!("NAT: failed to send PP v2 header to {}: {}", addr, e); + debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e); let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await; return; } @@ -612,6 +673,7 @@ async fn udp_bridge_task( key: SessionKey, mut data_rx: mpsc::Receiver>, bridge_tx: mpsc::Sender, + connect_addr: SocketAddr, ) { let socket = match UdpSocket::bind("0.0.0.0:0").await { Ok(s) => s, @@ -620,7 +682,7 @@ async fn udp_bridge_task( return; } }; - let dest = SocketAddr::new(key.dst_ip.into(), key.dst_port); + let dest = connect_addr; let socket = Arc::new(socket); let socket2 = socket.clone(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 726e1a7..23dcc53 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartvpn', - version: '1.13.0', + version: '1.14.0', description: 'A VPN solution with TypeScript control plane and Rust data plane daemon' } diff --git a/ts/smartvpn.interfaces.ts b/ts/smartvpn.interfaces.ts index 67db08a..8c2dea4 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -125,6 +125,27 @@ export interface IVpnServerConfig { * 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; + /** 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). */ + destinationPolicy?: IDestinationPolicy; +} + +/** + * Destination routing policy for VPN client traffic. + * Evaluated per-packet in the NAT engine before per-client ACLs. + */ +export interface IDestinationPolicy { + /** Default action for traffic not matching allow/block lists */ + default: 'forceTarget' | 'block' | 'allow'; + /** Target IP address for 'forceTarget' mode (e.g. '127.0.0.1'). Required when default is 'forceTarget'. */ + target?: string; + /** Destinations that pass through directly — not rewritten, not blocked. + * Supports: exact IP, CIDR, wildcards (192.168.190.*), ranges. */ + allowList?: string[]; + /** Destinations that are always blocked. Overrides allowList (deny wins). + * Supports: exact IP, CIDR, wildcards, ranges. */ + blockList?: string[]; } export interface IVpnServerOptions {