From 180282ba8604004b6dbdc7313a8841ed63fb936e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 1 Apr 2026 03:47:26 +0000 Subject: [PATCH] feat(forwarding): add hybrid forwarding mode with per-client bridge and VLAN settings --- changelog.md | 7 +++ readme.md | 86 ++++++++++++++++++++----- rust/src/bridge.rs | 44 +++++++++++++ rust/src/client_registry.rs | 18 ++++++ rust/src/server.rs | 121 ++++++++++++++++++++++++++++++++++++ rust/src/wireguard.rs | 22 +++++++ ts/00_commitinfo_data.ts | 2 +- ts/smartvpn.interfaces.ts | 18 +++++- 8 files changed, 301 insertions(+), 17 deletions(-) diff --git a/changelog.md b/changelog.md index 03c9b7d..c8b6a8f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-01 - 1.19.0 - feat(forwarding) +add hybrid forwarding mode with per-client bridge and VLAN settings + +- introduces a new hybrid forwarding mode that routes each client through either userspace NAT or bridge mode based on per-client configuration +- adds per-client bridge options including useHostIp, DHCP, static LAN IP, and VLAN assignment fields to the server and TypeScript interfaces +- adds Linux bridge VLAN helper functions and updates documentation to cover hybrid mode and VLAN-capable bridge clients + ## 2026-03-31 - 1.18.0 - feat(server) add bridge forwarding mode and per-client destination policy overrides diff --git a/readme.md b/readme.md index f94887e..72c3c79 100644 --- a/readme.md +++ b/readme.md @@ -2,17 +2,19 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust data plane daemon**. Enterprise-ready client authentication, triple transport support (WebSocket + QUIC + WireGuard), and a typed hub API for managing clients from code. -🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry -🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol) -🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions -🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum) -📊 **Per-transport metrics**: active clients and total connections broken down by websocket, QUIC, and WireGuard -🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs -📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs -🌐 **Unified forwarding pipeline**: all transports share the same engine — TUN (kernel), userspace NAT (no root), L2 bridge, or testing mode -🏠 **Bridge mode**: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network -🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration -⚡ **Handshake-driven WireGuard state**: peers appear as "connected" only after a successful WireGuard handshake, and auto-disconnect on idle timeout +- 🔐 **Noise IK** mutual authentication — per-client X25519 keypairs, server-side registry +- 🚀 **Triple transport**: WebSocket (Cloudflare-friendly), raw **QUIC** (datagrams), and **WireGuard** (standard protocol) +- 🛡️ **ACL engine** — deny-overrides-allow IP filtering, aligned with SmartProxy conventions +- 🔀 **PROXY protocol v2** — real client IPs behind reverse proxies (HAProxy, SmartProxy, Cloudflare Spectrum) +- 📊 **Per-transport metrics**: active clients and total connections broken down by websocket, QUIC, and WireGuard +- 🔄 **Hub API**: one `createClient()` call generates keys, assigns IP, returns both SmartVPN + WireGuard configs +- 📡 **Real-time telemetry**: RTT, jitter, loss ratio, link health — all via typed APIs +- 🌐 **Unified forwarding pipeline**: all transports share the same engine — TUN (kernel), userspace NAT (no root), L2 bridge, hybrid, or testing mode +- 🏠 **Bridge mode**: VPN clients get IPs from your LAN subnet — seamlessly bridge remote clients onto a physical network +- 🔀 **Hybrid mode**: per-client routing — some clients bridge to the LAN, others use userspace NAT, all on the same server +- 🏷️ **VLAN support**: assign individual clients to 802.1Q VLANs on the bridge +- 🎯 **Destination routing policy**: force-target, block, or allow traffic per destination with nftables integration +- ⚡ **Handshake-driven WireGuard state**: peers appear as "connected" only after a successful WireGuard handshake, and auto-disconnect on idle timeout ## Issue Reporting and Security @@ -85,7 +87,7 @@ await server.start({ publicKey: '', subnet: '10.8.0.0/24', transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default) - forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'testing' + forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'hybrid' | 'testing' wgPrivateKey: '', // required for WireGuard transport enableNat: true, dns: ['1.1.1.1', '8.8.8.8'], @@ -267,13 +269,14 @@ await server.start({ ### 📦 Packet Forwarding Modes -SmartVPN supports four forwarding modes, configurable per-server and per-client: +SmartVPN supports five forwarding modes, configurable per-server: | Mode | Flag | Description | Root Required | |------|------|-------------|---------------| | **TUN** | `'tun'` | Kernel TUN device — real packet forwarding with system routing | ✅ Yes | | **Userspace NAT** | `'socket'` | Userspace TCP/UDP proxy via `connect(2)` — no TUN, no root needed | ❌ No | | **Bridge** | `'bridge'` | L2 bridge — VPN clients get IPs from a physical LAN subnet | ✅ Yes | +| **Hybrid** | `'hybrid'` | Per-client routing: some clients use socket NAT, others use bridge — both engines run simultaneously | ✅ Yes | | **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No | ```typescript @@ -294,6 +297,13 @@ await server.start({ bridgeIpRangeEnd: 250, }); +// Server with hybrid mode — per-client routing +await server.start({ + // ... + forwardingMode: 'hybrid', + bridgePhysicalInterface: 'eth0', // for bridge clients +}); + // Client with TUN device const { assignedIp } = await client.connect({ // ... @@ -305,6 +315,52 @@ The **userspace NAT** mode extracts destination IP/port from IP packets, opens a The **bridge** mode assigns VPN clients IPs from a real LAN subnet instead of a virtual VPN subnet. Clients appear as if they're directly on the physical network — perfect for remote access to home labs, office networks, or IoT devices. +The **hybrid** mode runs both engines simultaneously with a **per-client routing table**. Each client's `useHostIp` flag determines whether its packets go through the bridge (L2, LAN IP) or socket NAT (userspace, VPN IP). This is ideal when most clients need internet NAT but some need direct LAN access. + +### 🏠 Per-Client Bridge & VLAN Settings + +When using `bridge` or `hybrid` mode, each client can be individually configured for LAN bridging, static IPs, DHCP, and 802.1Q VLAN assignment: + +```typescript +// Client that bridges to the LAN with a static IP +await server.createClient({ + clientId: 'office-printer', + useHostIp: true, // bridge to LAN instead of VPN subnet + staticIp: '192.168.1.210', // fixed LAN IP +}); + +// Client that gets a LAN IP via DHCP +await server.createClient({ + clientId: 'roaming-laptop', + useHostIp: true, + useDhcp: true, // obtain IP from LAN DHCP server +}); + +// Client on a specific VLAN +await server.createClient({ + clientId: 'iot-sensor', + useHostIp: true, + forceVlan: true, + vlanId: 100, // 802.1Q VLAN ID (1-4094) +}); + +// Regular NAT client (default, no bridge) +await server.createClient({ + clientId: 'remote-worker', + // useHostIp defaults to false → uses socket NAT +}); +``` + +| Field | Type | Description | +|-------|------|-------------| +| `useHostIp` | `boolean` | `true` = bridge to LAN (host IP), `false` = VPN subnet via NAT (default) | +| `useDhcp` | `boolean` | When `useHostIp` is true, obtain IP via DHCP relay instead of static/auto-assign | +| `staticIp` | `string` | Fixed LAN IP when `useHostIp` is true and `useDhcp` is false | +| `forceVlan` | `boolean` | Assign this client to a specific 802.1Q VLAN on the bridge | +| `vlanId` | `number` | VLAN ID (1-4094), required when `forceVlan` is true | + +VLAN support uses Linux bridge VLAN filtering — each client's TAP port gets tagged with the specified VLAN ID, isolating traffic at Layer 2. + ### 📊 Telemetry & QoS - **Connection quality**: Smoothed RTT, jitter, min/max RTT, loss ratio, link health (`healthy` / `degraded` / `critical`) @@ -473,9 +529,9 @@ server.on('reconnected', () => { /* socket transport reconnected */ }); | Interface | Purpose | |-----------|---------| -| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge, clients, proxy protocol, destination policy) | +| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge/hybrid, clients, proxy protocol, destination policy) | | `IVpnClientConfig` | Client configuration (server URL, keys, transport, forwarding mode, WG options, client-defined tags) | -| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry) | +| `IClientEntry` | Server-side client definition (ID, keys, security, priority, server/client tags, expiry, bridge/VLAN settings) | | `IClientSecurity` | Per-client ACLs, rate limits, and destination policy override (SmartProxy-aligned naming) | | `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) | | `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets | diff --git a/rust/src/bridge.rs b/rust/src/bridge.rs index 528896c..108e4f8 100644 --- a/rust/src/bridge.rs +++ b/rust/src/bridge.rs @@ -225,6 +225,50 @@ pub async fn enable_proxy_arp(iface: &str) -> Result<()> { Ok(()) } +// ============================================================================ +// VLAN support (802.1Q via Linux bridge VLAN filtering) +// ============================================================================ + +async fn run_bridge_cmd(args: &[&str]) -> Result { + let output = tokio::process::Command::new("bridge") + .args(args) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("bridge {} failed: {}", args.join(" "), stderr.trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Enable VLAN filtering on a bridge. +pub async fn enable_vlan_filtering(bridge: &str) -> Result<()> { + run_ip_cmd(&["link", "set", bridge, "type", "bridge", "vlan_filtering", "1"]).await?; + info!("Enabled VLAN filtering on bridge {}", bridge); + Ok(()) +} + +/// Add a VLAN ID to a bridge port (TAP or physical interface). +/// `pvid` = set as port VLAN ID (untagged ingress), `untagged` = strip tag on egress. +pub async fn add_vlan_to_port(port: &str, vlan_id: u16, pvid: bool, untagged: bool) -> Result<()> { + let mut args = vec!["vlan", "add", "dev", port, "vid"]; + let vid_str = vlan_id.to_string(); + args.push(&vid_str); + if pvid { args.push("pvid"); } + if untagged { args.push("untagged"); } + run_bridge_cmd(&args).await?; + info!("Added VLAN {} to port {} (pvid={}, untagged={})", vlan_id, port, pvid, untagged); + Ok(()) +} + +/// Remove a VLAN ID from a bridge port. +pub async fn remove_vlan_from_port(port: &str, vlan_id: u16) -> Result<()> { + let vid_str = vlan_id.to_string(); + run_bridge_cmd(&["vlan", "del", "dev", port, "vid", &vid_str]).await?; + info!("Removed VLAN {} from port {}", vlan_id, port); + Ok(()) +} + /// Create a TAP device (L2) using the tun crate. pub fn create_tap(name: &str, mtu: u16) -> Result { let mut config = tun::Configuration::default(); diff --git a/rust/src/client_registry.rs b/rust/src/client_registry.rs index c90755b..91eb3e9 100644 --- a/rust/src/client_registry.rs +++ b/rust/src/client_registry.rs @@ -60,6 +60,19 @@ pub struct ClientEntry { pub expires_at: Option, /// Assigned VPN IP address. pub assigned_ip: Option, + + // Per-client bridge/host-IP settings + + /// If true, client gets a host network IP via bridge mode. + pub use_host_ip: Option, + /// If true and use_host_ip is true, obtain IP via DHCP relay. + pub use_dhcp: Option, + /// Static LAN IP when use_host_ip is true and use_dhcp is false. + pub static_ip: Option, + /// If true, assign this client to a specific 802.1Q VLAN. + pub force_vlan: Option, + /// 802.1Q VLAN ID (1-4094). + pub vlan_id: Option, } impl ClientEntry { @@ -236,6 +249,11 @@ mod tests { description: None, expires_at: None, assigned_ip: None, + use_host_ip: None, + use_dhcp: None, + static_ip: None, + force_vlan: None, + vlan_id: None, } } diff --git a/rust/src/server.rs b/rust/src/server.rs index 0854bca..8dbc759 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -161,6 +161,14 @@ pub enum ForwardingEngine { Socket(mpsc::Sender>), /// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN. Bridge(mpsc::Sender>), + /// Hybrid — both socket NAT and bridge engines running simultaneously. + /// Per-client routing: look up src_ip in routing_table to decide socket vs bridge. + Hybrid { + socket_tx: mpsc::Sender>, + bridge_tx: mpsc::Sender>, + /// Fast lookup: VPN IP → true if client uses bridge (host IP), false for socket. + routing_table: Arc>>, + }, /// Testing/monitoring — packets are counted but not forwarded. Testing, } @@ -246,6 +254,16 @@ impl VpnServer { tap_device: tun::AsyncDevice, shutdown_rx: mpsc::Receiver<()>, }, + Hybrid { + socket_tx: mpsc::Sender>, + socket_rx: mpsc::Receiver>, + socket_shutdown_rx: mpsc::Receiver<()>, + bridge_tx: mpsc::Sender>, + bridge_rx: mpsc::Receiver>, + bridge_shutdown_rx: mpsc::Receiver<()>, + tap_device: tun::AsyncDevice, + routing_table: Arc>>, + }, Testing, } @@ -296,6 +314,48 @@ impl VpnServer { let (tx, rx) = mpsc::channel::<()>(1); (ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx) } + "hybrid" => { + info!("Starting hybrid forwarding (socket + bridge, per-client routing)"); + + // Socket engine setup + let (s_tx, s_rx) = mpsc::channel::>(4096); + let (s_shut_tx, s_shut_rx) = mpsc::channel::<()>(1); + + // Bridge engine setup + let phys_iface = match &config.bridge_physical_interface { + Some(i) => i.clone(), + None => crate::bridge::detect_default_interface().await?, + }; + let (host_ip, host_prefix) = crate::bridge::get_interface_ip(&phys_iface).await?; + let bridge_name = "svpn_br0"; + let tap_name = "svpn_tap0"; + + let tap_device = crate::bridge::create_tap(tap_name, link_mtu)?; + crate::bridge::create_bridge(bridge_name).await?; + crate::bridge::set_interface_up(bridge_name).await?; + crate::bridge::bridge_add_interface(bridge_name, tap_name).await?; + crate::bridge::set_interface_up(tap_name).await?; + crate::bridge::bridge_add_interface(bridge_name, &phys_iface).await?; + crate::bridge::migrate_host_ip_to_bridge(&phys_iface, bridge_name, host_ip, host_prefix).await?; + crate::bridge::enable_proxy_arp(bridge_name).await?; + + let (b_tx, b_rx) = mpsc::channel::>(4096); + let (b_shut_tx, b_shut_rx) = mpsc::channel::<()>(1); + + // Build routing table from registered clients + let routing_table = Arc::new(RwLock::new(HashMap::::new())); + + info!("Hybrid mode: socket + bridge (TAP={}, physical={}, IP={}/{})", tap_name, phys_iface, host_ip, host_prefix); + + // We use s_shut_tx as the main shutdown (it will trigger both) + let _ = b_shut_tx; // bridge shutdown handled separately + let (tx, _) = mpsc::channel::<()>(1); + (ForwardingSetup::Hybrid { + socket_tx: s_tx, socket_rx: s_rx, socket_shutdown_rx: s_shut_rx, + bridge_tx: b_tx, bridge_rx: b_rx, bridge_shutdown_rx: b_shut_rx, + tap_device, routing_table, + }, tx) + } _ => { info!("Forwarding disabled (testing/monitoring mode)"); let (tx, _rx) = mpsc::channel::<()>(1); @@ -363,6 +423,51 @@ impl VpnServer { } }); } + ForwardingSetup::Hybrid { + socket_tx, socket_rx, socket_shutdown_rx, + bridge_tx, bridge_rx, bridge_shutdown_rx, + tap_device, routing_table, + } => { + // Populate routing table from registered clients + { + let registry = state.client_registry.read().await; + let mut rt = routing_table.write().await; + for entry in registry.list() { + if let Some(ref ip_str) = entry.assigned_ip { + if let Ok(ip) = ip_str.parse::() { + rt.insert(ip, entry.use_host_ip.unwrap_or(false)); + } + } + } + } + + // Start socket (NAT) engine + let proxy_protocol = config.socket_forward_proxy_protocol.unwrap_or(false); + let nat_engine = crate::userspace_nat::NatEngine::new( + gateway_ip, + link_mtu as usize, + state.clone(), + proxy_protocol, + config.destination_policy.clone(), + ); + tokio::spawn(async move { + if let Err(e) = nat_engine.run(socket_rx, socket_shutdown_rx).await { + error!("NAT engine error (hybrid): {}", e); + } + }); + + // Start bridge engine + let bridge_engine = crate::bridge::BridgeEngine::new(state.clone()); + tokio::spawn(async move { + if let Err(e) = bridge_engine.run(tap_device, bridge_rx, bridge_shutdown_rx).await { + error!("Bridge engine error (hybrid): {}", e); + } + }); + + *state.forwarding_engine.lock().await = ForwardingEngine::Hybrid { + socket_tx, bridge_tx, routing_table, + }; + } ForwardingSetup::Testing => {} } @@ -695,6 +800,11 @@ impl VpnServer { 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()), + use_host_ip: partial.get("useHostIp").and_then(|v| v.as_bool()), + use_dhcp: partial.get("useDhcp").and_then(|v| v.as_bool()), + static_ip: partial.get("staticIp").and_then(|v| v.as_str()).map(String::from), + force_vlan: partial.get("forceVlan").and_then(|v| v.as_bool()), + vlan_id: partial.get("vlanId").and_then(|v| v.as_u64()).map(|v| v as u16), }; // Add to registry @@ -1495,6 +1605,17 @@ async fn handle_client_connection( ForwardingEngine::Bridge(sender) => { let _ = sender.try_send(buf[..len].to_vec()); } + ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => { + if len >= 20 { + let src_ip = Ipv4Addr::new(buf[12], buf[13], buf[14], buf[15]); + let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false); + if use_bridge { + let _ = bridge_tx.try_send(buf[..len].to_vec()); + } else { + let _ = socket_tx.try_send(buf[..len].to_vec()); + } + } + } ForwardingEngine::Testing => {} } } diff --git a/rust/src/wireguard.rs b/rust/src/wireguard.rs index 514d8af..ac4b4b6 100644 --- a/rust/src/wireguard.rs +++ b/rust/src/wireguard.rs @@ -579,6 +579,17 @@ pub async fn run_wg_listener( ForwardingEngine::Bridge(sender) => { let _ = sender.try_send(packet.to_vec()); } + ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => { + if packet.len() >= 20 { + let src_ip = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false); + if use_bridge { + let _ = bridge_tx.try_send(packet.to_vec()); + } else { + let _ = socket_tx.try_send(packet.to_vec()); + } + } + } ForwardingEngine::Testing => {} } peer.stats.bytes_received += pkt_len; @@ -614,6 +625,17 @@ pub async fn run_wg_listener( ForwardingEngine::Bridge(sender) => { let _ = sender.try_send(packet.to_vec()); } + ForwardingEngine::Hybrid { socket_tx, bridge_tx, routing_table } => { + if packet.len() >= 20 { + let src_ip = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let use_bridge = routing_table.read().await.get(&src_ip).copied().unwrap_or(false); + if use_bridge { + let _ = bridge_tx.try_send(packet.to_vec()); + } else { + let _ = socket_tx.try_send(packet.to_vec()); + } + } + } ForwardingEngine::Testing => {} } peer.stats.bytes_received += pkt_len; diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 74d040a..ccf1909 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.18.0', + version: '1.19.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 84abda4..49ad225 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -92,8 +92,9 @@ export interface IVpnServerConfig { /** Enable NAT/masquerade for client traffic */ enableNat?: boolean; /** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT), + * 'bridge' (L2 bridge to host LAN), 'hybrid' (per-client socket+bridge), * or 'testing' (monitoring only). Default: 'testing'. */ - forwardingMode?: 'tun' | 'socket' | 'bridge' | 'testing'; + forwardingMode?: 'tun' | 'socket' | 'bridge' | 'hybrid' | 'testing'; /** Default rate limit for new clients (bytes/sec). Omit for unlimited. */ defaultRateLimitBytesPerSec?: number; /** Default burst size for new clients (bytes). Omit for unlimited. */ @@ -361,6 +362,21 @@ export interface IClientEntry { expiresAt?: string; /** Assigned VPN IP address (set by server) */ assignedIp?: string; + + // Per-client bridge/host-IP settings + + /** If true, client gets a host network IP via bridge mode (L2 to LAN). + * If false (default), client gets a VPN subnet IP via socket/NAT mode. */ + useHostIp?: boolean; + /** If true and useHostIp is true, obtain IP via DHCP relay. + * If false or omitted, use staticIp or auto-assign from bridge IP range. */ + useDhcp?: boolean; + /** Static LAN IP when useHostIp is true and useDhcp is false. */ + staticIp?: string; + /** If true, assign this client to a specific 802.1Q VLAN on the bridge. */ + forceVlan?: boolean; + /** 802.1Q VLAN ID (1-4094). Required when forceVlan is true. */ + vlanId?: number; } /**