Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e513f8686b | |||
| e06667b298 | |||
| c3afb83470 | |||
| 2d7a507cf2 |
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
||||
# 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
|
||||
|
||||
- Adds distinct serverDefinedClientTags and clientDefinedClientTags fields to client registry and TypeScript interfaces.
|
||||
- Treats legacy tags values as serverDefinedClientTags during deserialization and server-side create/update flows for backward compatibility.
|
||||
- Clarifies that only server-defined tags are trusted for access control while client-defined tags are informational only.
|
||||
|
||||
## 2026-03-30 - 1.12.0 - feat(server)
|
||||
add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartvpn",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"private": false,
|
||||
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
|
||||
"type": "module",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -44,7 +44,12 @@ pub struct ClientEntry {
|
||||
pub priority: Option<u32>,
|
||||
/// Whether this client is enabled (default: true).
|
||||
pub enabled: Option<bool>,
|
||||
/// Tags for grouping.
|
||||
/// Tags assigned by the server admin — trusted, used for access control.
|
||||
pub server_defined_client_tags: Option<Vec<String>>,
|
||||
/// Tags reported by the connecting client — informational only.
|
||||
pub client_defined_client_tags: Option<Vec<String>>,
|
||||
/// Legacy tags field — treated as serverDefinedClientTags during deserialization.
|
||||
#[serde(default)]
|
||||
pub tags: Option<Vec<String>>,
|
||||
/// Optional description.
|
||||
pub description: Option<String>,
|
||||
@@ -90,7 +95,11 @@ impl ClientRegistry {
|
||||
/// Build a registry from a list of client entries.
|
||||
pub fn from_entries(entries: Vec<ClientEntry>) -> Result<Self> {
|
||||
let mut registry = Self::new();
|
||||
for entry in entries {
|
||||
for mut entry in entries {
|
||||
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||
if entry.server_defined_client_tags.is_none() && entry.tags.is_some() {
|
||||
entry.server_defined_client_tags = entry.tags.take();
|
||||
}
|
||||
registry.add(entry)?;
|
||||
}
|
||||
Ok(registry)
|
||||
@@ -193,6 +202,8 @@ mod tests {
|
||||
security: None,
|
||||
priority: None,
|
||||
enabled: None,
|
||||
server_defined_client_tags: None,
|
||||
client_defined_client_tags: None,
|
||||
tags: None,
|
||||
description: None,
|
||||
expires_at: None,
|
||||
|
||||
@@ -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 {
|
||||
@@ -551,9 +568,16 @@ impl VpnServer {
|
||||
).ok(),
|
||||
priority: partial.get("priority").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
enabled: partial.get("enabled").and_then(|v| v.as_bool()).or(Some(true)),
|
||||
tags: partial.get("tags").and_then(|v| {
|
||||
server_defined_client_tags: partial.get("serverDefinedClientTags").and_then(|v| {
|
||||
v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
}).or_else(|| {
|
||||
// Legacy: accept "tags" as serverDefinedClientTags
|
||||
partial.get("tags").and_then(|v| {
|
||||
v.as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
})
|
||||
}),
|
||||
client_defined_client_tags: None, // Only set by connecting client
|
||||
tags: None, // Legacy field — not used for new entries
|
||||
description: partial.get("description").and_then(|v| v.as_str()).map(String::from),
|
||||
expires_at: partial.get("expiresAt").and_then(|v| v.as_str()).map(String::from),
|
||||
assigned_ip: Some(assigned_ip.to_string()),
|
||||
@@ -648,8 +672,11 @@ impl VpnServer {
|
||||
if let Some(enabled) = update.get("enabled").and_then(|v| v.as_bool()) {
|
||||
entry.enabled = Some(enabled);
|
||||
}
|
||||
if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) {
|
||||
entry.tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
if let Some(tags) = update.get("serverDefinedClientTags").and_then(|v| v.as_array()) {
|
||||
entry.server_defined_client_tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
} else if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) {
|
||||
// Legacy: accept "tags" as serverDefinedClientTags
|
||||
entry.server_defined_client_tags = Some(tags.iter().filter_map(|s| s.as_str().map(String::from)).collect());
|
||||
}
|
||||
if let Some(desc) = update.get("description").and_then(|v| v.as_str()) {
|
||||
entry.description = Some(desc.to_string());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartvpn',
|
||||
version: '1.12.0',
|
||||
version: '1.14.0',
|
||||
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface IVpnClientConfig {
|
||||
wgEndpoint?: string;
|
||||
/** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */
|
||||
wgAllowedIps?: string[];
|
||||
/** Client-defined tags reported to the server after connection (informational, not for access control) */
|
||||
clientDefinedClientTags?: string[];
|
||||
}
|
||||
|
||||
export interface IVpnClientOptions {
|
||||
@@ -123,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 {
|
||||
@@ -290,7 +313,11 @@ export interface IClientEntry {
|
||||
priority?: number;
|
||||
/** Whether this client is enabled (default: true) */
|
||||
enabled?: boolean;
|
||||
/** Tags for grouping (e.g. ["engineering", "office"]) */
|
||||
/** Tags assigned by the server admin — trusted, used for access control (e.g. ["engineering", "office"]) */
|
||||
serverDefinedClientTags?: string[];
|
||||
/** Tags reported by the connecting client — informational only, never used for access control */
|
||||
clientDefinedClientTags?: string[];
|
||||
/** @deprecated Use serverDefinedClientTags instead. Legacy field kept for backward compatibility. */
|
||||
tags?: string[];
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
|
||||
Reference in New Issue
Block a user