2 Commits

Author SHA1 Message Date
e513f8686b v1.14.0 2026-03-30 12:52:17 +00:00
e06667b298 feat(nat): add destination routing policy support for socket-mode VPN traffic 2026-03-30 12:52:17 +00:00
7 changed files with 127 additions and 20 deletions

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvpn",
"version": "1.13.0",
"version": "1.14.0",
"private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module",

View File

@@ -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;

View File

@@ -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<String>,
/// Destinations that pass through directly (not rewritten, not blocked).
pub allow_list: Option<Vec<String>>,
/// Destinations always blocked (overrides allowList, deny wins).
pub block_list: Option<Vec<String>>,
}
/// 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<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.
pub wg_private_key: Option<String>,
/// 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 {

View File

@@ -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<DestinationPolicyConfig>,
}
/// 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<ServerState>, proxy_protocol: bool) -> Self {
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>, proxy_protocol: 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);
@@ -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::<Ipv4Addr>().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<u8>) {
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<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>,
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<Vec<u8>>,
bridge_tx: mpsc::Sender<BridgeMessage>,
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();

View File

@@ -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'
}

View File

@@ -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 {