feat(forwarding): add hybrid forwarding mode with per-client bridge and VLAN settings
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user