feat(server): add bridge forwarding mode and per-client destination policy overrides
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-03-31 - 1.17.1 - fix(readme)
|
||||||
document per-transport metrics and handshake-driven WireGuard connection state
|
document per-transport metrics and handshake-driven WireGuard connection state
|
||||||
|
|
||||||
|
|||||||
41
readme.md
41
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
|
📊 **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
|
🔄 **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
|
📡 **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
|
🎯 **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
|
⚡ **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: '<server-noise-public-key-base64>',
|
publicKey: '<server-noise-public-key-base64>',
|
||||||
subnet: '10.8.0.0/24',
|
subnet: '10.8.0.0/24',
|
||||||
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
|
transportMode: 'all', // WebSocket + QUIC + WireGuard simultaneously (default)
|
||||||
forwardingMode: 'tun', // 'tun' (kernel), 'socket' (userspace NAT), or 'testing'
|
forwardingMode: 'tun', // 'tun' | 'socket' | 'bridge' | 'testing'
|
||||||
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
|
wgPrivateKey: '<server-wg-private-key-base64>', // required for WireGuard transport
|
||||||
enableNat: true,
|
enableNat: true,
|
||||||
dns: ['1.1.1.1', '8.8.8.8'],
|
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.
|
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
|
### 🔗 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):
|
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
|
### 📦 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 |
|
| Mode | Flag | Description | Root Required |
|
||||||
|------|------|-------------|---------------|
|
|------|------|-------------|---------------|
|
||||||
| **TUN** | `'tun'` | Kernel TUN device — real packet forwarding with system routing | ✅ Yes |
|
| **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 |
|
| **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 |
|
| **Testing** | `'testing'` | Monitoring only — packets are counted but not forwarded | ❌ No |
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -267,6 +284,16 @@ await server.start({
|
|||||||
enableNat: true,
|
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
|
// Client with TUN device
|
||||||
const { assignedIp } = await client.connect({
|
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
|
### 📊 Telemetry & QoS
|
||||||
|
|
||||||
@@ -444,10 +473,10 @@ server.on('reconnected', () => { /* socket transport reconnected */ });
|
|||||||
|
|
||||||
| Interface | Purpose |
|
| 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) |
|
| `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) |
|
||||||
| `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) |
|
| `IClientRateLimit` | Rate limiting config (bytesPerSec, burstBytes) |
|
||||||
| `IClientConfigBundle` | Full config bundle returned by `createClient()` — includes SmartVPN config, WireGuard .conf, and secrets |
|
| `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) |
|
| `IVpnClientInfo` | Connected client info (IP, stats, authenticated key, remote addr, transport type) |
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ mod tests {
|
|||||||
destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()),
|
destination_block_list: dst_block.map(|v| v.into_iter().map(String::from).collect()),
|
||||||
max_connections: None,
|
max_connections: None,
|
||||||
rate_limit: None,
|
rate_limit: None,
|
||||||
|
destination_policy: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
352
rust/src/bridge.rs
Normal file
352
rust/src/bridge.rs
Normal file
@@ -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<u8> {
|
||||||
|
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<Ipv4Addr> {
|
||||||
|
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<Ipv4Addr> {
|
||||||
|
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<u8> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<tun::AsyncDevice> {
|
||||||
|
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<ServerState>,
|
||||||
|
/// Learned MAC addresses for LAN peers (dst IP → MAC).
|
||||||
|
/// Populated from ARP replies and Ethernet frame src MACs.
|
||||||
|
arp_cache: HashMap<Ipv4Addr, [u8; 6]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeEngine {
|
||||||
|
pub fn new(state: Arc<ServerState>) -> 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<Vec<u8>>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,9 @@ pub struct ClientSecurity {
|
|||||||
pub max_connections: Option<u32>,
|
pub max_connections: Option<u32>,
|
||||||
/// Per-client rate limiting.
|
/// Per-client rate limiting.
|
||||||
pub rate_limit: Option<ClientRateLimit>,
|
pub rate_limit: Option<ClientRateLimit>,
|
||||||
|
/// Per-client destination routing policy override.
|
||||||
|
/// When set, overrides the server-level DestinationPolicy for this client's traffic.
|
||||||
|
pub destination_policy: Option<crate::server::DestinationPolicyConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A registered client entry — the server-side source of truth.
|
/// 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 {
|
pub struct ClientRegistry {
|
||||||
/// Primary index: clientId → ClientEntry
|
/// Primary index: clientId → ClientEntry
|
||||||
entries: HashMap<String, ClientEntry>,
|
entries: HashMap<String, ClientEntry>,
|
||||||
/// Secondary index: publicKey (base64) → clientId (fast lookup during handshake)
|
/// Secondary index: publicKey (base64) → clientId (fast lookup during handshake)
|
||||||
key_index: HashMap<String, String>,
|
key_index: HashMap<String, String>,
|
||||||
|
/// Tertiary index: assignedIp → clientId (fast lookup during NAT destination policy)
|
||||||
|
ip_index: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientRegistry {
|
impl ClientRegistry {
|
||||||
@@ -89,6 +94,7 @@ impl ClientRegistry {
|
|||||||
Self {
|
Self {
|
||||||
entries: HashMap::new(),
|
entries: HashMap::new(),
|
||||||
key_index: 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");
|
anyhow::bail!("Public key already registered to another client");
|
||||||
}
|
}
|
||||||
self.key_index.insert(entry.public_key.clone(), entry.client_id.clone());
|
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);
|
self.entries.insert(entry.client_id.clone(), entry);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -123,6 +132,9 @@ impl ClientRegistry {
|
|||||||
let entry = self.entries.remove(client_id)
|
let entry = self.entries.remove(client_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||||
self.key_index.remove(&entry.public_key);
|
self.key_index.remove(&entry.public_key);
|
||||||
|
if let Some(ref ip) = entry.assigned_ip {
|
||||||
|
self.ip_index.remove(ip);
|
||||||
|
}
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +149,12 @@ impl ClientRegistry {
|
|||||||
self.entries.get(client_id)
|
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).
|
/// Check if a public key is authorized (exists, enabled, not expired).
|
||||||
pub fn is_authorized(&self, public_key: &str) -> bool {
|
pub fn is_authorized(&self, public_key: &str) -> bool {
|
||||||
match self.get_by_key(public_key) {
|
match self.get_by_key(public_key) {
|
||||||
@@ -153,12 +171,22 @@ impl ClientRegistry {
|
|||||||
let entry = self.entries.get_mut(client_id)
|
let entry = self.entries.get_mut(client_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||||
let old_key = entry.public_key.clone();
|
let old_key = entry.public_key.clone();
|
||||||
|
let old_ip = entry.assigned_ip.clone();
|
||||||
updater(entry);
|
updater(entry);
|
||||||
// If public key changed, update the index
|
// If public key changed, update the key index
|
||||||
if entry.public_key != old_key {
|
if entry.public_key != old_key {
|
||||||
self.key_index.remove(&old_key);
|
self.key_index.remove(&old_key);
|
||||||
self.key_index.insert(entry.public_key.clone(), client_id.to_string());
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +390,7 @@ mod tests {
|
|||||||
bytes_per_sec: 1_000_000,
|
bytes_per_sec: 1_000_000,
|
||||||
burst_bytes: 2_000_000,
|
burst_bytes: 2_000_000,
|
||||||
}),
|
}),
|
||||||
|
destination_policy: None,
|
||||||
});
|
});
|
||||||
let mut reg = ClientRegistry::new();
|
let mut reg = ClientRegistry::new();
|
||||||
reg.add(entry).unwrap();
|
reg.add(entry).unwrap();
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ pub mod client_registry;
|
|||||||
pub mod acl;
|
pub mod acl;
|
||||||
pub mod proxy_protocol;
|
pub mod proxy_protocol;
|
||||||
pub mod userspace_nat;
|
pub mod userspace_nat;
|
||||||
|
pub mod bridge;
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct IpPool {
|
|||||||
allocated: HashMap<Ipv4Addr, String>,
|
allocated: HashMap<Ipv4Addr, String>,
|
||||||
/// Next candidate offset (skipping .0 network and .1 gateway)
|
/// Next candidate offset (skipping .0 network and .1 gateway)
|
||||||
next_offset: u32,
|
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 {
|
impl IpPool {
|
||||||
@@ -28,11 +32,47 @@ impl IpPool {
|
|||||||
anyhow::bail!("Prefix too long for VPN pool: /{}", prefix_len);
|
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 {
|
Ok(Self {
|
||||||
network,
|
network,
|
||||||
prefix_len,
|
prefix_len,
|
||||||
allocated: HashMap::new(),
|
allocated: HashMap::new(),
|
||||||
next_offset: 2, // Skip .0 (network) and .1 (server/gateway)
|
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<Self> {
|
||||||
|
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.
|
/// Total number of usable client addresses in the pool.
|
||||||
pub fn capacity(&self) -> u32 {
|
pub fn capacity(&self) -> u32 {
|
||||||
let host_bits = 32 - self.prefix_len as u32;
|
self.max_offset.saturating_sub(self.min_offset)
|
||||||
let total = 1u32 << host_bits;
|
|
||||||
total.saturating_sub(3) // minus network, gateway, broadcast
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allocate an IP for a client. Returns the assigned IP.
|
/// Allocate an IP for a client. Returns the assigned IP.
|
||||||
pub fn allocate(&mut self, client_id: &str) -> Result<Ipv4Addr> {
|
pub fn allocate(&mut self, client_id: &str) -> Result<Ipv4Addr> {
|
||||||
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
|
// Try to find a free IP starting from next_offset
|
||||||
let start = self.next_offset;
|
let start = self.next_offset;
|
||||||
let mut offset = start;
|
let mut offset = start;
|
||||||
loop {
|
loop {
|
||||||
if offset >= max_offset {
|
if offset >= self.max_offset {
|
||||||
offset = 2; // wrap around
|
offset = self.min_offset; // wrap around
|
||||||
}
|
}
|
||||||
|
|
||||||
let ip = Ipv4Addr::from(u32::from(self.network) + offset);
|
let ip = Ipv4Addr::from(u32::from(self.network) + offset);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use crate::tunnel::{self, TunConfig};
|
|||||||
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
|
const DEAD_PEER_TIMEOUT: Duration = Duration::from_secs(180);
|
||||||
|
|
||||||
/// Destination routing policy for VPN client traffic.
|
/// Destination routing policy for VPN client traffic.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct DestinationPolicyConfig {
|
pub struct DestinationPolicyConfig {
|
||||||
/// Default action: "forceTarget", "block", or "allow".
|
/// Default action: "forceTarget", "block", or "allow".
|
||||||
@@ -92,6 +92,17 @@ pub struct ServerConfig {
|
|||||||
/// Defaults to ["0.0.0.0/0"] (full tunnel).
|
/// Defaults to ["0.0.0.0/0"] (full tunnel).
|
||||||
#[serde(alias = "clientAllowedIPs")]
|
#[serde(alias = "clientAllowedIPs")]
|
||||||
pub client_allowed_ips: Option<Vec<String>>,
|
pub client_allowed_ips: Option<Vec<String>>,
|
||||||
|
|
||||||
|
// Bridge mode configuration (forwarding_mode: "bridge")
|
||||||
|
|
||||||
|
/// LAN subnet CIDR for bridge mode (e.g. "192.168.1.0/24").
|
||||||
|
pub bridge_lan_subnet: Option<String>,
|
||||||
|
/// Physical network interface to bridge (e.g. "eth0"). Auto-detected if omitted.
|
||||||
|
pub bridge_physical_interface: Option<String>,
|
||||||
|
/// Start of VPN client IP range within the LAN subnet (host offset, e.g. 200).
|
||||||
|
pub bridge_ip_range_start: Option<u32>,
|
||||||
|
/// End of VPN client IP range within the LAN subnet (host offset, e.g. 250).
|
||||||
|
pub bridge_ip_range_end: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information about a connected client.
|
/// Information about a connected client.
|
||||||
@@ -148,6 +159,8 @@ pub enum ForwardingEngine {
|
|||||||
Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
|
Tun(tokio::io::WriteHalf<tun::AsyncDevice>),
|
||||||
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
|
/// Userspace NAT — packets sent to smoltcp-based NAT engine via channel.
|
||||||
Socket(mpsc::Sender<Vec<u8>>),
|
Socket(mpsc::Sender<Vec<u8>>),
|
||||||
|
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
|
||||||
|
Bridge(mpsc::Sender<Vec<u8>>),
|
||||||
/// Testing/monitoring — packets are counted but not forwarded.
|
/// Testing/monitoring — packets are counted but not forwarded.
|
||||||
Testing,
|
Testing,
|
||||||
}
|
}
|
||||||
@@ -191,7 +204,15 @@ impl VpnServer {
|
|||||||
anyhow::bail!("Server is already running");
|
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 config.enable_nat.unwrap_or(false) {
|
||||||
if let Err(e) = crate::network::enable_ip_forwarding() {
|
if let Err(e) = crate::network::enable_ip_forwarding() {
|
||||||
@@ -205,7 +226,6 @@ impl VpnServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let link_mtu = config.mtu.unwrap_or(1420);
|
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();
|
let gateway_ip = ip_pool.gateway_addr();
|
||||||
|
|
||||||
// Create forwarding engine based on mode
|
// Create forwarding engine based on mode
|
||||||
@@ -220,6 +240,12 @@ impl VpnServer {
|
|||||||
packet_rx: mpsc::Receiver<Vec<u8>>,
|
packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
shutdown_rx: mpsc::Receiver<()>,
|
shutdown_rx: mpsc::Receiver<()>,
|
||||||
},
|
},
|
||||||
|
Bridge {
|
||||||
|
packet_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
tap_device: tun::AsyncDevice,
|
||||||
|
shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
},
|
||||||
Testing,
|
Testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +269,33 @@ impl VpnServer {
|
|||||||
let (tx, rx) = mpsc::channel::<()>(1);
|
let (tx, rx) = mpsc::channel::<()>(1);
|
||||||
(ForwardingSetup::Socket { packet_tx, packet_rx, shutdown_rx: rx }, tx)
|
(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::<Vec<u8>>(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)");
|
info!("Forwarding disabled (testing/monitoring mode)");
|
||||||
let (tx, _rx) = mpsc::channel::<()>(1);
|
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 => {}
|
ForwardingSetup::Testing => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1430,6 +1492,9 @@ async fn handle_client_connection(
|
|||||||
ForwardingEngine::Socket(sender) => {
|
ForwardingEngine::Socket(sender) => {
|
||||||
let _ = sender.try_send(buf[..len].to_vec());
|
let _ = sender.try_send(buf[..len].to_vec());
|
||||||
}
|
}
|
||||||
|
ForwardingEngine::Bridge(sender) => {
|
||||||
|
let _ = sender.try_send(buf[..len].to_vec());
|
||||||
|
}
|
||||||
ForwardingEngine::Testing => {}
|
ForwardingEngine::Testing => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,8 +267,19 @@ impl NatEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluate destination policy for a packet's destination IP.
|
/// Evaluate destination policy for a packet's destination IP.
|
||||||
fn evaluate_destination(&self, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction {
|
/// Checks per-client policy first (via src_ip → client registry lookup),
|
||||||
let policy = match &self.destination_policy {
|
/// 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,
|
Some(p) => p,
|
||||||
None => return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)),
|
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)
|
// Skip if session exists (including closing sessions — let FIN complete)
|
||||||
let session_exists = self.tcp_sessions.contains_key(&key);
|
let session_exists = self.tcp_sessions.contains_key(&key);
|
||||||
if is_syn && !session_exists {
|
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 => {
|
DestinationAction::Drop => {
|
||||||
debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||||
return;
|
return;
|
||||||
@@ -350,7 +361,7 @@ impl NatEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if !self.udp_sessions.contains_key(&key) {
|
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 => {
|
DestinationAction::Drop => {
|
||||||
debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -576,6 +576,9 @@ pub async fn run_wg_listener(
|
|||||||
ForwardingEngine::Socket(sender) => {
|
ForwardingEngine::Socket(sender) => {
|
||||||
let _ = sender.try_send(packet.to_vec());
|
let _ = sender.try_send(packet.to_vec());
|
||||||
}
|
}
|
||||||
|
ForwardingEngine::Bridge(sender) => {
|
||||||
|
let _ = sender.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
ForwardingEngine::Testing => {}
|
ForwardingEngine::Testing => {}
|
||||||
}
|
}
|
||||||
peer.stats.bytes_received += pkt_len;
|
peer.stats.bytes_received += pkt_len;
|
||||||
@@ -608,6 +611,9 @@ pub async fn run_wg_listener(
|
|||||||
ForwardingEngine::Socket(sender) => {
|
ForwardingEngine::Socket(sender) => {
|
||||||
let _ = sender.try_send(packet.to_vec());
|
let _ = sender.try_send(packet.to_vec());
|
||||||
}
|
}
|
||||||
|
ForwardingEngine::Bridge(sender) => {
|
||||||
|
let _ = sender.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
ForwardingEngine::Testing => {}
|
ForwardingEngine::Testing => {}
|
||||||
}
|
}
|
||||||
peer.stats.bytes_received += pkt_len;
|
peer.stats.bytes_received += pkt_len;
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
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'
|
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export interface IVpnServerConfig {
|
|||||||
enableNat?: boolean;
|
enableNat?: boolean;
|
||||||
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
|
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
|
||||||
* or 'testing' (monitoring only). Default: 'testing'. */
|
* 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. */
|
/** Default rate limit for new clients (bytes/sec). Omit for unlimited. */
|
||||||
defaultRateLimitBytesPerSec?: number;
|
defaultRateLimitBytesPerSec?: number;
|
||||||
/** Default burst size for new clients (bytes). Omit for unlimited. */
|
/** 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.
|
* 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. */
|
* Defaults to ['0.0.0.0/0'] (full tunnel). Set to e.g. ['10.8.0.0/24'] for split tunnel. */
|
||||||
clientAllowedIPs?: string[];
|
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;
|
maxConnections?: number;
|
||||||
/** Per-client rate limiting. */
|
/** Per-client rate limiting. */
|
||||||
rateLimit?: IClientRateLimit;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user