From fdeba5eeb5c259e789c60590a9e627f585be4eea Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 31 Mar 2026 21:34:49 +0000 Subject: [PATCH] feat(server): add bridge forwarding mode and per-client destination policy overrides --- changelog.md | 9 + readme.md | 41 ++++- rust/src/acl.rs | 1 + rust/src/bridge.rs | 352 ++++++++++++++++++++++++++++++++++++ rust/src/client_registry.rs | 33 +++- rust/src/lib.rs | 1 + rust/src/network.rs | 51 +++++- rust/src/server.rs | 71 +++++++- rust/src/userspace_nat.rs | 19 +- rust/src/wireguard.rs | 6 + ts/00_commitinfo_data.ts | 2 +- ts/smartvpn.interfaces.ts | 22 ++- 12 files changed, 583 insertions(+), 25 deletions(-) create mode 100644 rust/src/bridge.rs diff --git a/changelog.md b/changelog.md index e4219e8..03c9b7d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-31 - 1.18.0 - feat(server) +add bridge forwarding mode and per-client destination policy overrides + +- introduces Linux bridge-based forwarding so VPN clients can receive IPs from a LAN subnet via TAP/bridge integration +- adds bridge server configuration options for LAN subnet, physical interface, and client IP allocation range +- adds per-client destinationPolicy overrides in the client registry and applies them in the userspace NAT engine based on assigned tunnel IP +- extends IP pool allocation to support constrained address ranges needed for bridge mode +- updates TypeScript interfaces and documentation to cover bridge mode and per-client destination policy behavior + ## 2026-03-31 - 1.17.1 - fix(readme) document per-transport metrics and handshake-driven WireGuard connection state diff --git a/readme.md b/readme.md index b1fdbff..f94887e 100644 --- a/readme.md +++ b/readme.md @@ -9,7 +9,8 @@ A high-performance VPN solution with a **TypeScript control plane** and a **Rust 📊 **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), or testing mode +🌐 **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 @@ -84,7 +85,7 @@ await server.start({ publicKey: '', subnet: '10.8.0.0/24', transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default) - forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing' + forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'testing' wgPrivateKey: '', // required for WireGuard transport enableNat: true, dns: ['1.1.1.1', '8.8.8.8'], @@ -237,6 +238,21 @@ In **TUN mode**, destination policies are enforced via **nftables** rules (using In **socket mode**, the policy is evaluated in the userspace NAT engine before per-client ACLs. +**Per-client override** — individual clients can have their own destination policy that overrides the server-level default: + +```typescript +await server.createClient({ + clientId: 'restricted-client', + security: { + destinationPolicy: { + default: 'block', // block everything by default + allowList: ['10.0.0.0/8'], // except internal network + }, + // ... other security settings + }, +}); +``` + ### 🔗 Socket Forward Proxy Protocol When using `forwardingMode: 'socket'` (userspace NAT), you can prepend **PROXY protocol v2 headers** on outbound TCP connections. This conveys the VPN client's tunnel IP as the source address to downstream services (e.g., SmartProxy): @@ -251,12 +267,13 @@ await server.start({ ### 📦 Packet Forwarding Modes -SmartVPN supports three forwarding modes, configurable per-server and per-client: +SmartVPN supports four forwarding modes, configurable per-server and per-client: | 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 | | **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No | ```typescript @@ -267,6 +284,16 @@ await server.start({ enableNat: true, }); +// Server with bridge mode — VPN clients appear on the LAN +await server.start({ + // ... + forwardingMode: 'bridge', + bridgeLanSubnet: '192.168.1.0/24', // LAN subnet to bridge into + bridgePhysicalInterface: 'eth0', // auto-detected if omitted + bridgeIpRangeStart: 200, // clients get .200–.250 (defaults) + bridgeIpRangeEnd: 250, +}); + // Client with TUN device const { assignedIp } = await client.connect({ // ... @@ -274,7 +301,9 @@ const { assignedIp } = await client.connect({ }); ``` -The userspace NAT mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring `CAP_NET_ADMIN` or root privileges. +The **userspace NAT** mode extracts destination IP/port from IP packets, opens a real socket to the destination, and relays data — supporting both TCP streams and UDP datagrams without requiring `CAP_NET_ADMIN` or root privileges. + +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. ### 📊 Telemetry & QoS @@ -444,10 +473,10 @@ server.on('reconnected', () => { /* socket transport reconnected */ }); | Interface | Purpose | |-----------|---------| -| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode, clients, proxy protocol, destination policy) | +| `IVpnServerConfig` | Server configuration (listen addr, keys, subnet, transport mode, forwarding mode incl. bridge, 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) | -| `IClientSecurity` | Per-client ACLs and rate limits (SmartProxy-aligned naming) | +| `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 | | `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr, transport type) | diff --git a/rust/src/acl.rs b/rust/src/acl.rs index cee853d..adaf62c 100644 --- a/rust/src/acl.rs +++ b/rust/src/acl.rs @@ -164,6 +164,7 @@ mod tests { destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()), max_connections: None, rate_limit: None, + destination_policy: None, } } diff --git a/rust/src/bridge.rs b/rust/src/bridge.rs new file mode 100644 index 0000000..528896c --- /dev/null +++ b/rust/src/bridge.rs @@ -0,0 +1,352 @@ +//! L2 Bridge forwarding engine. +//! +//! Provides server-side bridging: receives L3 IP packets from VPN clients, +//! wraps them in Ethernet frames, and injects them into a Linux bridge +//! connected to the host's physical network interface. +//! +//! Return traffic from the bridge is stripped of its Ethernet header and +//! routed back to VPN clients via `tun_routes`. + +use anyhow::Result; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::server::ServerState; + +/// Configuration for the bridge forwarding engine. +pub struct BridgeConfig { + /// TAP device name (e.g., "svpn_tap0") + pub tap_name: String, + /// Linux bridge name (e.g., "svpn_br0") + pub bridge_name: String, + /// Physical interface to bridge (e.g., "eth0") + pub physical_interface: String, + /// Gateway IP on the bridge (host's LAN IP) + pub gateway_ip: Ipv4Addr, + /// Subnet prefix length (e.g., 24) + pub prefix_len: u8, + /// MTU for the TAP device + pub mtu: u16, +} + +/// Ethernet frame constants +const ETH_HEADER_LEN: usize = 14; +const ETH_TYPE_IPV4: [u8; 2] = [0x08, 0x00]; +const ETH_TYPE_ARP: [u8; 2] = [0x08, 0x06]; +const BROADCAST_MAC: [u8; 6] = [0xff; 6]; + +/// Generate a deterministic locally-administered MAC from an IPv4 address. +/// Uses prefix 02:53:56 (locally administered, "SVP" in hex-ish). +fn mac_from_ip(ip: Ipv4Addr) -> [u8; 6] { + let octets = ip.octets(); + [0x02, 0x53, 0x56, octets[1], octets[2], octets[3]] +} + +/// Wrap an IP packet in an Ethernet frame. +fn wrap_in_ethernet(ip_packet: &[u8], src_mac: [u8; 6], dst_mac: [u8; 6]) -> Vec { + let mut frame = Vec::with_capacity(ETH_HEADER_LEN + ip_packet.len()); + frame.extend_from_slice(&dst_mac); + frame.extend_from_slice(&src_mac); + frame.extend_from_slice(Ð_TYPE_IPV4); + frame.extend_from_slice(ip_packet); + frame +} + +/// Extract the EtherType and payload from an Ethernet frame. +fn unwrap_ethernet(frame: &[u8]) -> Option<([u8; 2], &[u8])> { + if frame.len() < ETH_HEADER_LEN { + return None; + } + let ether_type = [frame[12], frame[13]]; + Some((ether_type, &frame[ETH_HEADER_LEN..])) +} + +/// Extract destination IPv4 from a raw IP packet header. +fn dst_ip_from_packet(packet: &[u8]) -> Option { + if packet.len() < 20 { + return None; + } + // Version must be 4 + if (packet[0] >> 4) != 4 { + return None; + } + Some(Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19])) +} + +/// Extract source IPv4 from a raw IP packet header. +fn src_ip_from_packet(packet: &[u8]) -> Option { + if packet.len() < 20 { + return None; + } + if (packet[0] >> 4) != 4 { + return None; + } + Some(Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15])) +} + +/// Build a gratuitous ARP announcement frame. +fn build_garp(ip: Ipv4Addr, mac: [u8; 6]) -> Vec { + let ip_bytes = ip.octets(); + let mut frame = Vec::with_capacity(42); // 14 eth + 28 ARP + // Ethernet header + frame.extend_from_slice(&BROADCAST_MAC); // dst: broadcast + frame.extend_from_slice(&mac); // src: our MAC + frame.extend_from_slice(Ð_TYPE_ARP); // EtherType: ARP + // ARP payload + frame.extend_from_slice(&[0x00, 0x01]); // Hardware type: Ethernet + frame.extend_from_slice(&[0x08, 0x00]); // Protocol type: IPv4 + frame.push(6); // Hardware addr len + frame.push(4); // Protocol addr len + frame.extend_from_slice(&[0x00, 0x01]); // Operation: ARP Request (GARP uses request) + frame.extend_from_slice(&mac); // Sender hardware addr + frame.extend_from_slice(&ip_bytes); // Sender protocol addr + frame.extend_from_slice(&[0x00; 6]); // Target hardware addr (ignored in GARP) + frame.extend_from_slice(&ip_bytes); // Target protocol addr (same as sender for GARP) + frame +} + +// ============================================================================ +// Linux bridge management (ip commands) +// ============================================================================ + +async fn run_ip_cmd(args: &[&str]) -> Result { + let output = tokio::process::Command::new("ip") + .args(args) + .output() + .await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("ip {} failed: {}", args.join(" "), stderr.trim()); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Create a Linux bridge interface. +pub async fn create_bridge(name: &str) -> Result<()> { + run_ip_cmd(&["link", "add", name, "type", "bridge"]).await?; + info!("Created bridge {}", name); + Ok(()) +} + +/// Add an interface to a bridge. +pub async fn bridge_add_interface(bridge: &str, iface: &str) -> Result<()> { + run_ip_cmd(&["link", "set", iface, "master", bridge]).await?; + info!("Added {} to bridge {}", iface, bridge); + Ok(()) +} + +/// Bring an interface up. +pub async fn set_interface_up(iface: &str) -> Result<()> { + run_ip_cmd(&["link", "set", iface, "up"]).await?; + Ok(()) +} + +/// Remove a bridge interface. +pub async fn remove_bridge(name: &str) -> Result<()> { + // First bring it down, ignore errors + let _ = run_ip_cmd(&["link", "set", name, "down"]).await; + run_ip_cmd(&["link", "del", name]).await?; + info!("Removed bridge {}", name); + Ok(()) +} + +/// Detect the default network interface from the routing table. +pub async fn detect_default_interface() -> Result { + let output = run_ip_cmd(&["route", "show", "default"]).await?; + // Format: "default via X.X.X.X dev IFACE ..." + let parts: Vec<&str> = output.split_whitespace().collect(); + if let Some(idx) = parts.iter().position(|&s| s == "dev") { + if let Some(iface) = parts.get(idx + 1) { + return Ok(iface.to_string()); + } + } + anyhow::bail!("Could not detect default network interface from route table"); +} + +/// Get the IP address and prefix length of a network interface. +pub async fn get_interface_ip(iface: &str) -> Result<(Ipv4Addr, u8)> { + let output = run_ip_cmd(&["-4", "addr", "show", "dev", iface]).await?; + // Parse "inet X.X.X.X/NN" from output + for line in output.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("inet ") { + let addr_cidr = rest.split_whitespace().next().unwrap_or(""); + let parts: Vec<&str> = addr_cidr.split('/').collect(); + if parts.len() == 2 { + let ip: Ipv4Addr = parts[0].parse()?; + let prefix: u8 = parts[1].parse()?; + return Ok((ip, prefix)); + } + } + } + anyhow::bail!("Could not find IPv4 address on interface {}", iface); +} + +/// Migrate the host's IP from a physical interface to a bridge. +/// This is the most delicate operation — briefly interrupts connectivity. +pub async fn migrate_host_ip_to_bridge( + physical_iface: &str, + bridge: &str, + ip: Ipv4Addr, + prefix: u8, +) -> Result<()> { + let cidr = format!("{}/{}", ip, prefix); + // Remove IP from physical interface + let _ = run_ip_cmd(&["addr", "del", &cidr, "dev", physical_iface]).await; + // Add IP to bridge + run_ip_cmd(&["addr", "add", &cidr, "dev", bridge]).await?; + info!("Migrated IP {} from {} to {}", cidr, physical_iface, bridge); + Ok(()) +} + +/// Restore the host's IP from bridge back to the physical interface. +pub async fn restore_host_ip( + physical_iface: &str, + bridge: &str, + ip: Ipv4Addr, + prefix: u8, +) -> Result<()> { + let cidr = format!("{}/{}", ip, prefix); + let _ = run_ip_cmd(&["addr", "del", &cidr, "dev", bridge]).await; + run_ip_cmd(&["addr", "add", &cidr, "dev", physical_iface]).await?; + info!("Restored IP {} to {}", cidr, physical_iface); + Ok(()) +} + +/// Enable proxy ARP on an interface via sysctl. +pub async fn enable_proxy_arp(iface: &str) -> Result<()> { + let path = format!("/proc/sys/net/ipv4/conf/{}/proxy_arp", iface); + tokio::fs::write(&path, "1").await?; + info!("Enabled proxy_arp on {}", iface); + 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(); + config + .tun_name(name) + .layer(tun::Layer::L2) + .mtu(mtu) + .up(); + + #[cfg(target_os = "linux")] + config.platform_config(|p| { + p.ensure_root_privileges(true); + }); + + let device = tun::create_as_async(&config)?; + info!("TAP device {} created (L2, mtu={})", name, mtu); + Ok(device) +} + +// ============================================================================ +// BridgeEngine — main event loop +// ============================================================================ + +/// The BridgeEngine wraps/unwraps Ethernet frames and bridges VPN traffic +/// to the host's physical LAN via a Linux bridge + TAP device. +pub struct BridgeEngine { + state: Arc, + /// Learned MAC addresses for LAN peers (dst IP → MAC). + /// Populated from ARP replies and Ethernet frame src MACs. + arp_cache: HashMap, +} + +impl BridgeEngine { + pub fn new(state: Arc) -> Self { + Self { + state, + arp_cache: HashMap::new(), + } + } + + /// Run the bridge engine event loop. + /// Receives L3 IP packets from VPN clients, wraps in Ethernet, writes to TAP. + /// Reads Ethernet frames from TAP, strips header, routes back to VPN clients. + pub async fn run( + mut self, + mut tap_device: tun::AsyncDevice, + mut packet_rx: mpsc::Receiver>, + mut shutdown_rx: mpsc::Receiver<()>, + ) -> Result<()> { + let mut buf = vec![0u8; 2048]; + + info!("BridgeEngine started"); + + loop { + tokio::select! { + // Packet from VPN client → wrap in Ethernet → write to TAP + Some(ip_packet) = packet_rx.recv() => { + if let Some(dst_ip) = dst_ip_from_packet(&ip_packet) { + let src_ip = src_ip_from_packet(&ip_packet).unwrap_or(Ipv4Addr::UNSPECIFIED); + let src_mac = mac_from_ip(src_ip); + let dst_mac = self.arp_cache.get(&dst_ip) + .copied() + .unwrap_or(BROADCAST_MAC); + let frame = wrap_in_ethernet(&ip_packet, src_mac, dst_mac); + if let Err(e) = tap_device.write_all(&frame).await { + warn!("TAP write error: {}", e); + } + } + } + + // Frame from TAP (LAN) → strip Ethernet → route to VPN client + result = tap_device.read(&mut buf) => { + match result { + Ok(len) if len >= ETH_HEADER_LEN => { + let frame = &buf[..len]; + + // Learn src MAC from incoming frames + if let Some((ether_type, payload)) = unwrap_ethernet(frame) { + // Learn ARP cache from src MAC + src IP + let src_mac: [u8; 6] = frame[6..12].try_into().unwrap_or([0; 6]); + if ether_type == ETH_TYPE_IPV4 { + if let Some(src_ip) = src_ip_from_packet(payload) { + self.arp_cache.insert(src_ip, src_mac); + } + } + + // Only forward IPv4 packets to VPN clients + if ether_type == ETH_TYPE_IPV4 { + if let Some(dst_ip) = dst_ip_from_packet(payload) { + // Look up VPN client by dst IP in tun_routes + let routes = self.state.tun_routes.read().await; + if let Some(sender) = routes.get(&dst_ip) { + let _ = sender.try_send(payload.to_vec()); + } + } + } + } + } + Ok(_) => {} // Frame too short, ignore + Err(e) => { + warn!("TAP read error: {}", e); + } + } + } + + _ = shutdown_rx.recv() => { + info!("BridgeEngine shutting down"); + break; + } + } + } + + Ok(()) + } + + /// Send a gratuitous ARP for a VPN client IP. + pub async fn announce_client(tap: &mut tun::AsyncDevice, ip: Ipv4Addr) -> Result<()> { + let mac = mac_from_ip(ip); + let garp = build_garp(ip, mac); + tap.write_all(&garp).await?; + debug!("Sent GARP for {} (MAC {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x})", + ip, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Ok(()) + } +} diff --git a/rust/src/client_registry.rs b/rust/src/client_registry.rs index 113e8a3..c90755b 100644 --- a/rust/src/client_registry.rs +++ b/rust/src/client_registry.rs @@ -26,6 +26,9 @@ pub struct ClientSecurity { pub max_connections: Option, /// Per-client rate limiting. pub rate_limit: Option, + /// Per-client destination routing policy override. + /// When set, overrides the server-level DestinationPolicy for this client's traffic. + pub destination_policy: Option, } /// A registered client entry — the server-side source of truth. @@ -76,12 +79,14 @@ impl ClientEntry { } } -/// In-memory client registry with dual-key indexing. +/// In-memory client registry with triple-key indexing. pub struct ClientRegistry { /// Primary index: clientId → ClientEntry entries: HashMap, /// Secondary index: publicKey (base64) → clientId (fast lookup during handshake) key_index: HashMap, + /// Tertiary index: assignedIp → clientId (fast lookup during NAT destination policy) + ip_index: HashMap, } impl ClientRegistry { @@ -89,6 +94,7 @@ impl ClientRegistry { Self { entries: HashMap::new(), key_index: HashMap::new(), + ip_index: HashMap::new(), } } @@ -114,6 +120,9 @@ impl ClientRegistry { anyhow::bail!("Public key already registered to another client"); } self.key_index.insert(entry.public_key.clone(), entry.client_id.clone()); + if let Some(ref ip) = entry.assigned_ip { + self.ip_index.insert(ip.clone(), entry.client_id.clone()); + } self.entries.insert(entry.client_id.clone(), entry); Ok(()) } @@ -123,6 +132,9 @@ impl ClientRegistry { let entry = self.entries.remove(client_id) .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; self.key_index.remove(&entry.public_key); + if let Some(ref ip) = entry.assigned_ip { + self.ip_index.remove(ip); + } Ok(entry) } @@ -137,6 +149,12 @@ impl ClientRegistry { self.entries.get(client_id) } + /// Get a client by assigned IP (used for per-client destination policy in NAT engine). + pub fn get_by_assigned_ip(&self, ip: &str) -> Option<&ClientEntry> { + let client_id = self.ip_index.get(ip)?; + self.entries.get(client_id) + } + /// Check if a public key is authorized (exists, enabled, not expired). pub fn is_authorized(&self, public_key: &str) -> bool { match self.get_by_key(public_key) { @@ -153,12 +171,22 @@ impl ClientRegistry { let entry = self.entries.get_mut(client_id) .ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?; let old_key = entry.public_key.clone(); + let old_ip = entry.assigned_ip.clone(); updater(entry); - // If public key changed, update the index + // If public key changed, update the key index if entry.public_key != old_key { self.key_index.remove(&old_key); self.key_index.insert(entry.public_key.clone(), client_id.to_string()); } + // If assigned IP changed, update the IP index + if entry.assigned_ip != old_ip { + if let Some(ref old) = old_ip { + self.ip_index.remove(old); + } + if let Some(ref new_ip) = entry.assigned_ip { + self.ip_index.insert(new_ip.clone(), client_id.to_string()); + } + } Ok(()) } @@ -362,6 +390,7 @@ mod tests { bytes_per_sec: 1_000_000, burst_bytes: 2_000_000, }), + destination_policy: None, }); let mut reg = ClientRegistry::new(); reg.add(entry).unwrap(); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index da8932b..1618c1f 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -22,3 +22,4 @@ pub mod client_registry; pub mod acl; pub mod proxy_protocol; pub mod userspace_nat; +pub mod bridge; diff --git a/rust/src/network.rs b/rust/src/network.rs index b611ba2..d7e0c8c 100644 --- a/rust/src/network.rs +++ b/rust/src/network.rs @@ -13,6 +13,10 @@ pub struct IpPool { allocated: HashMap, /// Next candidate offset (skipping .0 network and .1 gateway) next_offset: u32, + /// Minimum allocation offset (inclusive). Default: 2 (skip .0 network and .1 gateway). + min_offset: u32, + /// Maximum allocation offset (exclusive). Default: broadcast offset. + max_offset: u32, } impl IpPool { @@ -28,11 +32,47 @@ impl IpPool { anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len); } + let host_bits = 32 - prefix_len as u32; + let max_offset = (1u32 << host_bits) - 1; // broadcast offset + Ok(Self { network, prefix_len, allocated: HashMap::new(), next_offset: 2, // Skip .0 (network) and .1 (server/gateway) + min_offset: 2, + max_offset, + }) + } + + /// Create a new IP pool with a restricted allocation range within the subnet. + /// `range_start` and `range_end` are host offsets (e.g., 200 and 250 for .200-.250). + pub fn new_with_range(subnet: &str, range_start: u32, range_end: u32) -> Result { + let parts: Vec<&str> = subnet.split('/').collect(); + if parts.len() != 2 { + anyhow::bail!("Invalid subnet format: {}", subnet); + } + let network: Ipv4Addr = parts[0].parse()?; + let prefix_len: u8 = parts[1].parse()?; + if prefix_len > 30 { + anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len); + } + if range_start >= range_end { + anyhow::bail!("Invalid IP range: start ({}) must be less than end ({})", range_start, range_end); + } + let host_bits = 32 - prefix_len as u32; + let broadcast_offset = (1u32 << host_bits) - 1; + if range_end > broadcast_offset { + anyhow::bail!("IP range end ({}) exceeds subnet broadcast ({})", range_end, broadcast_offset); + } + + Ok(Self { + network, + prefix_len, + allocated: HashMap::new(), + next_offset: range_start, + min_offset: range_start, + max_offset: range_end + 1, // exclusive }) } @@ -44,22 +84,17 @@ impl IpPool { /// Total number of usable client addresses in the pool. pub fn capacity(&self) -> u32 { - let host_bits = 32 - self.prefix_len as u32; - let total = 1u32 << host_bits; - total.saturating_sub(3) // minus network, gateway, broadcast + self.max_offset.saturating_sub(self.min_offset) } /// Allocate an IP for a client. Returns the assigned IP. pub fn allocate(&mut self, client_id: &str) -> Result { - let host_bits = 32 - self.prefix_len as u32; - let max_offset = (1u32 << host_bits) - 1; // broadcast offset - // Try to find a free IP starting from next_offset let start = self.next_offset; let mut offset = start; loop { - if offset >= max_offset { - offset = 2; // wrap around + if offset >= self.max_offset { + offset = self.min_offset; // wrap around } let ip = Ipv4Addr::from(u32::from(self.network) + offset); diff --git a/rust/src/server.rs b/rust/src/server.rs index 311cc2d..0854bca 100644 --- a/rust/src/server.rs +++ b/rust/src/server.rs @@ -25,7 +25,7 @@ use crate::tunnel::{self, TunConfig}; const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180); /// Destination routing policy for VPN client traffic. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct DestinationPolicyConfig { /// Default action: "forceTarget", "block", or "allow". @@ -92,6 +92,17 @@ pub struct ServerConfig { /// Defaults to ["0.0.0.0/0"] (full tunnel). #[serde(alias = "clientAllowedIPs")] pub client_allowed_ips: Option>, + + // Bridge mode configuration (forwarding_mode: "bridge") + + /// LAN subnet CIDR for bridge mode (e.g. "192.168.1.0/24"). + pub bridge_lan_subnet: Option, + /// Physical network interface to bridge (e.g. "eth0"). Auto-detected if omitted. + pub bridge_physical_interface: Option, + /// Start of VPN client IP range within the LAN subnet (host offset, e.g. 200). + pub bridge_ip_range_start: Option, + /// End of VPN client IP range within the LAN subnet (host offset, e.g. 250). + pub bridge_ip_range_end: Option, } /// Information about a connected client. @@ -148,6 +159,8 @@ pub enum ForwardingEngine { Tun(tokio::io::WriteHalf), /// Userspace NAT — packets sent to smoltcp-based NAT engine via channel. Socket(mpsc::Sender>), + /// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN. + Bridge(mpsc::Sender>), /// Testing/monitoring — packets are counted but not forwarded. Testing, } @@ -191,7 +204,15 @@ impl VpnServer { anyhow::bail!("Server is already running"); } - let ip_pool = IpPool::new(&config.subnet)?; + let mode = config.forwarding_mode.as_deref().unwrap_or("testing"); + let ip_pool = if mode == "bridge" { + let lan_subnet = config.bridge_lan_subnet.as_deref().unwrap_or(&config.subnet); + let range_start = config.bridge_ip_range_start.unwrap_or(200); + let range_end = config.bridge_ip_range_end.unwrap_or(250); + IpPool::new_with_range(lan_subnet, range_start, range_end)? + } else { + IpPool::new(&config.subnet)? + }; if config.enable_nat.unwrap_or(false) { if let Err(e) = crate::network::enable_ip_forwarding() { @@ -205,7 +226,6 @@ impl VpnServer { } let link_mtu = config.mtu.unwrap_or(1420); - let mode = config.forwarding_mode.as_deref().unwrap_or("testing"); let gateway_ip = ip_pool.gateway_addr(); // Create forwarding engine based on mode @@ -220,6 +240,12 @@ impl VpnServer { packet_rx: mpsc::Receiver>, shutdown_rx: mpsc::Receiver<()>, }, + Bridge { + packet_tx: mpsc::Sender>, + packet_rx: mpsc::Receiver>, + tap_device: tun::AsyncDevice, + shutdown_rx: mpsc::Receiver<()>, + }, Testing, } @@ -243,6 +269,33 @@ impl VpnServer { let (tx, rx) = mpsc::channel::<()>(1); (ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx) } + "bridge" => { + info!("Starting L2 bridge forwarding (requires CAP_NET_ADMIN)"); + 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"; + + // Create TAP + bridge infrastructure + 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?; + + info!("Bridge {} created: TAP={}, physical={}, IP={}/{}", bridge_name, tap_name, phys_iface, host_ip, host_prefix); + + let (packet_tx, packet_rx) = mpsc::channel::>(4096); + let (tx, rx) = mpsc::channel::<()>(1); + (ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx) + } _ => { info!("Forwarding disabled (testing/monitoring mode)"); let (tx, _rx) = mpsc::channel::<()>(1); @@ -301,6 +354,15 @@ impl VpnServer { } }); } + ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx } => { + *state.forwarding_engine.lock().await = ForwardingEngine::Bridge(packet_tx); + let bridge_engine = crate::bridge::BridgeEngine::new(state.clone()); + tokio::spawn(async move { + if let Err(e) = bridge_engine.run(tap_device, packet_rx, shutdown_rx).await { + error!("Bridge engine error: {}", e); + } + }); + } ForwardingSetup::Testing => {} } @@ -1430,6 +1492,9 @@ async fn handle_client_connection( ForwardingEngine::Socket(sender) => { let _ = sender.try_send(buf[..len].to_vec()); } + ForwardingEngine::Bridge(sender) => { + let _ = sender.try_send(buf[..len].to_vec()); + } ForwardingEngine::Testing => {} } } diff --git a/rust/src/userspace_nat.rs b/rust/src/userspace_nat.rs index f931a0b..f5fa587 100644 --- a/rust/src/userspace_nat.rs +++ b/rust/src/userspace_nat.rs @@ -267,8 +267,19 @@ impl NatEngine { } /// 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 { + /// Checks per-client policy first (via src_ip → client registry lookup), + /// falls back to server-wide policy. + fn evaluate_destination(&self, src_ip: Ipv4Addr, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction { + // Try per-client destination policy (lookup by tunnel IP) + let client_policy = if let Ok(registry) = self.state.client_registry.try_read() { + registry.get_by_assigned_ip(&src_ip.to_string()) + .and_then(|e| e.security.as_ref()) + .and_then(|s| s.destination_policy.clone()) + } else { + None + }; + + let policy = match client_policy.as_ref().or(self.destination_policy.as_ref()) { Some(p) => p, None => return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)), }; @@ -326,7 +337,7 @@ impl NatEngine { // Skip if session exists (including closing sessions — let FIN complete) let session_exists = self.tcp_sessions.contains_key(&key); if is_syn && !session_exists { - match self.evaluate_destination(dst_ip, dst_port) { + match self.evaluate_destination(src_ip, dst_ip, dst_port) { DestinationAction::Drop => { debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port); return; @@ -350,7 +361,7 @@ impl NatEngine { }; if !self.udp_sessions.contains_key(&key) { - match self.evaluate_destination(dst_ip, dst_port) { + match self.evaluate_destination(src_ip, dst_ip, dst_port) { DestinationAction::Drop => { debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port); return; diff --git a/rust/src/wireguard.rs b/rust/src/wireguard.rs index 899069a..514d8af 100644 --- a/rust/src/wireguard.rs +++ b/rust/src/wireguard.rs @@ -576,6 +576,9 @@ pub async fn run_wg_listener( ForwardingEngine::Socket(sender) => { let _ = sender.try_send(packet.to_vec()); } + ForwardingEngine::Bridge(sender) => { + let _ = sender.try_send(packet.to_vec()); + } ForwardingEngine::Testing => {} } peer.stats.bytes_received += pkt_len; @@ -608,6 +611,9 @@ pub async fn run_wg_listener( ForwardingEngine::Socket(sender) => { let _ = sender.try_send(packet.to_vec()); } + ForwardingEngine::Bridge(sender) => { + let _ = sender.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 ed4874a..74d040a 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.17.1', + version: '1.18.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 f990cdc..84abda4 100644 --- a/ts/smartvpn.interfaces.ts +++ b/ts/smartvpn.interfaces.ts @@ -93,7 +93,7 @@ export interface IVpnServerConfig { enableNat?: boolean; /** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT), * or 'testing' (monitoring only). Default: 'testing'. */ - forwardingMode?: 'tun' | 'socket' | 'testing'; + forwardingMode?: 'tun' | 'socket' | 'bridge' | 'testing'; /** Default rate limit for new clients (bytes/sec). Omit for unlimited. */ defaultRateLimitBytesPerSec?: number; /** Default burst size for new clients (bytes). Omit for unlimited. */ @@ -137,6 +137,22 @@ export interface IVpnServerConfig { * Controls what traffic the client routes through the VPN tunnel. * Defaults to ['0.0.0.0/0'] (full tunnel). Set to e.g. ['10.8.0.0/24'] for split tunnel. */ clientAllowedIPs?: string[]; + + // Bridge mode configuration (forwardingMode: 'bridge') + + /** LAN subnet CIDR for bridge mode (e.g. '192.168.1.0/24'). + * VPN clients get IPs from this subnet instead of the VPN subnet. + * Required when forwardingMode is 'bridge'. */ + bridgeLanSubnet?: string; + /** Physical network interface to bridge (e.g. 'eth0'). + * Auto-detected from the default route if omitted. */ + bridgePhysicalInterface?: string; + /** Start of VPN client IP range within the LAN subnet (host offset, e.g. 200 for .200). + * Default: 200. */ + bridgeIpRangeStart?: number; + /** End of VPN client IP range within the LAN subnet (host offset, e.g. 250 for .250). + * Default: 250. */ + bridgeIpRangeEnd?: number; } /** @@ -310,6 +326,10 @@ export interface IClientSecurity { maxConnections?: number; /** Per-client rate limiting. */ rateLimit?: IClientRateLimit; + /** Per-client destination routing policy override. + * When set, overrides the server-level destinationPolicy for this client's traffic. + * Supports the same options: forceTarget, block, allow with allow/block lists. */ + destinationPolicy?: IDestinationPolicy; } /**