Compare commits

..

4 Commits

9 changed files with 2298 additions and 2868 deletions
+6
View File
@@ -26,6 +26,8 @@
]
},
"release": {
"targets": {
"npm": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
@@ -33,4 +35,8 @@
"accessLevel": "public"
}
}
},
"schemaVersion": 2
},
"@ship.zone/szci": {}
}
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## Pending
## 2026-05-12 - 1.19.3
### Fixes
- update release config schema, bump dependencies, and refresh runtime documentation (release)
- migrates .smartconfig.json release settings to the targets-based schema with schemaVersion 2
- bumps runtime and development dependencies including smartnftables and smartrust
- clarifies README details for custom Rust binaries, transport behavior, runtime events, and protocol frame types
## 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
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvpn",
"version": "1.19.1",
"version": "1.19.3",
"private": false,
"description": "A VPN solution with TypeScript control plane and Rust data plane daemon",
"type": "module",
@@ -29,16 +29,16 @@
],
"license": "MIT",
"dependencies": {
"@push.rocks/smartnftables": "1.1.0",
"@push.rocks/smartnftables": "1.2.0",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartrust": "^1.3.2"
"@push.rocks/smartrust": "^1.4.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tsrust": "^1.3.2",
"@git.zone/tstest": "^3.6.3",
"@types/node": "^25.5.0"
"@git.zone/tsbuild": "^4.4.1",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tsrust": "^1.3.4",
"@git.zone/tstest": "^3.6.6",
"@types/node": "^25.7.0"
},
"files": [
"ts/**/*",
+2134 -2808
View File
File diff suppressed because it is too large Load Diff
+18 -30
View File
@@ -24,11 +24,9 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
```bash
pnpm install @push.rocks/smartvpn
# or
npm install @push.rocks/smartvpn
```
The package ships with pre-compiled Rust binaries for **linux/amd64** and **linux/arm64**. No Rust toolchain required at runtime.
The package ships with pre-compiled Rust binaries for **linux/amd64** and **linux/arm64**. No Rust toolchain is required at runtime. Set `SMARTVPN_RUST_BINARY` if you want the TypeScript bridge to use a custom daemon binary.
## Architecture 🏗️
@@ -37,7 +35,7 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
│ TypeScript Control Plane │ ◄─────────────────────► │ Rust Data Plane Daemon │
│ │ stdio or Unix sock │ │
│ VpnServer / VpnClient │ │ Noise IK handshake │
│ Typed IPC commands │ │ XChaCha20-Poly1305
│ Typed IPC commands │ │ Noise transport encryption
│ Config validation │ │ WS + QUIC + WireGuard │
│ Hub: client management │ │ TUN device, IP pool, NAT │
│ WireGuard .conf generation │ │ Rate limiting, ACLs, QoS │
@@ -45,7 +43,7 @@ The package ships with pre-compiled Rust binaries for **linux/amd64** and **linu
└──────────────────────────────┘ └───────────────────────────────┘
```
**Split-plane design** — TypeScript handles orchestration, config, and DX; Rust handles every hot-path byte with zero-copy async I/O (tokio, mimalloc).
**Split-plane design** — TypeScript handles orchestration, config, and DX; Rust handles the hot path with async I/O, framed packet codecs, and the Noise transport state after authentication.
### IPC Transport Modes
@@ -142,7 +140,7 @@ Every client authenticates with a **Noise IK handshake** (`Noise_IK_25519_ChaCha
| **QUIC** | UDP (via quinn) | Low latency, datagram support for IP packets |
| **WireGuard** | UDP (via boringtun) | Standard WG clients (iOS, Android, wg-quick) |
The server runs **all three simultaneously** by default with `transportMode: 'all'`. All transports share the same unified forwarding pipeline (`ForwardingEngine`), IP pool, client registry, and stats — so WireGuard peers get the same userspace NAT, rate limiting, and monitoring as WS/QUIC clients. Clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS).
The server runs with `transportMode: 'all'` by default: WebSocket and QUIC are enabled, and WireGuard joins the same server when `wgPrivateKey` is configured. All server transports share the same forwarding pipeline (`ForwardingEngine`), IP pool, client registry, and statistics, so WireGuard peers can use the same userspace NAT, bridge/hybrid routing, and monitoring model as WS/QUIC clients. Native SmartVPN clients auto-negotiate with `transport: 'auto'` (tries QUIC first, falls back to WS).
### 📊 Per-Transport Metrics
@@ -481,37 +479,23 @@ const unit = VpnInstaller.generateServiceUnit({
You can also call `generateSystemdUnit()` or `generateLaunchdPlist()` directly for platform-specific options like custom descriptions.
### 📢 Events
### 📢 Runtime Events
Both `VpnServer` and `VpnClient` extend `EventEmitter` and emit typed events:
`VpnServer` and `VpnClient` extend `EventEmitter`. The high-level wrappers currently forward bridge lifecycle events:
```typescript
server.on('client-connected', (info: IVpnClientInfo) => {
console.log(`${info.registeredClientId} connected from ${info.remoteAddr} via ${info.transportType}`);
});
server.on('client-disconnected', ({ clientId, reason }) => {
console.log(`${clientId} disconnected: ${reason}`);
});
client.on('status', (status: IVpnStatus) => {
console.log(`State: ${status.state}, IP: ${status.assignedIp}`);
});
// Both server and client emit:
server.on('exit', ({ code, signal }) => { /* daemon process exited */ });
server.on('reconnected', () => { /* socket transport reconnected */ });
client.on('exit', ({ code, signal }) => { /* daemon process exited */ });
```
| Event | Emitted By | Payload |
|-------|-----------|---------|
| `status` | Both | `IVpnStatus` — connection state changes |
| `error` | Both | `{ message, code? }` |
| `client-connected` | Server | `IVpnClientInfo` — full client info including transport type |
| `client-disconnected` | Server | `{ clientId, reason? }` |
| `exit` | Both | `{ code, signal }` — daemon process exited |
| `reconnected` | Both | `void` — socket transport reconnected |
For connection state and telemetry, use `getStatus()`, `getStatistics()`, `listClients()`, and `getClientTelemetry()`.
## API Reference 📖
### Classes
@@ -541,7 +525,7 @@ server.on('reconnected', () => { /* socket transport reconnected */ });
| `IVpnMtuInfo` | TUN MTU, effective MTU, overhead bytes, oversized packet stats |
| `IVpnKeypair` | Base64-encoded public/private key pair |
| `IDestinationPolicy` | Destination routing policy (forceTarget / block / allow with allow/block lists) |
| `IVpnEventMap` | Typed event map for server and client EventEmitter |
| `IVpnEventMap` | Exported event payload shapes for lifecycle and daemon event integrations |
### Server IPC Commands
@@ -600,8 +584,8 @@ All transport modes share the same `forwardingMode` — WireGuard peers can use
// Explicit QUIC with certificate pinning
{ transport: 'quic', serverUrl: '1.2.3.4:4433', serverCertHash: '<sha256-base64>' }
// WireGuard
{ transport: 'wireguard', wgPrivateKey: '...', wgEndpoint: 'vpn.example.com:51820', ... }
// WireGuard clients use the standard .conf returned by createClient()
// or generated via WgConfigGenerator.
```
## Cryptography 🔑
@@ -610,7 +594,7 @@ All transport modes share the same `forwardingMode` — WireGuard peers can use
|-------|-----------|---------|
| **Handshake** | Noise IK (X25519 + ChaChaPoly + BLAKE2s) | Mutual authentication + key exchange |
| **Transport** | Noise transport state (ChaChaPoly) | All post-handshake data encryption |
| **Additional** | XChaCha20-Poly1305 | Extended nonce space for data-at-rest |
| **Utility** | XChaCha20-Poly1305 helper | Nonce-safe symmetric encryption helper in the Rust crypto module |
| **WireGuard** | X25519 + ChaCha20-Poly1305 (via boringtun) | Standard WireGuard crypto |
## Binary Protocol 📡
@@ -624,6 +608,9 @@ All frames use `[type:1B][length:4B][payload:NB]` with a 64KB max payload:
| IpPacket | `0x10` | Bidirectional | Encrypted tunnel data |
| Keepalive | `0x20` | Client → Server | App-level keepalive (not WS ping) |
| KeepaliveAck | `0x21` | Server → Client | Keepalive response with RTT payload |
| SessionResume | `0x30` | Client → Server | Session resume attempt |
| SessionResumeOk | `0x31` | Server → Client | Session resume accepted |
| SessionResumeErr | `0x32` | Server → Client | Session resume rejected |
| Disconnect | `0x3F` | Bidirectional | Graceful disconnect |
## Development 🛠️
@@ -674,6 +661,7 @@ smartvpn/
│ ├── transport_trait.rs # Transport abstraction (Sink/Stream)
│ ├── quic_transport.rs # QUIC transport
│ ├── wireguard.rs # WireGuard (boringtun)
│ ├── bridge.rs # Linux bridge/TAP integration
│ ├── codec.rs # Binary frame protocol
│ ├── keepalive.rs # Adaptive keepalives
│ ├── ratelimit.rs # Token bucket
@@ -691,7 +679,7 @@ smartvpn/
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license.md](./license.md) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+78 -8
View File
@@ -173,6 +173,14 @@ pub enum ForwardingEngine {
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.
pub struct ServerState {
pub config: ServerConfig,
@@ -189,6 +197,10 @@ pub struct ServerState {
pub tun_routes: RwLock<HashMap<Ipv4Addr, mpsc::Sender<Vec<u8>>>>,
/// Shutdown signal for the forwarding background task (TUN reader or NAT engine).
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.
@@ -267,6 +279,9 @@ impl VpnServer {
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 {
"tun" => {
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);
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 (tx, rx) = mpsc::channel::<()>(1);
(ForwardingSetup::Bridge { packet_tx, packet_rx, tap_device, shutdown_rx: rx }, tx)
@@ -319,7 +341,7 @@ impl VpnServer {
// Socket engine setup
let (s_tx, s_rx) = mpsc::channel::<Vec<u8>>(4096);
let (_s_shut_tx, s_shut_rx) = mpsc::channel::<()>(1);
let (s_shut_tx, s_shut_rx) = mpsc::channel::<()>(1);
// Bridge engine setup
let phys_iface = match &config.bridge_physical_interface {
@@ -347,14 +369,20 @@ impl VpnServer {
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);
bridge_cleanup_info = Some(BridgeCleanupInfo {
physical_iface: phys_iface,
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 {
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)
}, s_shut_tx)
}
_ => {
info!("Forwarding disabled (testing/monitoring mode)");
@@ -365,7 +393,7 @@ impl VpnServer {
// Compute effective MTU from 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
let registry = ClientRegistry::from_entries(
@@ -385,6 +413,8 @@ impl VpnServer {
forwarding_engine: Mutex::new(ForwardingEngine::Testing),
tun_routes: RwLock::new(HashMap::new()),
tun_shutdown: fwd_shutdown_tx,
bridge_shutdown: bridge_shut_tx,
bridge_cleanup: bridge_cleanup_info,
});
// Spawn the forwarding background task and set the engine
@@ -588,6 +618,43 @@ impl VpnServer {
let _ = state.tun_shutdown.send(()).await;
*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),
};
// Add to registry
state.client_registry.write().await.add(entry.clone())?;
// Add to registry — release IP on failure to avoid pool leak
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)
if self.wg_command_tx.is_some() {
+3 -1
View File
@@ -319,12 +319,14 @@ 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 {
if let IpAddr::V4(v4) = aip.addr {
if !v4.is_unspecified() {
return Some(v4);
}
}
}
None
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartvpn',
version: '1.19.1',
version: '1.19.3',
description: 'A VPN solution with TypeScript control plane and Rust data plane daemon'
}
+20 -2
View File
@@ -333,17 +333,35 @@ export class VpnServer extends plugins.events.EventEmitter {
/**
* Stop the daemon bridge.
*/
public stop(): void {
public async stop(): Promise<void> {
// Clean up nftables rules
if (this.nftHealthInterval) {
clearInterval(this.nftHealthInterval);
this.nftHealthInterval = undefined;
}
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;
}
// 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();
await exitPromise;
}
/**