feat(forwarding): add hybrid forwarding mode with per-client bridge and VLAN settings

This commit is contained in:
2026-04-01 03:47:26 +00:00
parent c49fcaf1ce
commit 180282ba86
8 changed files with 301 additions and 17 deletions

View File

@@ -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<String> {
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<tun::AsyncDevice> {
let mut config = tun::Configuration::default();

View File

@@ -60,6 +60,19 @@ pub struct ClientEntry {
pub expires_at: Option<String>,
/// Assigned VPN IP address.
pub assigned_ip: Option<String>,
// Per-client bridge/host-IP settings
/// If true, client gets a host network IP via bridge mode.
pub use_host_ip: Option<bool>,
/// If true and use_host_ip is true, obtain IP via DHCP relay.
pub use_dhcp: Option<bool>,
/// Static LAN IP when use_host_ip is true and use_dhcp is false.
pub static_ip: Option<String>,
/// If true, assign this client to a specific 802.1Q VLAN.
pub force_vlan: Option<bool>,
/// 802.1Q VLAN ID (1-4094).
pub vlan_id: Option<u16>,
}
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,
}
}

View File

@@ -161,6 +161,14 @@ pub enum ForwardingEngine {
Socket(mpsc::Sender<Vec<u8>>),
/// L2 Bridge — packets sent to BridgeEngine via channel, bridged to host LAN.
Bridge(mpsc::Sender<Vec<u8>>),
/// 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<Vec<u8>>,
bridge_tx: mpsc::Sender<Vec<u8>>,
/// Fast lookup: VPN IP → true if client uses bridge (host IP), false for socket.
routing_table: Arc<RwLock<HashMap<Ipv4Addr, bool>>>,
},
/// 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<Vec<u8>>,
socket_rx: mpsc::Receiver<Vec<u8>>,
socket_shutdown_rx: mpsc::Receiver<()>,
bridge_tx: mpsc::Sender<Vec<u8>>,
bridge_rx: mpsc::Receiver<Vec<u8>>,
bridge_shutdown_rx: mpsc::Receiver<()>,
tap_device: tun::AsyncDevice,
routing_table: Arc<RwLock<HashMap<Ipv4Addr, bool>>>,
},
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::<Vec<u8>>(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::<Vec<u8>>(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::<Ipv4Addr, bool>::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::<Ipv4Addr>() {
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 => {}
}
}

View File

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