Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8bdb991c8 | |||
| d4bad38908 | |||
| a293986d6d | |||
| 96a3159c5d |
15
changelog.md
15
changelog.md
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-04-06 - 1.19.2 - fix(server)
|
||||||
|
clean up bridge and hybrid shutdown handling
|
||||||
|
|
||||||
|
- persist bridge teardown metadata so stop() can restore host IP configuration and remove the bridge in bridge and hybrid modes
|
||||||
|
- use separate shutdown channels for hybrid socket and bridge engines to stop both forwarding paths correctly
|
||||||
|
- avoid IP pool leaks when client registration fails and ignore unspecified IPv4 addresses when selecting WireGuard peer addresses
|
||||||
|
- make daemon bridge stop await nftables cleanup and process exit, and cap effective tunnel MTU to the link MTU
|
||||||
|
|
||||||
|
## 2026-04-01 - 1.19.1 - fix(rust)
|
||||||
|
clean up unused Rust warnings in bridge, network, and server modules
|
||||||
|
|
||||||
|
- remove the unused error import from the bridge module
|
||||||
|
- mark IpPool.prefix_len as intentionally unused to suppress dead code warnings
|
||||||
|
- rename the unused socket shutdown sender binding in the server to an underscore-prefixed variable
|
||||||
|
|
||||||
## 2026-04-01 - 1.19.0 - feat(forwarding)
|
## 2026-04-01 - 1.19.0 - feat(forwarding)
|
||||||
add hybrid forwarding mode with per-client bridge and VLAN settings
|
add hybrid forwarding mode with per-client bridge and VLAN settings
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.19.0",
|
"version": "1.19.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"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",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::net::Ipv4Addr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::server::ServerState;
|
use crate::server::ServerState;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct IpPool {
|
|||||||
/// Network address (e.g., 10.8.0.0)
|
/// Network address (e.g., 10.8.0.0)
|
||||||
network: Ipv4Addr,
|
network: Ipv4Addr,
|
||||||
/// Prefix length (e.g., 24)
|
/// Prefix length (e.g., 24)
|
||||||
|
#[allow(dead_code)]
|
||||||
prefix_len: u8,
|
prefix_len: u8,
|
||||||
/// Allocated IPs: IP -> client_id
|
/// Allocated IPs: IP -> client_id
|
||||||
allocated: HashMap<Ipv4Addr, String>,
|
allocated: HashMap<Ipv4Addr, String>,
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ pub enum ForwardingEngine {
|
|||||||
Testing,
|
Testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Info needed to tear down bridge infrastructure on stop().
|
||||||
|
pub struct BridgeCleanupInfo {
|
||||||
|
pub physical_iface: String,
|
||||||
|
pub bridge_name: String,
|
||||||
|
pub host_ip: Ipv4Addr,
|
||||||
|
pub host_prefix: u8,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared server state.
|
/// Shared server state.
|
||||||
pub struct ServerState {
|
pub struct ServerState {
|
||||||
pub config: ServerConfig,
|
pub config: ServerConfig,
|
||||||
@@ -189,6 +197,10 @@ pub struct ServerState {
|
|||||||
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
|
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
|
||||||
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
|
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
|
||||||
pub tun_shutdown: mpsc::Sender<()>,
|
pub tun_shutdown: mpsc::Sender<()>,
|
||||||
|
/// Shutdown signal for the bridge engine (bridge/hybrid modes only).
|
||||||
|
pub bridge_shutdown: Option<mpsc::Sender<()>>,
|
||||||
|
/// Bridge teardown info (bridge/hybrid modes only).
|
||||||
|
pub bridge_cleanup: Option<BridgeCleanupInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The VPN server.
|
/// The VPN server.
|
||||||
@@ -267,6 +279,9 @@ impl VpnServer {
|
|||||||
Testing,
|
Testing,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut bridge_cleanup_info: Option<BridgeCleanupInfo> = None;
|
||||||
|
let mut bridge_shut_tx: Option<mpsc::Sender<()>> = None;
|
||||||
|
|
||||||
let (setup, fwd_shutdown_tx) = match mode {
|
let (setup, fwd_shutdown_tx) = match mode {
|
||||||
"tun" => {
|
"tun" => {
|
||||||
let tun_config = TunConfig {
|
let tun_config = TunConfig {
|
||||||
@@ -310,6 +325,13 @@ impl VpnServer {
|
|||||||
|
|
||||||
info!("Bridge {} created: TAP={}, physical={}, IP={}/{}", bridge_name, tap_name, phys_iface, host_ip, host_prefix);
|
info!("Bridge {} created: TAP={}, physical={}, IP={}/{}", bridge_name, tap_name, phys_iface, host_ip, host_prefix);
|
||||||
|
|
||||||
|
bridge_cleanup_info = Some(BridgeCleanupInfo {
|
||||||
|
physical_iface: phys_iface,
|
||||||
|
bridge_name: bridge_name.to_string(),
|
||||||
|
host_ip,
|
||||||
|
host_prefix,
|
||||||
|
});
|
||||||
|
|
||||||
let (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
|
let (packet_tx, packet_rx) = mpsc::channel::<Vec<u8>>(4096);
|
||||||
let (tx, rx) = mpsc::channel::<()>(1);
|
let (tx, rx) = mpsc::channel::<()>(1);
|
||||||
(ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx)
|
(ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx)
|
||||||
@@ -347,14 +369,20 @@ impl VpnServer {
|
|||||||
|
|
||||||
info!("Hybrid mode: socket + bridge (TAP={}, physical={}, IP={}/{})", tap_name, phys_iface, host_ip, host_prefix);
|
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)
|
bridge_cleanup_info = Some(BridgeCleanupInfo {
|
||||||
let _ = b_shut_tx; // bridge shutdown handled separately
|
physical_iface: phys_iface,
|
||||||
let (tx, _) = mpsc::channel::<()>(1);
|
bridge_name: bridge_name.to_string(),
|
||||||
|
host_ip,
|
||||||
|
host_prefix,
|
||||||
|
});
|
||||||
|
bridge_shut_tx = Some(b_shut_tx);
|
||||||
|
|
||||||
|
// Socket engine uses fwd_shutdown_tx (stored in state.tun_shutdown)
|
||||||
(ForwardingSetup::Hybrid {
|
(ForwardingSetup::Hybrid {
|
||||||
socket_tx: s_tx, socket_rx: s_rx, socket_shutdown_rx: s_shut_rx,
|
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,
|
bridge_tx: b_tx, bridge_rx: b_rx, bridge_shutdown_rx: b_shut_rx,
|
||||||
tap_device, routing_table,
|
tap_device, routing_table,
|
||||||
}, tx)
|
}, s_shut_tx)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
info!("Forwarding disabled (testing/monitoring mode)");
|
info!("Forwarding disabled (testing/monitoring mode)");
|
||||||
@@ -365,7 +393,7 @@ impl VpnServer {
|
|||||||
|
|
||||||
// Compute effective MTU from overhead
|
// Compute effective MTU from overhead
|
||||||
let overhead = TunnelOverhead::default_overhead();
|
let overhead = TunnelOverhead::default_overhead();
|
||||||
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).max(link_mtu));
|
let mtu_config = MtuConfig::new(overhead.effective_tun_mtu(1500).min(link_mtu));
|
||||||
|
|
||||||
// Build client registry from config
|
// Build client registry from config
|
||||||
let registry = ClientRegistry::from_entries(
|
let registry = ClientRegistry::from_entries(
|
||||||
@@ -385,6 +413,8 @@ impl VpnServer {
|
|||||||
forwarding_engine: Mutex::new(ForwardingEngine::Testing),
|
forwarding_engine: Mutex::new(ForwardingEngine::Testing),
|
||||||
tun_routes: RwLock::new(HashMap::new()),
|
tun_routes: RwLock::new(HashMap::new()),
|
||||||
tun_shutdown: fwd_shutdown_tx,
|
tun_shutdown: fwd_shutdown_tx,
|
||||||
|
bridge_shutdown: bridge_shut_tx,
|
||||||
|
bridge_cleanup: bridge_cleanup_info,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn the forwarding background task and set the engine
|
// Spawn the forwarding background task and set the engine
|
||||||
@@ -588,6 +618,43 @@ impl VpnServer {
|
|||||||
let _ = state.tun_shutdown.send(()).await;
|
let _ = state.tun_shutdown.send(()).await;
|
||||||
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
||||||
}
|
}
|
||||||
|
"bridge" => {
|
||||||
|
let _ = state.tun_shutdown.send(()).await;
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
||||||
|
// Restore host networking: move IP back and remove bridge
|
||||||
|
if let Some(ref cleanup) = state.bridge_cleanup {
|
||||||
|
if let Err(e) = crate::bridge::restore_host_ip(
|
||||||
|
&cleanup.physical_iface, &cleanup.bridge_name,
|
||||||
|
cleanup.host_ip, cleanup.host_prefix,
|
||||||
|
).await {
|
||||||
|
warn!("Failed to restore host IP: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::bridge::remove_bridge(&cleanup.bridge_name).await {
|
||||||
|
warn!("Failed to remove bridge: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"hybrid" => {
|
||||||
|
// Shut down socket (NAT) engine
|
||||||
|
let _ = state.tun_shutdown.send(()).await;
|
||||||
|
// Shut down bridge engine
|
||||||
|
if let Some(ref bridge_shut) = state.bridge_shutdown {
|
||||||
|
let _ = bridge_shut.send(()).await;
|
||||||
|
}
|
||||||
|
*state.forwarding_engine.lock().await = ForwardingEngine::Testing;
|
||||||
|
// Restore host networking: move IP back and remove bridge
|
||||||
|
if let Some(ref cleanup) = state.bridge_cleanup {
|
||||||
|
if let Err(e) = crate::bridge::restore_host_ip(
|
||||||
|
&cleanup.physical_iface, &cleanup.bridge_name,
|
||||||
|
cleanup.host_ip, cleanup.host_prefix,
|
||||||
|
).await {
|
||||||
|
warn!("Failed to restore host IP: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = crate::bridge::remove_bridge(&cleanup.bridge_name).await {
|
||||||
|
warn!("Failed to remove bridge: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,8 +874,11 @@ impl VpnServer {
|
|||||||
vlan_id: partial.get("vlanId").and_then(|v| v.as_u64()).map(|v| v as u16),
|
vlan_id: partial.get("vlanId").and_then(|v| v.as_u64()).map(|v| v as u16),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to registry
|
// Add to registry — release IP on failure to avoid pool leak
|
||||||
state.client_registry.write().await.add(entry.clone())?;
|
if let Err(e) = state.client_registry.write().await.add(entry.clone()) {
|
||||||
|
state.ip_pool.lock().await.release(&assigned_ip);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Register WG peer with the running WG listener (if active)
|
// Register WG peer with the running WG listener (if active)
|
||||||
if self.wg_command_tx.is_some() {
|
if self.wg_command_tx.is_some() {
|
||||||
|
|||||||
@@ -319,10 +319,12 @@ fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: use the first IPv4 address from any prefix length
|
// Fallback: use the first non-unspecified IPv4 address from any prefix length
|
||||||
for aip in allowed_ips {
|
for aip in allowed_ips {
|
||||||
if let IpAddr::V4(v4) = aip.addr {
|
if let IpAddr::V4(v4) = aip.addr {
|
||||||
return Some(v4);
|
if !v4.is_unspecified() {
|
||||||
|
return Some(v4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.19.0',
|
version: '1.19.2',
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,17 +333,35 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Stop the daemon bridge.
|
* Stop the daemon bridge.
|
||||||
*/
|
*/
|
||||||
public stop(): void {
|
public async stop(): Promise<void> {
|
||||||
// Clean up nftables rules
|
// Clean up nftables rules
|
||||||
if (this.nftHealthInterval) {
|
if (this.nftHealthInterval) {
|
||||||
clearInterval(this.nftHealthInterval);
|
clearInterval(this.nftHealthInterval);
|
||||||
this.nftHealthInterval = undefined;
|
this.nftHealthInterval = undefined;
|
||||||
}
|
}
|
||||||
if (this.nft) {
|
if (this.nft) {
|
||||||
this.nft.cleanup().catch(() => {}); // best-effort cleanup
|
try {
|
||||||
|
await this.nft.cleanup();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[smartvpn] nftables cleanup failed: ${e}`);
|
||||||
|
}
|
||||||
this.nft = undefined;
|
this.nft = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for bridge process to exit (with timeout)
|
||||||
|
const exitPromise = new Promise<void>((resolve) => {
|
||||||
|
if (!this.bridge.running) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => resolve(), 5000);
|
||||||
|
this.bridge.once('exit', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
this.bridge.stop();
|
this.bridge.stop();
|
||||||
|
await exitPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user