Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfa91fd419 | |||
| 8eb26e1920 | |||
| e513f8686b | |||
| e06667b298 | |||
| c3afb83470 | |||
| 2d7a507cf2 | |||
| a757a4bb73 | |||
| 5bf21ab4ac | |||
| af46dc9b39 | |||
| 79d9928485 | |||
| 70e838c8ff | |||
| dbcfdb1fb6 | |||
| c97beed6e0 | |||
| c3cc237db5 | |||
| 17c27a92d6 | |||
| 9d105e8034 | |||
| e9cf575271 | |||
| 229db4be38 | |||
| e31086d0c2 | |||
| 01a0d8b9f4 | |||
| 187a69028b |
76
changelog.md
76
changelog.md
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.15.0 - feat(vpnserver)
|
||||||
|
add nftables-backed destination policy enforcement for TUN mode
|
||||||
|
|
||||||
|
- add @push.rocks/smartnftables dependency and export it through the plugin layer
|
||||||
|
- apply destination policy rules via nftables when starting the server in TUN mode
|
||||||
|
- add periodic nftables health checks and best-effort cleanup on server stop
|
||||||
|
- update documentation for destination routing policy, socket transport mode, trusted client tags, events, and service generation
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.14.0 - feat(nat)
|
||||||
|
add destination routing policy support for socket-mode VPN traffic
|
||||||
|
|
||||||
|
- introduce configurable destinationPolicy settings in server and TypeScript interfaces
|
||||||
|
- apply allow, block, and forceTarget routing decisions when creating TCP and UDP NAT sessions
|
||||||
|
- export ACL IP matching helper for destination policy evaluation
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.13.0 - feat(client-registry)
|
||||||
|
separate trusted server-defined client tags from client-reported tags with legacy tag compatibility
|
||||||
|
|
||||||
|
- Adds distinct serverDefinedClientTags and clientDefinedClientTags fields to client registry and TypeScript interfaces.
|
||||||
|
- Treats legacy tags values as serverDefinedClientTags during deserialization and server-side create/update flows for backward compatibility.
|
||||||
|
- Clarifies that only server-defined tags are trusted for access control while client-defined tags are informational only.
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.12.0 - feat(server)
|
||||||
|
add optional PROXY protocol v2 headers for socket-based userspace NAT forwarding
|
||||||
|
|
||||||
|
- introduce a socketForwardProxyProtocol server option in Rust and TypeScript interfaces
|
||||||
|
- pass the new setting into the userspace NAT engine and TCP bridge tasks
|
||||||
|
- prepend PROXY protocol v2 headers on outbound TCP connections when socket forwarding is enabled
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.11.0 - feat(server)
|
||||||
|
unify WireGuard into the shared server transport pipeline
|
||||||
|
|
||||||
|
- add integrated WireGuard server support to VpnServer with shared startup, shutdown, status, statistics, and peer management
|
||||||
|
- introduce transportMode 'all' as the default and add server config support for wgPrivateKey, wgListenPort, and preconfigured peers
|
||||||
|
- register WireGuard peers in the shared client registry and IP pool so they use the same forwarding engine, routing, and monitoring as WebSocket and QUIC clients
|
||||||
|
- expose transportType in server client info and update TypeScript interfaces and documentation to reflect unified multi-transport forwarding
|
||||||
|
|
||||||
|
## 2026-03-30 - 1.10.2 - fix(client)
|
||||||
|
wait for the connection task to shut down cleanly before disconnecting and increase test timeout
|
||||||
|
|
||||||
|
- store the spawned client connection task handle and await it during disconnect with a 5 second timeout so the disconnect frame can be sent before closing
|
||||||
|
- increase the test script timeout from 60 seconds to 90 seconds to reduce flaky test runs
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.10.1 - fix(test, docs, scripts)
|
||||||
|
correct test command verbosity, shorten load test timings, and document forwarding modes
|
||||||
|
|
||||||
|
- Fixes the test script by removing the duplicated verbose flag in package.json.
|
||||||
|
- Reduces load test delays and burst sizes to keep keepalive and connection tests faster and more stable.
|
||||||
|
- Updates the README to describe forwardingMode options, userspace NAT support, and related configuration examples.
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.10.0 - feat(rust-server, rust-client, ts-interfaces)
|
||||||
|
add configurable packet forwarding with TUN and userspace NAT modes
|
||||||
|
|
||||||
|
- introduce forwardingMode options for client and server configuration interfaces
|
||||||
|
- add server-side forwarding engines for kernel TUN, userspace socket NAT, and testing mode
|
||||||
|
- add a smoltcp-based userspace NAT implementation for packet forwarding without root-only TUN routing
|
||||||
|
- enable client-side TUN forwarding support with route setup, packet I/O, and cleanup
|
||||||
|
- centralize raw packet destination IP extraction in tunnel utilities for shared routing logic
|
||||||
|
- update test command timeout and logging flags
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.9.0 - feat(server)
|
||||||
|
add PROXY protocol v2 support for real client IP handling and connection ACLs
|
||||||
|
|
||||||
|
- add PROXY protocol v2 parsing for WebSocket connections, including IPv4/IPv6 support, LOCAL command handling, and header read timeout protection
|
||||||
|
- apply server-level connection IP block lists before the Noise handshake and enforce per-client source IP allow/block lists using the resolved remote address
|
||||||
|
- expose proxy protocol configuration and remote client address fields in Rust and TypeScript interfaces, and document reverse-proxy usage in the README
|
||||||
|
|
||||||
|
## 2026-03-29 - 1.8.0 - feat(auth,client-registry)
|
||||||
|
add Noise IK client authentication with managed client registry and per-client ACL controls
|
||||||
|
|
||||||
|
- switch the native tunnel handshake from Noise NK to Noise IK and require client keypairs in client configuration
|
||||||
|
- add server-side client registry management APIs for creating, updating, disabling, rotating, listing, and exporting client configs
|
||||||
|
- enforce client authorization from the registry during handshake and expose authenticated client metadata in server client info
|
||||||
|
- introduce per-client security policies with source/destination ACLs and per-client rate limit settings
|
||||||
|
- add Rust ACL matching support for exact IPs, CIDR ranges, wildcards, and IP ranges with test coverage
|
||||||
|
|
||||||
## 2026-03-29 - 1.7.0 - feat(rust-tests)
|
## 2026-03-29 - 1.7.0 - feat(rust-tests)
|
||||||
add end-to-end WireGuard UDP integration tests and align TypeScript build configuration
|
add end-to-end WireGuard UDP integration tests and align TypeScript build configuration
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@push.rocks/smartvpn",
|
"name": "@push.rocks/smartvpn",
|
||||||
"version": "1.7.0",
|
"version": "1.15.0",
|
||||||
"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",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "(tsbuild tsfolders) && (tsrust)",
|
"build": "(tsbuild tsfolders) && (tsrust)",
|
||||||
"test:before": "(tsrust)",
|
"test:before": "(tsrust)",
|
||||||
"test": "tstest test/ --verbose",
|
"test": "tstest test/ --verbose --logfile --timeout 90",
|
||||||
"buildDocs": "tsdoc"
|
"buildDocs": "tsdoc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@push.rocks/smartnftables": "1.1.0",
|
||||||
"@push.rocks/smartpath": "^6.0.0",
|
"@push.rocks/smartpath": "^6.0.0",
|
||||||
"@push.rocks/smartrust": "^1.3.2"
|
"@push.rocks/smartrust": "^1.3.2"
|
||||||
},
|
},
|
||||||
|
|||||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@push.rocks/smartnftables':
|
||||||
|
specifier: 1.1.0
|
||||||
|
version: 1.1.0
|
||||||
'@push.rocks/smartpath':
|
'@push.rocks/smartpath':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -1132,6 +1135,9 @@ packages:
|
|||||||
'@push.rocks/smartnetwork@4.5.2':
|
'@push.rocks/smartnetwork@4.5.2':
|
||||||
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
resolution: {integrity: sha512-lbMMyc2f/WWd5+qzZyF1ynXndjCtasxPWmj/d8GUuis9rDrW7sLIT1PlAPC2F6Qsy4H/K32JrYU+01d/6sWObg==}
|
||||||
|
|
||||||
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
|
resolution: {integrity: sha512-7JNzerlW20HEl2wKMBIHltwneCQRpXiD2lJkXZZc02ctnfjgFejXVDIeWomhPx6PZ0Z6zmqdF6rrFDtDHyqqfA==}
|
||||||
|
|
||||||
'@push.rocks/smartnpm@2.0.6':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
resolution: {integrity: sha512-7anKDOjX6gXWs1IAc+YWz9ZZ8gDsTwaLh+CxRnGHjAawOmK788NrrgVCg2Fb3qojrPnoxecc46F8Ivp1BT7Izw==}
|
||||||
|
|
||||||
@@ -5335,6 +5341,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@push.rocks/smartnftables@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/smartlog': 3.2.1
|
||||||
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
|
|
||||||
'@push.rocks/smartnpm@2.0.6':
|
'@push.rocks/smartnpm@2.0.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/consolecolor': 2.0.3
|
'@push.rocks/consolecolor': 2.0.3
|
||||||
|
|||||||
253
readme.plan.md
Normal file
253
readme.plan.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# PROXY Protocol v2 Support for SmartVPN WebSocket Transport
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
SmartVPN's WebSocket transport is designed to sit behind reverse proxies (Cloudflare, HAProxy, SmartProxy). The recently added ACL engine has `ipAllowList`/`ipBlockList` per client, but without PROXY protocol support the server only sees the proxy's IP — not the real client's. This makes source-IP ACLs useless behind a proxy.
|
||||||
|
|
||||||
|
PROXY protocol v2 solves this by letting the proxy prepend a binary header with the real client IP/port before the WebSocket upgrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Two-Phase ACL with Real Client IP
|
||||||
|
|
||||||
|
```
|
||||||
|
TCP accept → Read PP v2 header → Extract real IP
|
||||||
|
│
|
||||||
|
├─ Phase 1 (pre-handshake): Check server-level connectionIpBlockList → reject early
|
||||||
|
│
|
||||||
|
├─ WebSocket upgrade → Noise IK handshake → Client identity known
|
||||||
|
│
|
||||||
|
└─ Phase 2 (post-handshake): Check per-client ipAllowList/ipBlockList → reject if denied
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Phase 1**: Server-wide block list (`connectionIpBlockList` on `IVpnServerConfig`). Rejects before any crypto work. Protects server resources.
|
||||||
|
- **Phase 2**: Per-client ACL from `IClientSecurity.ipAllowList`/`ipBlockList`. Applied after the Noise IK handshake identifies the client.
|
||||||
|
|
||||||
|
### No New Dependencies
|
||||||
|
|
||||||
|
PROXY protocol v2 is a fixed-format binary header (16-byte signature + variable address block). Manual parsing (~80 lines) follows the same pattern as `codec.rs`. No crate needed.
|
||||||
|
|
||||||
|
### Scope: WebSocket Only
|
||||||
|
|
||||||
|
- **WebSocket**: Needs PP v2 (sits behind reverse proxies)
|
||||||
|
- **QUIC**: Direct UDP, just use `conn.remote_address()`
|
||||||
|
- **WireGuard**: Direct UDP, uses boringtun peer tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase 1: New Rust module `proxy_protocol.rs`
|
||||||
|
|
||||||
|
**New file: `rust/src/proxy_protocol.rs`**
|
||||||
|
|
||||||
|
PP v2 binary format:
|
||||||
|
```
|
||||||
|
Bytes 0-11: Signature \x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A
|
||||||
|
Byte 12: Version (high nibble = 0x2) | Command (low nibble: 0x0=LOCAL, 0x1=PROXY)
|
||||||
|
Byte 13: Address family | Protocol (0x11 = IPv4/TCP, 0x21 = IPv6/TCP)
|
||||||
|
Bytes 14-15: Address data length (big-endian u16)
|
||||||
|
Bytes 16+: IPv4: 4 src_ip + 4 dst_ip + 2 src_port + 2 dst_port (12 bytes)
|
||||||
|
IPv6: 16 src_ip + 16 dst_ip + 2 src_port + 2 dst_port (36 bytes)
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ProxyHeader {
|
||||||
|
pub src_addr: SocketAddr,
|
||||||
|
pub dst_addr: SocketAddr,
|
||||||
|
pub is_local: bool, // LOCAL command = health check probe
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse a PROXY protocol v2 header from a TCP stream.
|
||||||
|
/// Reads exactly the header bytes — the stream is clean for WS upgrade after.
|
||||||
|
pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 5-second timeout on header read (constant `PROXY_HEADER_TIMEOUT`)
|
||||||
|
- Validates 12-byte signature, version nibble, command type
|
||||||
|
- Parses IPv4 and IPv6 address blocks
|
||||||
|
- LOCAL command returns `is_local: true` (caller closes connection gracefully)
|
||||||
|
- Unit tests: valid IPv4/IPv6 headers, LOCAL command, invalid signature, truncated data
|
||||||
|
|
||||||
|
**Modify: `rust/src/lib.rs`** — add `pub mod proxy_protocol;`
|
||||||
|
|
||||||
|
### Phase 2: Server config + client info fields
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `ServerConfig`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```rust
|
||||||
|
/// Enable PROXY protocol v2 parsing on WebSocket connections.
|
||||||
|
/// SECURITY: Must be false when accepting direct client connections.
|
||||||
|
pub proxy_protocol: Option<bool>,
|
||||||
|
/// Server-level IP block list — applied at TCP accept time, before Noise handshake.
|
||||||
|
pub connection_ip_block_list: Option<Vec<String>>,
|
||||||
|
```
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `ClientInfo`**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```rust
|
||||||
|
/// Real client IP:port (from PROXY protocol header or direct TCP connection).
|
||||||
|
pub remote_addr: Option<String>,
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: ACL helper
|
||||||
|
|
||||||
|
**File: `rust/src/acl.rs`**
|
||||||
|
|
||||||
|
Add a public function for the server-level pre-handshake check:
|
||||||
|
```rust
|
||||||
|
/// Check whether a connection source IP is in a block list.
|
||||||
|
pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
|
||||||
|
ip_matches_any(ip, block_list)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keeps `ip_matches_any` private; exposes only the specific check needed.)
|
||||||
|
|
||||||
|
### Phase 4: WebSocket listener integration
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `run_ws_listener()`**
|
||||||
|
|
||||||
|
Between `listener.accept()` and `transport::accept_connection()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Determine real client address
|
||||||
|
let remote_addr = if state.config.proxy_protocol.unwrap_or(false) {
|
||||||
|
match proxy_protocol::read_proxy_header(&mut tcp_stream).await {
|
||||||
|
Ok(header) if header.is_local => {
|
||||||
|
// Health check probe — close gracefully
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(header) => {
|
||||||
|
info!("PP v2: real client {} -> {}", header.src_addr, header.dst_addr);
|
||||||
|
Some(header.src_addr)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("PP v2 parse failed from {}: {}", tcp_addr, e);
|
||||||
|
return; // Drop connection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(tcp_addr) // Direct connection — use TCP SocketAddr
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-handshake server-level block list check
|
||||||
|
if let (Some(ref block_list), Some(ref addr)) = (&state.config.connection_ip_block_list, &remote_addr) {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if acl::is_connection_blocked(v4, block_list) {
|
||||||
|
warn!("Connection blocked by server IP block list: {}", addr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then proceed with WS upgrade + handle_client_connection as before
|
||||||
|
```
|
||||||
|
|
||||||
|
Key correctness note: `read_proxy_header` reads *exactly* the PP header bytes via `read_exact`. The `TcpStream` is then in a clean state for the WS HTTP upgrade. No buffered wrapper needed.
|
||||||
|
|
||||||
|
### Phase 5: Update `handle_client_connection` signature
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs`**
|
||||||
|
|
||||||
|
Change signature:
|
||||||
|
```rust
|
||||||
|
async fn handle_client_connection(
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
mut sink: Box<dyn TransportSink>,
|
||||||
|
mut stream: Box<dyn TransportStream>,
|
||||||
|
remote_addr: Option<std::net::SocketAddr>, // NEW
|
||||||
|
) -> Result<()>
|
||||||
|
```
|
||||||
|
|
||||||
|
After Noise IK handshake + registry lookup (where `client_security` is available), add connection-level per-client ACL:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if let (Some(ref sec), Some(addr)) = (&client_security, &remote_addr) {
|
||||||
|
if let std::net::IpAddr::V4(v4) = addr.ip() {
|
||||||
|
if acl::is_connection_blocked(v4, sec.ip_block_list.as_deref().unwrap_or(&[])) {
|
||||||
|
anyhow::bail!("Client {} connection denied: source IP {} blocked", registered_client_id, addr);
|
||||||
|
}
|
||||||
|
if let Some(ref allow) = sec.ip_allow_list {
|
||||||
|
if !allow.is_empty() && !acl::is_ip_allowed(v4, allow) {
|
||||||
|
anyhow::bail!("Client {} connection denied: source IP {} not in allow list", registered_client_id, addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Populate `remote_addr` when building `ClientInfo`:
|
||||||
|
```rust
|
||||||
|
remote_addr: remote_addr.map(|a| a.to_string()),
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: QUIC listener — pass remote addr through
|
||||||
|
|
||||||
|
**File: `rust/src/server.rs` — `run_quic_listener()`**
|
||||||
|
|
||||||
|
QUIC doesn't use PROXY protocol. Just pass `conn.remote_address()` through:
|
||||||
|
```rust
|
||||||
|
let remote = conn.remote_address();
|
||||||
|
// ...
|
||||||
|
handle_client_connection(state, Box::new(sink), Box::new(stream), Some(remote)).await
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 7: TypeScript interface updates
|
||||||
|
|
||||||
|
**File: `ts/smartvpn.interfaces.ts`**
|
||||||
|
|
||||||
|
Add to `IVpnServerConfig`:
|
||||||
|
```typescript
|
||||||
|
/** Enable PROXY protocol v2 on incoming WebSocket connections.
|
||||||
|
* Required when behind a reverse proxy that sends PP v2 headers. */
|
||||||
|
proxyProtocol?: boolean;
|
||||||
|
/** Server-level IP block list — applied at TCP accept time, before Noise handshake. */
|
||||||
|
connectionIpBlockList?: string[];
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `IVpnClientInfo`:
|
||||||
|
```typescript
|
||||||
|
/** Real client IP:port (from PROXY protocol or direct TCP). */
|
||||||
|
remoteAddr?: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 8: Tests
|
||||||
|
|
||||||
|
**Rust unit tests in `proxy_protocol.rs`:**
|
||||||
|
- `parse_valid_ipv4_header` — construct a valid PP v2 header with known IPs, verify parsed correctly
|
||||||
|
- `parse_valid_ipv6_header` — same for IPv6
|
||||||
|
- `parse_local_command` — health check probe returns `is_local: true`
|
||||||
|
- `reject_invalid_signature` — random bytes rejected
|
||||||
|
- `reject_truncated_header` — short reads fail gracefully
|
||||||
|
- `reject_v1_header` — PROXY v1 text format rejected (we only support v2)
|
||||||
|
|
||||||
|
**Rust unit tests in `acl.rs`:**
|
||||||
|
- `is_connection_blocked` with various IP patterns
|
||||||
|
|
||||||
|
**TypeScript tests:**
|
||||||
|
- Config validation accepts `proxyProtocol: true` + `connectionIpBlockList`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `rust/src/proxy_protocol.rs` | **NEW** — PP v2 parser + tests |
|
||||||
|
| `rust/src/lib.rs` | Add `pub mod proxy_protocol;` |
|
||||||
|
| `rust/src/server.rs` | `ServerConfig` + `ClientInfo` fields, `run_ws_listener` PP integration, `handle_client_connection` signature + connection ACL, `run_quic_listener` pass-through |
|
||||||
|
| `rust/src/acl.rs` | Add `is_connection_blocked` public function |
|
||||||
|
| `ts/smartvpn.interfaces.ts` | `proxyProtocol`, `connectionIpBlockList`, `remoteAddr` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cargo test` — all existing 121 tests + new PP parser tests pass
|
||||||
|
2. `pnpm test` — all 79 TS tests pass (no PP in test setup, just config validation)
|
||||||
|
3. Manual: `socat` or test harness to send a PP v2 header before WS upgrade, verify server logs real IP
|
||||||
226
rust/Cargo.lock
generated
226
rust/Cargo.lock
generated
@@ -46,6 +46,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_system_properties"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
@@ -228,6 +237,12 @@ version = "3.20.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -306,6 +321,20 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||||
|
dependencies = [
|
||||||
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
@@ -465,6 +494,47 @@ version = "2.10.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "0.3.100"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||||
|
dependencies = [
|
||||||
|
"defmt 1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"defmt-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-macros"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
|
||||||
|
dependencies = [
|
||||||
|
"defmt-parser",
|
||||||
|
"proc-macro-error2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "defmt-parser"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@@ -691,6 +761,25 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hash32"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heapless"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||||
|
dependencies = [
|
||||||
|
"hash32",
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -728,6 +817,30 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone"
|
||||||
|
version = "0.1.65"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||||
|
dependencies = [
|
||||||
|
"android_system_properties",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"iana-time-zone-haiku",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"windows-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iana-time-zone-haiku"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -868,6 +981,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "managed"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -942,6 +1061,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
@@ -1060,6 +1188,28 @@ dependencies = [
|
|||||||
"zerocopy",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error-attr2"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro-error2"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-error-attr2",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1528,8 +1678,10 @@ dependencies = [
|
|||||||
"boringtun",
|
"boringtun",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"ipnet",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@@ -1540,6 +1692,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"smoltcp",
|
||||||
"snow",
|
"snow",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1551,6 +1704,20 @@ dependencies = [
|
|||||||
"webpki-roots 1.0.6",
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smoltcp"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"byteorder",
|
||||||
|
"cfg-if",
|
||||||
|
"defmt 0.3.100",
|
||||||
|
"heapless",
|
||||||
|
"managed",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "snow"
|
name = "snow"
|
||||||
version = "0.9.6"
|
version = "0.9.6"
|
||||||
@@ -1577,6 +1744,12 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stable_deref_trait"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -2020,12 +2193,65 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.62.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement",
|
||||||
|
"windows-interface",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.60.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.59.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.45.0"
|
version = "0.45.0"
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ rustls-pemfile = "2"
|
|||||||
webpki-roots = "1"
|
webpki-roots = "1"
|
||||||
mimalloc = "0.1"
|
mimalloc = "0.1"
|
||||||
boringtun = "0.7"
|
boringtun = "0.7"
|
||||||
|
smoltcp = { version = "0.13", default-features = false, features = ["medium-ip", "proto-ipv4", "socket-tcp", "socket-udp", "alloc"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
ipnet = "2"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
302
rust/src/acl.rs
Normal file
302
rust/src/acl.rs
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use ipnet::Ipv4Net;
|
||||||
|
|
||||||
|
use crate::client_registry::ClientSecurity;
|
||||||
|
|
||||||
|
/// Result of an ACL check.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AclResult {
|
||||||
|
Allow,
|
||||||
|
DenySrc,
|
||||||
|
DenyDst,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a connection source IP is in a server-level block list.
|
||||||
|
/// Used for pre-handshake rejection of known-bad IPs.
|
||||||
|
pub fn is_connection_blocked(ip: Ipv4Addr, block_list: &[String]) -> bool {
|
||||||
|
ip_matches_any(ip, block_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a source IP is allowed by allow/block lists.
|
||||||
|
/// Returns true if the IP is permitted (not blocked and passes allow check).
|
||||||
|
pub fn is_source_allowed(ip: Ipv4Addr, allow_list: Option<&[String]>, block_list: Option<&[String]>) -> bool {
|
||||||
|
// Deny overrides allow
|
||||||
|
if let Some(bl) = block_list {
|
||||||
|
if ip_matches_any(ip, bl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If allow list exists and is non-empty, IP must match
|
||||||
|
if let Some(al) = allow_list {
|
||||||
|
if !al.is_empty() && !ip_matches_any(ip, al) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a packet from `src_ip` to `dst_ip` is allowed by the client's security policy.
|
||||||
|
///
|
||||||
|
/// Evaluation order (deny overrides allow):
|
||||||
|
/// 1. If src_ip is in ip_block_list → DenySrc
|
||||||
|
/// 2. If dst_ip is in destination_block_list → DenyDst
|
||||||
|
/// 3. If ip_allow_list is non-empty and src_ip is NOT in it → DenySrc
|
||||||
|
/// 4. If destination_allow_list is non-empty and dst_ip is NOT in it → DenyDst
|
||||||
|
/// 5. Otherwise → Allow
|
||||||
|
pub fn check_acl(security: &ClientSecurity, src_ip: Ipv4Addr, dst_ip: Ipv4Addr) -> AclResult {
|
||||||
|
// Step 1: Check source block list (deny overrides)
|
||||||
|
if let Some(ref block_list) = security.ip_block_list {
|
||||||
|
if ip_matches_any(src_ip, block_list) {
|
||||||
|
return AclResult::DenySrc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check destination block list (deny overrides)
|
||||||
|
if let Some(ref block_list) = security.destination_block_list {
|
||||||
|
if ip_matches_any(dst_ip, block_list) {
|
||||||
|
return AclResult::DenyDst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check source allow list (if non-empty, must match)
|
||||||
|
if let Some(ref allow_list) = security.ip_allow_list {
|
||||||
|
if !allow_list.is_empty() && !ip_matches_any(src_ip, allow_list) {
|
||||||
|
return AclResult::DenySrc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Check destination allow list (if non-empty, must match)
|
||||||
|
if let Some(ref allow_list) = security.destination_allow_list {
|
||||||
|
if !allow_list.is_empty() && !ip_matches_any(dst_ip, allow_list) {
|
||||||
|
return AclResult::DenyDst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AclResult::Allow
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if `ip` matches any pattern in the list.
|
||||||
|
/// Supports: exact IP, CIDR notation, wildcard patterns (192.168.1.*),
|
||||||
|
/// and IP ranges (192.168.1.1-192.168.1.100).
|
||||||
|
pub fn ip_matches_any(ip: Ipv4Addr, patterns: &[String]) -> bool {
|
||||||
|
for pattern in patterns {
|
||||||
|
if ip_matches(ip, pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if `ip` matches a single pattern.
|
||||||
|
fn ip_matches(ip: Ipv4Addr, pattern: &str) -> bool {
|
||||||
|
let pattern = pattern.trim();
|
||||||
|
|
||||||
|
// CIDR notation (e.g. 192.168.1.0/24)
|
||||||
|
if pattern.contains('/') {
|
||||||
|
if let Ok(net) = pattern.parse::<Ipv4Net>() {
|
||||||
|
return net.contains(&ip);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP range (e.g. 192.168.1.1-192.168.1.100)
|
||||||
|
if pattern.contains('-') {
|
||||||
|
let parts: Vec<&str> = pattern.splitn(2, '-').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
if let (Ok(start), Ok(end)) = (parts[0].trim().parse::<Ipv4Addr>(), parts[1].trim().parse::<Ipv4Addr>()) {
|
||||||
|
let ip_u32 = u32::from(ip);
|
||||||
|
return ip_u32 >= u32::from(start) && ip_u32 <= u32::from(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard pattern (e.g. 192.168.1.*)
|
||||||
|
if pattern.contains('*') {
|
||||||
|
return wildcard_matches(ip, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact IP match
|
||||||
|
if let Ok(exact) = pattern.parse::<Ipv4Addr>() {
|
||||||
|
return ip == exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match an IP against a wildcard pattern like "192.168.1.*" or "10.*.*.*".
|
||||||
|
fn wildcard_matches(ip: Ipv4Addr, pattern: &str) -> bool {
|
||||||
|
let ip_octets = ip.octets();
|
||||||
|
let pattern_parts: Vec<&str> = pattern.split('.').collect();
|
||||||
|
if pattern_parts.len() != 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (i, part) in pattern_parts.iter().enumerate() {
|
||||||
|
if *part == "*" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(octet) = part.parse::<u8>() {
|
||||||
|
if ip_octets[i] != octet {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client_registry::{ClientRateLimit, ClientSecurity};
|
||||||
|
|
||||||
|
fn security(
|
||||||
|
ip_allow: Option<Vec<&str>>,
|
||||||
|
ip_block: Option<Vec<&str>>,
|
||||||
|
dst_allow: Option<Vec<&str>>,
|
||||||
|
dst_block: Option<Vec<&str>>,
|
||||||
|
) -> ClientSecurity {
|
||||||
|
ClientSecurity {
|
||||||
|
ip_allow_list: ip_allow.map(|v| v.into_iter().map(String::from).collect()),
|
||||||
|
ip_block_list: ip_block.map(|v| v.into_iter().map(String::from).collect()),
|
||||||
|
destination_allow_list: dst_allow.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,
|
||||||
|
rate_limit: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip(s: &str) -> Ipv4Addr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No restrictions (empty security) ────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_security_allows_all() {
|
||||||
|
let sec = security(None, None, None, None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.2.3.4"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_lists_allow_all() {
|
||||||
|
let sec = security(Some(vec![]), Some(vec![]), Some(vec![]), Some(vec![]));
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.2.3.4"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source IP allow list ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn src_allow_exact_match() {
|
||||||
|
let sec = security(Some(vec!["10.0.0.1"]), None, None, None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.2"), ip("5.6.7.8")), AclResult::DenySrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn src_allow_cidr() {
|
||||||
|
let sec = security(Some(vec!["192.168.1.0/24"]), None, None, None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.1.50"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.2.1"), ip("5.6.7.8")), AclResult::DenySrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn src_allow_wildcard() {
|
||||||
|
let sec = security(Some(vec!["10.0.*.*"]), None, None, None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.5.3"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.1.0.1"), ip("5.6.7.8")), AclResult::DenySrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn src_allow_range() {
|
||||||
|
let sec = security(Some(vec!["10.0.0.1-10.0.0.10"]), None, None, None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.5"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.11"), ip("5.6.7.8")), AclResult::DenySrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Source IP block list (deny overrides) ───────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn src_block_overrides_allow() {
|
||||||
|
let sec = security(
|
||||||
|
Some(vec!["192.168.1.0/24"]),
|
||||||
|
Some(vec!["192.168.1.100"]),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.1.50"), ip("5.6.7.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.1.100"), ip("5.6.7.8")), AclResult::DenySrc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destination allow list ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dst_allow_exact() {
|
||||||
|
let sec = security(None, None, Some(vec!["8.8.8.8", "8.8.4.4"]), None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("8.8.8.8")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("1.1.1.1")), AclResult::DenyDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dst_allow_cidr() {
|
||||||
|
let sec = security(None, None, Some(vec!["10.0.0.0/8"]), None);
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.5.3.2")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("172.16.0.1")), AclResult::DenyDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Destination block list (deny overrides) ─────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dst_block_overrides_allow() {
|
||||||
|
let sec = security(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(vec!["10.0.0.0/8"]),
|
||||||
|
Some(vec!["10.0.0.99"]),
|
||||||
|
);
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.0.0.1")), AclResult::Allow);
|
||||||
|
assert_eq!(check_acl(&sec, ip("1.1.1.1"), ip("10.0.0.99")), AclResult::DenyDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combined source + destination ───────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_src_and_dst_filtering() {
|
||||||
|
let sec = security(
|
||||||
|
Some(vec!["192.168.1.0/24"]),
|
||||||
|
None,
|
||||||
|
Some(vec!["8.8.8.8"]),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
// Valid source, valid dest
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.1.10"), ip("8.8.8.8")), AclResult::Allow);
|
||||||
|
// Invalid source
|
||||||
|
assert_eq!(check_acl(&sec, ip("10.0.0.1"), ip("8.8.8.8")), AclResult::DenySrc);
|
||||||
|
// Valid source, invalid dest
|
||||||
|
assert_eq!(check_acl(&sec, ip("192.168.1.10"), ip("1.1.1.1")), AclResult::DenyDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IP matching edge cases ──────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildcard_single_octet() {
|
||||||
|
assert!(ip_matches(ip("10.0.0.5"), "10.0.0.*"));
|
||||||
|
assert!(!ip_matches(ip("10.0.1.5"), "10.0.0.*"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn range_boundaries() {
|
||||||
|
assert!(ip_matches(ip("10.0.0.1"), "10.0.0.1-10.0.0.5"));
|
||||||
|
assert!(ip_matches(ip("10.0.0.5"), "10.0.0.1-10.0.0.5"));
|
||||||
|
assert!(!ip_matches(ip("10.0.0.6"), "10.0.0.1-10.0.0.5"));
|
||||||
|
assert!(!ip_matches(ip("10.0.0.0"), "10.0.0.1-10.0.0.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_pattern_no_match() {
|
||||||
|
assert!(!ip_matches(ip("10.0.0.1"), "not-an-ip"));
|
||||||
|
assert!(!ip_matches(ip("10.0.0.1"), "10.0.0.1/99"));
|
||||||
|
assert!(!ip_matches(ip("10.0.0.1"), "10.0.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::{mpsc, watch, RwLock};
|
use tokio::sync::{mpsc, watch, RwLock};
|
||||||
use tracing::{info, error, warn, debug};
|
use tracing::{info, error, warn, debug};
|
||||||
@@ -12,6 +13,7 @@ use crate::telemetry::ConnectionQuality;
|
|||||||
use crate::transport;
|
use crate::transport;
|
||||||
use crate::transport_trait::{self, TransportSink, TransportStream};
|
use crate::transport_trait::{self, TransportSink, TransportStream};
|
||||||
use crate::quic_transport;
|
use crate::quic_transport;
|
||||||
|
use crate::tunnel::{self, TunConfig};
|
||||||
|
|
||||||
/// Client configuration (matches TS IVpnClientConfig).
|
/// Client configuration (matches TS IVpnClientConfig).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -19,6 +21,10 @@ use crate::quic_transport;
|
|||||||
pub struct ClientConfig {
|
pub struct ClientConfig {
|
||||||
pub server_url: String,
|
pub server_url: String,
|
||||||
pub server_public_key: String,
|
pub server_public_key: String,
|
||||||
|
/// Client's Noise IK static private key (base64) — required for authentication.
|
||||||
|
pub client_private_key: String,
|
||||||
|
/// Client's Noise IK static public key (base64) — for reference/display.
|
||||||
|
pub client_public_key: String,
|
||||||
pub dns: Option<Vec<String>>,
|
pub dns: Option<Vec<String>>,
|
||||||
pub mtu: Option<u16>,
|
pub mtu: Option<u16>,
|
||||||
pub keepalive_interval_secs: Option<u64>,
|
pub keepalive_interval_secs: Option<u64>,
|
||||||
@@ -26,6 +32,9 @@ pub struct ClientConfig {
|
|||||||
pub transport: Option<String>,
|
pub transport: Option<String>,
|
||||||
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
|
/// For QUIC: SHA-256 hash of server certificate (base64) for cert pinning.
|
||||||
pub server_cert_hash: Option<String>,
|
pub server_cert_hash: Option<String>,
|
||||||
|
/// Forwarding mode: "tun" (TUN device, requires root) or "testing" (no TUN).
|
||||||
|
/// Default: "testing".
|
||||||
|
pub forwarding_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Client statistics.
|
/// Client statistics.
|
||||||
@@ -72,6 +81,7 @@ pub struct VpnClient {
|
|||||||
connected_since: Arc<RwLock<Option<std::time::Instant>>>,
|
connected_since: Arc<RwLock<Option<std::time::Instant>>>,
|
||||||
quality_rx: Option<watch::Receiver<ConnectionQuality>>,
|
quality_rx: Option<watch::Receiver<ConnectionQuality>>,
|
||||||
link_health: Arc<RwLock<LinkHealth>>,
|
link_health: Arc<RwLock<LinkHealth>>,
|
||||||
|
connection_handle: Option<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VpnClient {
|
impl VpnClient {
|
||||||
@@ -84,6 +94,7 @@ impl VpnClient {
|
|||||||
connected_since: Arc::new(RwLock::new(None)),
|
connected_since: Arc::new(RwLock::new(None)),
|
||||||
quality_rx: None,
|
quality_rx: None,
|
||||||
link_health: Arc::new(RwLock::new(LinkHealth::Degraded)),
|
link_health: Arc::new(RwLock::new(LinkHealth::Degraded)),
|
||||||
|
connection_handle: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,11 +115,15 @@ impl VpnClient {
|
|||||||
let connected_since = self.connected_since.clone();
|
let connected_since = self.connected_since.clone();
|
||||||
let link_health = self.link_health.clone();
|
let link_health = self.link_health.clone();
|
||||||
|
|
||||||
// Decode server public key
|
// Decode keys
|
||||||
let server_pub_key = base64::Engine::decode(
|
let server_pub_key = base64::Engine::decode(
|
||||||
&base64::engine::general_purpose::STANDARD,
|
&base64::engine::general_purpose::STANDARD,
|
||||||
&config.server_public_key,
|
&config.server_public_key,
|
||||||
)?;
|
)?;
|
||||||
|
let client_priv_key = base64::Engine::decode(
|
||||||
|
&base64::engine::general_purpose::STANDARD,
|
||||||
|
&config.client_private_key,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Create transport based on configuration
|
// Create transport based on configuration
|
||||||
let (mut sink, mut stream): (Box<dyn TransportSink>, Box<dyn TransportStream>) = {
|
let (mut sink, mut stream): (Box<dyn TransportSink>, Box<dyn TransportStream>) = {
|
||||||
@@ -171,12 +186,12 @@ impl VpnClient {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Noise NK handshake (client side = initiator)
|
// Noise IK handshake (client side = initiator, presents static key)
|
||||||
*state.write().await = ClientState::Handshaking;
|
*state.write().await = ClientState::Handshaking;
|
||||||
let mut initiator = crypto::create_initiator(&server_pub_key)?;
|
let mut initiator = crypto::create_initiator(&client_priv_key, &server_pub_key)?;
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
|
|
||||||
// -> e, es
|
// -> e, es, s, ss
|
||||||
let len = initiator.write_message(&[], &mut buf)?;
|
let len = initiator.write_message(&[], &mut buf)?;
|
||||||
let init_frame = Frame {
|
let init_frame = Frame {
|
||||||
packet_type: PacketType::HandshakeInit,
|
packet_type: PacketType::HandshakeInit,
|
||||||
@@ -186,7 +201,7 @@ impl VpnClient {
|
|||||||
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, init_frame, &mut frame_bytes)?;
|
<FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(&mut FrameCodec, init_frame, &mut frame_bytes)?;
|
||||||
sink.send_reliable(frame_bytes.to_vec()).await?;
|
sink.send_reliable(frame_bytes.to_vec()).await?;
|
||||||
|
|
||||||
// <- e, ee
|
// <- e, ee, se
|
||||||
let resp_msg = match stream.recv_reliable().await? {
|
let resp_msg = match stream.recv_reliable().await? {
|
||||||
Some(data) => data,
|
Some(data) => data,
|
||||||
None => anyhow::bail!("Connection closed during handshake"),
|
None => anyhow::bail!("Connection closed during handshake"),
|
||||||
@@ -226,6 +241,31 @@ impl VpnClient {
|
|||||||
|
|
||||||
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
info!("Connected to VPN, assigned IP: {}", assigned_ip);
|
||||||
|
|
||||||
|
// Optionally create TUN device for IP packet forwarding (requires root)
|
||||||
|
let tun_enabled = config.forwarding_mode.as_deref() == Some("tun");
|
||||||
|
let (tun_reader, tun_writer, tun_subnet) = if tun_enabled {
|
||||||
|
let client_tun_ip: Ipv4Addr = assigned_ip.parse()?;
|
||||||
|
let mtu = ip_info["mtu"].as_u64().unwrap_or(1420) as u16;
|
||||||
|
let tun_config = TunConfig {
|
||||||
|
name: "svpn-client0".to_string(),
|
||||||
|
address: client_tun_ip,
|
||||||
|
netmask: Ipv4Addr::new(255, 255, 255, 0),
|
||||||
|
mtu,
|
||||||
|
};
|
||||||
|
let tun_device = tunnel::create_tun(&tun_config)?;
|
||||||
|
|
||||||
|
// Add route for VPN subnet through the TUN device
|
||||||
|
let gateway_str = ip_info["gateway"].as_str().unwrap_or("10.8.0.1");
|
||||||
|
let gateway: Ipv4Addr = gateway_str.parse().unwrap_or(Ipv4Addr::new(10, 8, 0, 1));
|
||||||
|
let subnet = format!("{}/24", Ipv4Addr::from(u32::from(gateway) & 0xFFFFFF00));
|
||||||
|
tunnel::add_route(&subnet, &tun_config.name).await?;
|
||||||
|
|
||||||
|
let (reader, writer) = tokio::io::split(tun_device);
|
||||||
|
(Some(reader), Some(writer), Some(subnet))
|
||||||
|
} else {
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
// Create adaptive keepalive monitor (use custom interval if configured)
|
// Create adaptive keepalive monitor (use custom interval if configured)
|
||||||
let ka_config = config.keepalive_interval_secs.map(|secs| {
|
let ka_config = config.keepalive_interval_secs.map(|secs| {
|
||||||
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
|
let mut cfg = keepalive::AdaptiveKeepaliveConfig::default();
|
||||||
@@ -242,7 +282,7 @@ impl VpnClient {
|
|||||||
|
|
||||||
// Spawn packet forwarding loop
|
// Spawn packet forwarding loop
|
||||||
let assigned_ip_clone = assigned_ip.clone();
|
let assigned_ip_clone = assigned_ip.clone();
|
||||||
tokio::spawn(client_loop(
|
let join_handle = tokio::spawn(client_loop(
|
||||||
sink,
|
sink,
|
||||||
stream,
|
stream,
|
||||||
noise_transport,
|
noise_transport,
|
||||||
@@ -252,7 +292,11 @@ impl VpnClient {
|
|||||||
handle.signal_rx,
|
handle.signal_rx,
|
||||||
handle.ack_tx,
|
handle.ack_tx,
|
||||||
link_health,
|
link_health,
|
||||||
|
tun_reader,
|
||||||
|
tun_writer,
|
||||||
|
tun_subnet,
|
||||||
));
|
));
|
||||||
|
self.connection_handle = Some(join_handle);
|
||||||
|
|
||||||
Ok(assigned_ip_clone)
|
Ok(assigned_ip_clone)
|
||||||
}
|
}
|
||||||
@@ -262,6 +306,13 @@ impl VpnClient {
|
|||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
let _ = tx.send(()).await;
|
let _ = tx.send(()).await;
|
||||||
}
|
}
|
||||||
|
// Wait for the connection task to send the Disconnect frame and close
|
||||||
|
if let Some(handle) = self.connection_handle.take() {
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
handle,
|
||||||
|
).await;
|
||||||
|
}
|
||||||
*self.assigned_ip.write().await = None;
|
*self.assigned_ip.write().await = None;
|
||||||
*self.connected_since.write().await = None;
|
*self.connected_since.write().await = None;
|
||||||
*self.state.write().await = ClientState::Disconnected;
|
*self.state.write().await = ClientState::Disconnected;
|
||||||
@@ -348,8 +399,14 @@ async fn client_loop(
|
|||||||
mut signal_rx: mpsc::Receiver<KeepaliveSignal>,
|
mut signal_rx: mpsc::Receiver<KeepaliveSignal>,
|
||||||
ack_tx: mpsc::Sender<()>,
|
ack_tx: mpsc::Sender<()>,
|
||||||
link_health: Arc<RwLock<LinkHealth>>,
|
link_health: Arc<RwLock<LinkHealth>>,
|
||||||
|
mut tun_reader: Option<tokio::io::ReadHalf<tun::AsyncDevice>>,
|
||||||
|
mut tun_writer: Option<tokio::io::WriteHalf<tun::AsyncDevice>>,
|
||||||
|
tun_subnet: Option<String>,
|
||||||
) {
|
) {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
|
let mut tun_buf = vec![0u8; 65536];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -365,6 +422,14 @@ async fn client_loop(
|
|||||||
let mut s = stats.write().await;
|
let mut s = stats.write().await;
|
||||||
s.bytes_received += len as u64;
|
s.bytes_received += len as u64;
|
||||||
s.packets_received += 1;
|
s.packets_received += 1;
|
||||||
|
drop(s);
|
||||||
|
|
||||||
|
// Write decrypted packet to TUN device (if enabled)
|
||||||
|
if let Some(ref mut writer) = tun_writer {
|
||||||
|
if let Err(e) = writer.write_all(&buf[..len]).await {
|
||||||
|
warn!("TUN write error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Decrypt error: {}", e);
|
warn!("Decrypt error: {}", e);
|
||||||
@@ -399,6 +464,50 @@ async fn client_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Read outbound packets from TUN and send to server (only when TUN enabled)
|
||||||
|
result = async {
|
||||||
|
match tun_reader {
|
||||||
|
Some(ref mut reader) => reader.read(&mut tun_buf).await,
|
||||||
|
None => std::future::pending::<std::io::Result<usize>>().await,
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
match result {
|
||||||
|
Ok(0) => {
|
||||||
|
info!("TUN device closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(n) => {
|
||||||
|
match noise_transport.write_message(&tun_buf[..n], &mut buf) {
|
||||||
|
Ok(len) => {
|
||||||
|
let frame = Frame {
|
||||||
|
packet_type: PacketType::IpPacket,
|
||||||
|
payload: buf[..len].to_vec(),
|
||||||
|
};
|
||||||
|
let mut frame_bytes = BytesMut::new();
|
||||||
|
if <FrameCodec as tokio_util::codec::Encoder<Frame>>::encode(
|
||||||
|
&mut FrameCodec, frame, &mut frame_bytes
|
||||||
|
).is_ok() {
|
||||||
|
if sink.send_reliable(frame_bytes.to_vec()).await.is_err() {
|
||||||
|
warn!("Failed to send TUN packet to server");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut s = stats.write().await;
|
||||||
|
s.bytes_sent += n as u64;
|
||||||
|
s.packets_sent += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Noise encrypt error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("TUN read error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
signal = signal_rx.recv() => {
|
signal = signal_rx.recv() => {
|
||||||
match signal {
|
match signal {
|
||||||
Some(KeepaliveSignal::SendPing(timestamp_ms)) => {
|
Some(KeepaliveSignal::SendPing(timestamp_ms)) => {
|
||||||
@@ -448,6 +557,13 @@ async fn client_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup: remove TUN route if enabled
|
||||||
|
if let Some(ref subnet) = tun_subnet {
|
||||||
|
if let Err(e) = tunnel::remove_route(subnet, "svpn-client0").await {
|
||||||
|
warn!("Failed to remove client TUN route: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to connect via QUIC. Returns transport halves on success.
|
/// Try to connect via QUIC. Returns transport halves on success.
|
||||||
|
|||||||
373
rust/src/client_registry.rs
Normal file
373
rust/src/client_registry.rs
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Per-client rate limiting configuration.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ClientRateLimit {
|
||||||
|
pub bytes_per_sec: u64,
|
||||||
|
pub burst_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-client security settings — aligned with SmartProxy's IRouteSecurity pattern.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ClientSecurity {
|
||||||
|
/// Source IPs/CIDRs the client may connect FROM (empty/None = any).
|
||||||
|
pub ip_allow_list: Option<Vec<String>>,
|
||||||
|
/// Source IPs blocked — overrides ip_allow_list (deny wins).
|
||||||
|
pub ip_block_list: Option<Vec<String>>,
|
||||||
|
/// Destination IPs/CIDRs the client may reach (empty/None = all).
|
||||||
|
pub destination_allow_list: Option<Vec<String>>,
|
||||||
|
/// Destination IPs blocked — overrides destination_allow_list (deny wins).
|
||||||
|
pub destination_block_list: Option<Vec<String>>,
|
||||||
|
/// Max concurrent connections from this client.
|
||||||
|
pub max_connections: Option<u32>,
|
||||||
|
/// Per-client rate limiting.
|
||||||
|
pub rate_limit: Option<ClientRateLimit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered client entry — the server-side source of truth.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ClientEntry {
|
||||||
|
/// Human-readable client ID (e.g. "alice-laptop").
|
||||||
|
pub client_id: String,
|
||||||
|
/// Client's Noise IK public key (base64).
|
||||||
|
pub public_key: String,
|
||||||
|
/// Client's WireGuard public key (base64) — optional.
|
||||||
|
pub wg_public_key: Option<String>,
|
||||||
|
/// Security settings (ACLs, rate limits).
|
||||||
|
pub security: Option<ClientSecurity>,
|
||||||
|
/// Traffic priority (lower = higher priority, default: 100).
|
||||||
|
pub priority: Option<u32>,
|
||||||
|
/// Whether this client is enabled (default: true).
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
/// Tags assigned by the server admin — trusted, used for access control.
|
||||||
|
pub server_defined_client_tags: Option<Vec<String>>,
|
||||||
|
/// Tags reported by the connecting client — informational only.
|
||||||
|
pub client_defined_client_tags: Option<Vec<String>>,
|
||||||
|
/// Legacy tags field — treated as serverDefinedClientTags during deserialization.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
/// Optional description.
|
||||||
|
pub description: Option<String>,
|
||||||
|
/// Optional expiry (ISO 8601 timestamp).
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
/// Assigned VPN IP address.
|
||||||
|
pub assigned_ip: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientEntry {
|
||||||
|
/// Whether this client is considered enabled (defaults to true).
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.enabled.unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this client has expired based on current time.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
if let Some(ref expires) = self.expires_at {
|
||||||
|
if let Ok(expiry) = chrono::DateTime::parse_from_rfc3339(expires) {
|
||||||
|
return chrono::Utc::now() > expiry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory client registry with dual-key indexing.
|
||||||
|
pub struct ClientRegistry {
|
||||||
|
/// Primary index: clientId → ClientEntry
|
||||||
|
entries: HashMap<String, ClientEntry>,
|
||||||
|
/// Secondary index: publicKey (base64) → clientId (fast lookup during handshake)
|
||||||
|
key_index: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientRegistry {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
key_index: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a registry from a list of client entries.
|
||||||
|
pub fn from_entries(entries: Vec<ClientEntry>) -> Result<Self> {
|
||||||
|
let mut registry = Self::new();
|
||||||
|
for mut entry in entries {
|
||||||
|
// Migrate legacy `tags` → `serverDefinedClientTags`
|
||||||
|
if entry.server_defined_client_tags.is_none() && entry.tags.is_some() {
|
||||||
|
entry.server_defined_client_tags = entry.tags.take();
|
||||||
|
}
|
||||||
|
registry.add(entry)?;
|
||||||
|
}
|
||||||
|
Ok(registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a client to the registry.
|
||||||
|
pub fn add(&mut self, entry: ClientEntry) -> Result<()> {
|
||||||
|
if self.entries.contains_key(&entry.client_id) {
|
||||||
|
anyhow::bail!("Client '{}' already exists", entry.client_id);
|
||||||
|
}
|
||||||
|
if self.key_index.contains_key(&entry.public_key) {
|
||||||
|
anyhow::bail!("Public key already registered to another client");
|
||||||
|
}
|
||||||
|
self.key_index.insert(entry.public_key.clone(), entry.client_id.clone());
|
||||||
|
self.entries.insert(entry.client_id.clone(), entry);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a client by ID.
|
||||||
|
pub fn remove(&mut self, client_id: &str) -> Result<ClientEntry> {
|
||||||
|
let entry = self.entries.remove(client_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||||
|
self.key_index.remove(&entry.public_key);
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a client by ID.
|
||||||
|
pub fn get_by_id(&self, client_id: &str) -> Option<&ClientEntry> {
|
||||||
|
self.entries.get(client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a client by public key (used during IK handshake verification).
|
||||||
|
pub fn get_by_key(&self, public_key: &str) -> Option<&ClientEntry> {
|
||||||
|
let client_id = self.key_index.get(public_key)?;
|
||||||
|
self.entries.get(client_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a public key is authorized (exists, enabled, not expired).
|
||||||
|
pub fn is_authorized(&self, public_key: &str) -> bool {
|
||||||
|
match self.get_by_key(public_key) {
|
||||||
|
Some(entry) => entry.is_enabled() && !entry.is_expired(),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a client entry. The closure receives a mutable reference to the entry.
|
||||||
|
pub fn update<F>(&mut self, client_id: &str, updater: F) -> Result<()>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut ClientEntry),
|
||||||
|
{
|
||||||
|
let entry = self.entries.get_mut(client_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||||
|
let old_key = entry.public_key.clone();
|
||||||
|
updater(entry);
|
||||||
|
// If public key changed, update the index
|
||||||
|
if entry.public_key != old_key {
|
||||||
|
self.key_index.remove(&old_key);
|
||||||
|
self.key_index.insert(entry.public_key.clone(), client_id.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all client entries.
|
||||||
|
pub fn list(&self) -> Vec<&ClientEntry> {
|
||||||
|
self.entries.values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate a client's keys. Returns the updated entry.
|
||||||
|
pub fn rotate_key(&mut self, client_id: &str, new_public_key: String, new_wg_public_key: Option<String>) -> Result<()> {
|
||||||
|
let entry = self.entries.get_mut(client_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Client '{}' not found", client_id))?;
|
||||||
|
// Update key index
|
||||||
|
self.key_index.remove(&entry.public_key);
|
||||||
|
entry.public_key = new_public_key.clone();
|
||||||
|
entry.wg_public_key = new_wg_public_key;
|
||||||
|
self.key_index.insert(new_public_key, client_id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of registered clients.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the registry is empty.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn make_entry(id: &str, key: &str) -> ClientEntry {
|
||||||
|
ClientEntry {
|
||||||
|
client_id: id.to_string(),
|
||||||
|
public_key: key.to_string(),
|
||||||
|
wg_public_key: None,
|
||||||
|
security: None,
|
||||||
|
priority: None,
|
||||||
|
enabled: None,
|
||||||
|
server_defined_client_tags: None,
|
||||||
|
client_defined_client_tags: None,
|
||||||
|
tags: None,
|
||||||
|
description: None,
|
||||||
|
expires_at: None,
|
||||||
|
assigned_ip: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_lookup() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key_alice")).unwrap();
|
||||||
|
|
||||||
|
assert!(reg.get_by_id("alice").is_some());
|
||||||
|
assert!(reg.get_by_key("key_alice").is_some());
|
||||||
|
assert_eq!(reg.get_by_key("key_alice").unwrap().client_id, "alice");
|
||||||
|
assert!(reg.get_by_id("bob").is_none());
|
||||||
|
assert!(reg.get_by_key("key_bob").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_duplicate_id() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key1")).unwrap();
|
||||||
|
assert!(reg.add(make_entry("alice", "key2")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_duplicate_key() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "same_key")).unwrap();
|
||||||
|
assert!(reg.add(make_entry("bob", "same_key")).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_client() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key_alice")).unwrap();
|
||||||
|
assert_eq!(reg.len(), 1);
|
||||||
|
|
||||||
|
let removed = reg.remove("alice").unwrap();
|
||||||
|
assert_eq!(removed.client_id, "alice");
|
||||||
|
assert_eq!(reg.len(), 0);
|
||||||
|
assert!(reg.get_by_key("key_alice").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_nonexistent_fails() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
assert!(reg.remove("ghost").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authorized_enabled() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key_alice")).unwrap();
|
||||||
|
assert!(reg.is_authorized("key_alice")); // enabled by default
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authorized_disabled() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
let mut entry = make_entry("alice", "key_alice");
|
||||||
|
entry.enabled = Some(false);
|
||||||
|
reg.add(entry).unwrap();
|
||||||
|
assert!(!reg.is_authorized("key_alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authorized_expired() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
let mut entry = make_entry("alice", "key_alice");
|
||||||
|
entry.expires_at = Some("2020-01-01T00:00:00Z".to_string());
|
||||||
|
reg.add(entry).unwrap();
|
||||||
|
assert!(!reg.is_authorized("key_alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authorized_future_expiry() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
let mut entry = make_entry("alice", "key_alice");
|
||||||
|
entry.expires_at = Some("2099-01-01T00:00:00Z".to_string());
|
||||||
|
reg.add(entry).unwrap();
|
||||||
|
assert!(reg.is_authorized("key_alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_authorized_unknown_key() {
|
||||||
|
let reg = ClientRegistry::new();
|
||||||
|
assert!(!reg.is_authorized("nonexistent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_client() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key_alice")).unwrap();
|
||||||
|
|
||||||
|
reg.update("alice", |entry| {
|
||||||
|
entry.description = Some("Updated".to_string());
|
||||||
|
entry.enabled = Some(false);
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
let entry = reg.get_by_id("alice").unwrap();
|
||||||
|
assert_eq!(entry.description.as_deref(), Some("Updated"));
|
||||||
|
assert!(!entry.is_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_nonexistent_fails() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
assert!(reg.update("ghost", |_| {}).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rotate_key() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "old_key")).unwrap();
|
||||||
|
|
||||||
|
reg.rotate_key("alice", "new_key".to_string(), None).unwrap();
|
||||||
|
|
||||||
|
assert!(reg.get_by_key("old_key").is_none());
|
||||||
|
assert!(reg.get_by_key("new_key").is_some());
|
||||||
|
assert_eq!(reg.get_by_id("alice").unwrap().public_key, "new_key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_entries() {
|
||||||
|
let entries = vec![
|
||||||
|
make_entry("alice", "key_a"),
|
||||||
|
make_entry("bob", "key_b"),
|
||||||
|
];
|
||||||
|
let reg = ClientRegistry::from_entries(entries).unwrap();
|
||||||
|
assert_eq!(reg.len(), 2);
|
||||||
|
assert!(reg.get_by_key("key_a").is_some());
|
||||||
|
assert!(reg.get_by_key("key_b").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_clients() {
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(make_entry("alice", "key_a")).unwrap();
|
||||||
|
reg.add(make_entry("bob", "key_b")).unwrap();
|
||||||
|
let list = reg.list();
|
||||||
|
assert_eq!(list.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_with_rate_limit() {
|
||||||
|
let mut entry = make_entry("alice", "key_alice");
|
||||||
|
entry.security = Some(ClientSecurity {
|
||||||
|
ip_allow_list: Some(vec!["192.168.1.0/24".to_string()]),
|
||||||
|
ip_block_list: Some(vec!["192.168.1.100".to_string()]),
|
||||||
|
destination_allow_list: None,
|
||||||
|
destination_block_list: None,
|
||||||
|
max_connections: Some(5),
|
||||||
|
rate_limit: Some(ClientRateLimit {
|
||||||
|
bytes_per_sec: 1_000_000,
|
||||||
|
burst_bytes: 2_000_000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let mut reg = ClientRegistry::new();
|
||||||
|
reg.add(entry).unwrap();
|
||||||
|
let e = reg.get_by_id("alice").unwrap();
|
||||||
|
let sec = e.security.as_ref().unwrap();
|
||||||
|
assert_eq!(sec.rate_limit.as_ref().unwrap().bytes_per_sec, 1_000_000);
|
||||||
|
assert_eq!(sec.max_connections, Some(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ use base64::Engine;
|
|||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use snow::Builder;
|
use snow::Builder;
|
||||||
|
|
||||||
/// Noise protocol pattern: NK (client knows server pubkey, no client auth at Noise level)
|
/// Noise protocol pattern: IK (client presents static key, server authenticates client)
|
||||||
const NOISE_PATTERN: &str = "Noise_NK_25519_ChaChaPoly_BLAKE2s";
|
/// IK = Initiator's static key is transmitted; responder's Key is pre-known.
|
||||||
|
/// This provides mutual authentication: server verifies client identity via public key.
|
||||||
|
const NOISE_PATTERN: &str = "Noise_IK_25519_ChaChaPoly_BLAKE2s";
|
||||||
|
|
||||||
/// Generate a new Noise static keypair.
|
/// Generate a new Noise static keypair.
|
||||||
/// Returns (public_key_base64, private_key_base64).
|
/// Returns (public_key_base64, private_key_base64).
|
||||||
@@ -22,18 +24,23 @@ pub fn generate_keypair_raw() -> Result<snow::Keypair> {
|
|||||||
Ok(builder.generate_keypair()?)
|
Ok(builder.generate_keypair()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Noise NK initiator (client side).
|
/// Create a Noise IK initiator (client side).
|
||||||
/// The client knows the server's static public key.
|
/// The client provides its own static keypair AND the server's public key.
|
||||||
pub fn create_initiator(server_public_key: &[u8]) -> Result<snow::HandshakeState> {
|
/// The client's static key is transmitted (encrypted) during the handshake,
|
||||||
|
/// allowing the server to authenticate the client.
|
||||||
|
pub fn create_initiator(client_private_key: &[u8], server_public_key: &[u8]) -> Result<snow::HandshakeState> {
|
||||||
let builder = Builder::new(NOISE_PATTERN.parse()?);
|
let builder = Builder::new(NOISE_PATTERN.parse()?);
|
||||||
let state = builder
|
let state = builder
|
||||||
|
.local_private_key(client_private_key)
|
||||||
.remote_public_key(server_public_key)
|
.remote_public_key(server_public_key)
|
||||||
.build_initiator()?;
|
.build_initiator()?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a Noise NK responder (server side).
|
/// Create a Noise IK responder (server side).
|
||||||
/// The server uses its static private key.
|
/// The server uses its static private key.
|
||||||
|
/// After the handshake, call `get_remote_static()` on the HandshakeState
|
||||||
|
/// (before `into_transport_mode()`) to retrieve the client's public key.
|
||||||
pub fn create_responder(private_key: &[u8]) -> Result<snow::HandshakeState> {
|
pub fn create_responder(private_key: &[u8]) -> Result<snow::HandshakeState> {
|
||||||
let builder = Builder::new(NOISE_PATTERN.parse()?);
|
let builder = Builder::new(NOISE_PATTERN.parse()?);
|
||||||
let state = builder
|
let state = builder
|
||||||
@@ -42,19 +49,20 @@ pub fn create_responder(private_key: &[u8]) -> Result<snow::HandshakeState> {
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform the full Noise NK handshake between initiator and responder.
|
/// Perform the full Noise IK handshake between initiator and responder.
|
||||||
/// Returns (initiator_transport, responder_transport).
|
/// Returns (initiator_transport, responder_transport, client_public_key).
|
||||||
|
/// The client_public_key is extracted from the responder before entering transport mode.
|
||||||
pub fn perform_handshake(
|
pub fn perform_handshake(
|
||||||
mut initiator: snow::HandshakeState,
|
mut initiator: snow::HandshakeState,
|
||||||
mut responder: snow::HandshakeState,
|
mut responder: snow::HandshakeState,
|
||||||
) -> Result<(snow::TransportState, snow::TransportState)> {
|
) -> Result<(snow::TransportState, snow::TransportState, Vec<u8>)> {
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
|
|
||||||
// -> e, es (initiator sends)
|
// -> e, es, s, ss (initiator sends ephemeral + encrypted static key)
|
||||||
let len = initiator.write_message(&[], &mut buf)?;
|
let len = initiator.write_message(&[], &mut buf)?;
|
||||||
let msg1 = buf[..len].to_vec();
|
let msg1 = buf[..len].to_vec();
|
||||||
|
|
||||||
// <- e, ee (responder reads and responds)
|
// <- e, ee, se (responder reads and responds)
|
||||||
responder.read_message(&msg1, &mut buf)?;
|
responder.read_message(&msg1, &mut buf)?;
|
||||||
let len = responder.write_message(&[], &mut buf)?;
|
let len = responder.write_message(&[], &mut buf)?;
|
||||||
let msg2 = buf[..len].to_vec();
|
let msg2 = buf[..len].to_vec();
|
||||||
@@ -62,10 +70,16 @@ pub fn perform_handshake(
|
|||||||
// Initiator reads response
|
// Initiator reads response
|
||||||
initiator.read_message(&msg2, &mut buf)?;
|
initiator.read_message(&msg2, &mut buf)?;
|
||||||
|
|
||||||
|
// Extract client's public key from responder BEFORE entering transport mode
|
||||||
|
let client_public_key = responder
|
||||||
|
.get_remote_static()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("IK handshake did not provide client static key"))?
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
let i_transport = initiator.into_transport_mode()?;
|
let i_transport = initiator.into_transport_mode()?;
|
||||||
let r_transport = responder.into_transport_mode()?;
|
let r_transport = responder.into_transport_mode()?;
|
||||||
|
|
||||||
Ok((i_transport, r_transport))
|
Ok((i_transport, r_transport, client_public_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// XChaCha20-Poly1305 encryption for post-handshake data.
|
/// XChaCha20-Poly1305 encryption for post-handshake data.
|
||||||
@@ -135,15 +149,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn noise_handshake() {
|
fn noise_ik_handshake() {
|
||||||
let server_kp = generate_keypair_raw().unwrap();
|
let server_kp = generate_keypair_raw().unwrap();
|
||||||
|
let client_kp = generate_keypair_raw().unwrap();
|
||||||
|
|
||||||
let initiator = create_initiator(&server_kp.public).unwrap();
|
let initiator = create_initiator(&client_kp.private, &server_kp.public).unwrap();
|
||||||
let responder = create_responder(&server_kp.private).unwrap();
|
let responder = create_responder(&server_kp.private).unwrap();
|
||||||
|
|
||||||
let (mut i_transport, mut r_transport) =
|
let (mut i_transport, mut r_transport, remote_key) =
|
||||||
perform_handshake(initiator, responder).unwrap();
|
perform_handshake(initiator, responder).unwrap();
|
||||||
|
|
||||||
|
// Verify the server received the client's public key
|
||||||
|
assert_eq!(remote_key, client_kp.public);
|
||||||
|
|
||||||
// Test encrypted communication
|
// Test encrypted communication
|
||||||
let mut buf = vec![0u8; 65535];
|
let mut buf = vec![0u8; 65535];
|
||||||
let plaintext = b"hello from client";
|
let plaintext = b"hello from client";
|
||||||
@@ -159,6 +177,20 @@ mod tests {
|
|||||||
assert_eq!(&out[..len], plaintext);
|
assert_eq!(&out[..len], plaintext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noise_ik_wrong_server_key_fails() {
|
||||||
|
let server_kp = generate_keypair_raw().unwrap();
|
||||||
|
let wrong_server_kp = generate_keypair_raw().unwrap();
|
||||||
|
let client_kp = generate_keypair_raw().unwrap();
|
||||||
|
|
||||||
|
// Client uses wrong server public key
|
||||||
|
let initiator = create_initiator(&client_kp.private, &wrong_server_kp.public).unwrap();
|
||||||
|
let responder = create_responder(&server_kp.private).unwrap();
|
||||||
|
|
||||||
|
// Handshake should fail because client targeted wrong server
|
||||||
|
assert!(perform_handshake(initiator, responder).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn xchacha_encrypt_decrypt() {
|
fn xchacha_encrypt_decrypt() {
|
||||||
let key = [42u8; 32];
|
let key = [42u8; 32];
|
||||||
|
|||||||
@@ -18,3 +18,7 @@ pub mod ratelimit;
|
|||||||
pub mod qos;
|
pub mod qos;
|
||||||
pub mod mtu;
|
pub mod mtu;
|
||||||
pub mod wireguard;
|
pub mod wireguard;
|
||||||
|
pub mod client_registry;
|
||||||
|
pub mod acl;
|
||||||
|
pub mod proxy_protocol;
|
||||||
|
pub mod userspace_nat;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use tracing::{info, error, warn};
|
|||||||
use crate::client::{ClientConfig, VpnClient};
|
use crate::client::{ClientConfig, VpnClient};
|
||||||
use crate::crypto;
|
use crate::crypto;
|
||||||
use crate::server::{ServerConfig, VpnServer};
|
use crate::server::{ServerConfig, VpnServer};
|
||||||
use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig, WgServer, WgServerConfig};
|
use crate::wireguard::{self, WgClient, WgClientConfig, WgPeerConfig};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// IPC protocol types
|
// IPC protocol types
|
||||||
@@ -95,7 +95,6 @@ pub async fn management_loop_stdio(mode: &str) -> Result<()> {
|
|||||||
let mut vpn_client = VpnClient::new();
|
let mut vpn_client = VpnClient::new();
|
||||||
let mut vpn_server = VpnServer::new();
|
let mut vpn_server = VpnServer::new();
|
||||||
let mut wg_client = WgClient::new();
|
let mut wg_client = WgClient::new();
|
||||||
let mut wg_server = WgServer::new();
|
|
||||||
|
|
||||||
send_event_stdout("ready", serde_json::json!({ "mode": mode }));
|
send_event_stdout("ready", serde_json::json!({ "mode": mode }));
|
||||||
|
|
||||||
@@ -131,7 +130,7 @@ pub async fn management_loop_stdio(mode: &str) -> Result<()> {
|
|||||||
|
|
||||||
let response = match mode {
|
let response = match mode {
|
||||||
"client" => handle_client_request(&request, &mut vpn_client, &mut wg_client).await,
|
"client" => handle_client_request(&request, &mut vpn_client, &mut wg_client).await,
|
||||||
"server" => handle_server_request(&request, &mut vpn_server, &mut wg_server).await,
|
"server" => handle_server_request(&request, &mut vpn_server).await,
|
||||||
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
||||||
};
|
};
|
||||||
send_response_stdout(&response);
|
send_response_stdout(&response);
|
||||||
@@ -154,7 +153,6 @@ pub async fn management_loop_socket(socket_path: &str, mode: &str) -> Result<()>
|
|||||||
let vpn_client = std::sync::Arc::new(Mutex::new(VpnClient::new()));
|
let vpn_client = std::sync::Arc::new(Mutex::new(VpnClient::new()));
|
||||||
let vpn_server = std::sync::Arc::new(Mutex::new(VpnServer::new()));
|
let vpn_server = std::sync::Arc::new(Mutex::new(VpnServer::new()));
|
||||||
let wg_client = std::sync::Arc::new(Mutex::new(WgClient::new()));
|
let wg_client = std::sync::Arc::new(Mutex::new(WgClient::new()));
|
||||||
let wg_server = std::sync::Arc::new(Mutex::new(WgServer::new()));
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match listener.accept().await {
|
match listener.accept().await {
|
||||||
@@ -163,10 +161,9 @@ pub async fn management_loop_socket(socket_path: &str, mode: &str) -> Result<()>
|
|||||||
let client = vpn_client.clone();
|
let client = vpn_client.clone();
|
||||||
let server = vpn_server.clone();
|
let server = vpn_server.clone();
|
||||||
let wg_c = wg_client.clone();
|
let wg_c = wg_client.clone();
|
||||||
let wg_s = wg_server.clone();
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
handle_socket_connection(stream, &mode, client, server, wg_c, wg_s).await
|
handle_socket_connection(stream, &mode, client, server, wg_c).await
|
||||||
{
|
{
|
||||||
warn!("Socket connection error: {}", e);
|
warn!("Socket connection error: {}", e);
|
||||||
}
|
}
|
||||||
@@ -185,7 +182,6 @@ async fn handle_socket_connection(
|
|||||||
vpn_client: std::sync::Arc<Mutex<VpnClient>>,
|
vpn_client: std::sync::Arc<Mutex<VpnClient>>,
|
||||||
vpn_server: std::sync::Arc<Mutex<VpnServer>>,
|
vpn_server: std::sync::Arc<Mutex<VpnServer>>,
|
||||||
wg_client: std::sync::Arc<Mutex<WgClient>>,
|
wg_client: std::sync::Arc<Mutex<WgClient>>,
|
||||||
wg_server: std::sync::Arc<Mutex<WgServer>>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (reader, mut writer) = stream.into_split();
|
let (reader, mut writer) = stream.into_split();
|
||||||
let buf_reader = BufReader::new(reader);
|
let buf_reader = BufReader::new(reader);
|
||||||
@@ -241,8 +237,7 @@ async fn handle_socket_connection(
|
|||||||
}
|
}
|
||||||
"server" => {
|
"server" => {
|
||||||
let mut server = vpn_server.lock().await;
|
let mut server = vpn_server.lock().await;
|
||||||
let mut wg_s = wg_server.lock().await;
|
handle_server_request(&request, &mut server).await
|
||||||
handle_server_request(&request, &mut server, &mut wg_s).await
|
|
||||||
}
|
}
|
||||||
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
_ => ManagementResponse::err(request.id.clone(), format!("Unknown mode: {}", mode)),
|
||||||
};
|
};
|
||||||
@@ -381,92 +376,46 @@ async fn handle_client_request(
|
|||||||
async fn handle_server_request(
|
async fn handle_server_request(
|
||||||
request: &ManagementRequest,
|
request: &ManagementRequest,
|
||||||
vpn_server: &mut VpnServer,
|
vpn_server: &mut VpnServer,
|
||||||
wg_server: &mut WgServer,
|
|
||||||
) -> ManagementResponse {
|
) -> ManagementResponse {
|
||||||
let id = request.id.clone();
|
let id = request.id.clone();
|
||||||
|
|
||||||
match request.method.as_str() {
|
match request.method.as_str() {
|
||||||
"start" => {
|
"start" => {
|
||||||
// Check if transportMode is "wireguard"
|
let config: ServerConfig = match serde_json::from_value(
|
||||||
let transport_mode = request.params
|
request.params.get("config").cloned().unwrap_or_default(),
|
||||||
.get("config")
|
) {
|
||||||
.and_then(|c| c.get("transportMode"))
|
Ok(c) => c,
|
||||||
.and_then(|t| t.as_str())
|
Err(e) => {
|
||||||
.unwrap_or("");
|
return ManagementResponse::err(id, format!("Invalid config: {}", e));
|
||||||
|
|
||||||
if transport_mode == "wireguard" {
|
|
||||||
let config: WgServerConfig = match serde_json::from_value(
|
|
||||||
request.params.get("config").cloned().unwrap_or_default(),
|
|
||||||
) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return ManagementResponse::err(id, format!("Invalid WG config: {}", e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match wg_server.start(config).await {
|
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
|
||||||
Err(e) => ManagementResponse::err(id, format!("WG start failed: {}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let config: ServerConfig = match serde_json::from_value(
|
|
||||||
request.params.get("config").cloned().unwrap_or_default(),
|
|
||||||
) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return ManagementResponse::err(id, format!("Invalid config: {}", e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match vpn_server.start(config).await {
|
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
|
||||||
Err(e) => ManagementResponse::err(id, format!("Start failed: {}", e)),
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
match vpn_server.start(config).await {
|
||||||
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Start failed: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"stop" => {
|
"stop" => {
|
||||||
if wg_server.is_running() {
|
match vpn_server.stop().await {
|
||||||
match wg_server.stop().await {
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
Err(e) => ManagementResponse::err(id, format!("Stop failed: {}", e)),
|
||||||
Err(e) => ManagementResponse::err(id, format!("WG stop failed: {}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match vpn_server.stop().await {
|
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
|
||||||
Err(e) => ManagementResponse::err(id, format!("Stop failed: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"getStatus" => {
|
"getStatus" => {
|
||||||
if wg_server.is_running() {
|
let status = vpn_server.get_status();
|
||||||
ManagementResponse::ok(id, wg_server.get_status())
|
ManagementResponse::ok(id, status)
|
||||||
} else {
|
|
||||||
let status = vpn_server.get_status();
|
|
||||||
ManagementResponse::ok(id, status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"getStatistics" => {
|
"getStatistics" => {
|
||||||
if wg_server.is_running() {
|
let stats = vpn_server.get_statistics().await;
|
||||||
ManagementResponse::ok(id, wg_server.get_statistics().await)
|
match serde_json::to_value(&stats) {
|
||||||
} else {
|
Ok(v) => ManagementResponse::ok(id, v),
|
||||||
let stats = vpn_server.get_statistics().await;
|
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||||
match serde_json::to_value(&stats) {
|
|
||||||
Ok(v) => ManagementResponse::ok(id, v),
|
|
||||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"listClients" => {
|
"listClients" => {
|
||||||
if wg_server.is_running() {
|
let clients = vpn_server.list_clients().await;
|
||||||
let peers = wg_server.list_peers().await;
|
match serde_json::to_value(&clients) {
|
||||||
match serde_json::to_value(&peers) {
|
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
||||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let clients = vpn_server.list_clients().await;
|
|
||||||
match serde_json::to_value(&clients) {
|
|
||||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
|
||||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"disconnectClient" => {
|
"disconnectClient" => {
|
||||||
@@ -546,9 +495,6 @@ async fn handle_server_request(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"addWgPeer" => {
|
"addWgPeer" => {
|
||||||
if !wg_server.is_running() {
|
|
||||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
|
||||||
}
|
|
||||||
let config: WgPeerConfig = match serde_json::from_value(
|
let config: WgPeerConfig = match serde_json::from_value(
|
||||||
request.params.get("peer").cloned().unwrap_or_default(),
|
request.params.get("peer").cloned().unwrap_or_default(),
|
||||||
) {
|
) {
|
||||||
@@ -557,34 +503,125 @@ async fn handle_server_request(
|
|||||||
return ManagementResponse::err(id, format!("Invalid peer config: {}", e));
|
return ManagementResponse::err(id, format!("Invalid peer config: {}", e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match wg_server.add_peer(config).await {
|
match vpn_server.add_wg_peer(config).await {
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
Err(e) => ManagementResponse::err(id, format!("Add peer failed: {}", e)),
|
Err(e) => ManagementResponse::err(id, format!("Add peer failed: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"removeWgPeer" => {
|
"removeWgPeer" => {
|
||||||
if !wg_server.is_running() {
|
|
||||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
|
||||||
}
|
|
||||||
let public_key = match request.params.get("publicKey").and_then(|v| v.as_str()) {
|
let public_key = match request.params.get("publicKey").and_then(|v| v.as_str()) {
|
||||||
Some(k) => k.to_string(),
|
Some(k) => k.to_string(),
|
||||||
None => return ManagementResponse::err(id, "Missing publicKey".to_string()),
|
None => return ManagementResponse::err(id, "Missing publicKey".to_string()),
|
||||||
};
|
};
|
||||||
match wg_server.remove_peer(&public_key).await {
|
match vpn_server.remove_wg_peer(&public_key).await {
|
||||||
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
Err(e) => ManagementResponse::err(id, format!("Remove peer failed: {}", e)),
|
Err(e) => ManagementResponse::err(id, format!("Remove peer failed: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"listWgPeers" => {
|
"listWgPeers" => {
|
||||||
if !wg_server.is_running() {
|
let peers = vpn_server.list_wg_peers().await;
|
||||||
return ManagementResponse::err(id, "WireGuard server not running".to_string());
|
|
||||||
}
|
|
||||||
let peers = wg_server.list_peers().await;
|
|
||||||
match serde_json::to_value(&peers) {
|
match serde_json::to_value(&peers) {
|
||||||
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "peers": v })),
|
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "peers": v })),
|
||||||
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Client Registry (Hub) Commands ────────────────────────────────
|
||||||
|
"createClient" => {
|
||||||
|
let client_partial = request.params.get("client").cloned().unwrap_or_default();
|
||||||
|
match vpn_server.create_client(client_partial).await {
|
||||||
|
Ok(bundle) => ManagementResponse::ok(id, bundle),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Create client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"removeClient" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
match vpn_server.remove_registered_client(&client_id).await {
|
||||||
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Remove client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"getClient" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
match vpn_server.get_registered_client(&client_id).await {
|
||||||
|
Ok(entry) => ManagementResponse::ok(id, entry),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Get client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"listRegisteredClients" => {
|
||||||
|
let clients = vpn_server.list_registered_clients().await;
|
||||||
|
match serde_json::to_value(&clients) {
|
||||||
|
Ok(v) => ManagementResponse::ok(id, serde_json::json!({ "clients": v })),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Serialize error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"updateClient" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
let update = request.params.get("update").cloned().unwrap_or_default();
|
||||||
|
match vpn_server.update_registered_client(&client_id, update).await {
|
||||||
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Update client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"enableClient" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
match vpn_server.enable_client(&client_id).await {
|
||||||
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Enable client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disableClient" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
match vpn_server.disable_client(&client_id).await {
|
||||||
|
Ok(()) => ManagementResponse::ok(id, serde_json::json!({})),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Disable client failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"rotateClientKey" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
match vpn_server.rotate_client_key(&client_id).await {
|
||||||
|
Ok(bundle) => ManagementResponse::ok(id, bundle),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Key rotation failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"exportClientConfig" => {
|
||||||
|
let client_id = match request.params.get("clientId").and_then(|v| v.as_str()) {
|
||||||
|
Some(cid) => cid.to_string(),
|
||||||
|
None => return ManagementResponse::err(id, "Missing clientId".to_string()),
|
||||||
|
};
|
||||||
|
let format = request.params.get("format").and_then(|v| v.as_str()).unwrap_or("smartvpn");
|
||||||
|
match vpn_server.export_client_config(&client_id, format).await {
|
||||||
|
Ok(config) => ManagementResponse::ok(id, config),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Export failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"generateClientKeypair" => match crypto::generate_keypair() {
|
||||||
|
Ok((public_key, private_key)) => ManagementResponse::ok(
|
||||||
|
id,
|
||||||
|
serde_json::json!({
|
||||||
|
"publicKey": public_key,
|
||||||
|
"privateKey": private_key,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Err(e) => ManagementResponse::err(id, format!("Keypair generation failed: {}", e)),
|
||||||
|
},
|
||||||
_ => ManagementResponse::err(id, format!("Unknown server method: {}", request.method)),
|
_ => ManagementResponse::err(id, format!("Unknown server method: {}", request.method)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ impl IpPool {
|
|||||||
client_id
|
client_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve a specific IP for a client (e.g., WireGuard static IP from allowed_ips).
|
||||||
|
pub fn reserve(&mut self, ip: Ipv4Addr, client_id: &str) -> Result<()> {
|
||||||
|
if self.allocated.contains_key(&ip) {
|
||||||
|
anyhow::bail!("IP {} is already allocated", ip);
|
||||||
|
}
|
||||||
|
self.allocated.insert(ip, client_id.to_string());
|
||||||
|
info!("Reserved IP {} for client {}", ip, client_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Number of currently allocated IPs.
|
/// Number of currently allocated IPs.
|
||||||
pub fn allocated_count(&self) -> usize {
|
pub fn allocated_count(&self) -> usize {
|
||||||
self.allocated.len()
|
self.allocated.len()
|
||||||
|
|||||||
261
rust/src/proxy_protocol.rs
Normal file
261
rust/src/proxy_protocol.rs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
//! PROXY protocol v2 parser for extracting real client addresses
|
||||||
|
//! when SmartVPN sits behind a reverse proxy (HAProxy, SmartProxy, etc.).
|
||||||
|
//!
|
||||||
|
//! Spec: <https://www.haproxy.org/download/2.9/doc/proxy-protocol.txt>
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
/// Timeout for reading the PROXY protocol header from a new connection.
|
||||||
|
const PROXY_HEADER_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// The 12-byte PP v2 signature.
|
||||||
|
const PP_V2_SIGNATURE: [u8; 12] = [
|
||||||
|
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Parsed PROXY protocol v2 header.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProxyHeader {
|
||||||
|
/// Real client source address.
|
||||||
|
pub src_addr: SocketAddr,
|
||||||
|
/// Proxy-to-server destination address.
|
||||||
|
pub dst_addr: SocketAddr,
|
||||||
|
/// True if this is a LOCAL command (health check probe from proxy).
|
||||||
|
pub is_local: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse a PROXY protocol v2 header from a TCP stream.
|
||||||
|
///
|
||||||
|
/// Reads exactly the header bytes — the stream is in a clean state for
|
||||||
|
/// WebSocket upgrade afterward. Returns an error on timeout, invalid
|
||||||
|
/// signature, or malformed header.
|
||||||
|
pub async fn read_proxy_header(stream: &mut TcpStream) -> Result<ProxyHeader> {
|
||||||
|
tokio::time::timeout(PROXY_HEADER_TIMEOUT, read_proxy_header_inner(stream))
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow::anyhow!("PROXY protocol header read timed out ({}s)", PROXY_HEADER_TIMEOUT.as_secs()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_proxy_header_inner(stream: &mut TcpStream) -> Result<ProxyHeader> {
|
||||||
|
// Read the 16-byte fixed prefix
|
||||||
|
let mut prefix = [0u8; 16];
|
||||||
|
stream.read_exact(&mut prefix).await?;
|
||||||
|
|
||||||
|
// Validate the 12-byte signature
|
||||||
|
if prefix[..12] != PP_V2_SIGNATURE {
|
||||||
|
anyhow::bail!("Invalid PROXY protocol v2 signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte 12: version (high nibble) | command (low nibble)
|
||||||
|
let version = (prefix[12] & 0xF0) >> 4;
|
||||||
|
let command = prefix[12] & 0x0F;
|
||||||
|
|
||||||
|
if version != 2 {
|
||||||
|
anyhow::bail!("Unsupported PROXY protocol version: {}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte 13: address family (high nibble) | protocol (low nibble)
|
||||||
|
let addr_family = (prefix[13] & 0xF0) >> 4;
|
||||||
|
let _protocol = prefix[13] & 0x0F; // 1 = STREAM (TCP)
|
||||||
|
|
||||||
|
// Bytes 14-15: address data length (big-endian)
|
||||||
|
let addr_len = u16::from_be_bytes([prefix[14], prefix[15]]) as usize;
|
||||||
|
|
||||||
|
// Read the address data
|
||||||
|
let mut addr_data = vec![0u8; addr_len];
|
||||||
|
if addr_len > 0 {
|
||||||
|
stream.read_exact(&mut addr_data).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOCAL command (0x00) = health check, no real address
|
||||||
|
if command == 0x00 {
|
||||||
|
return Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)),
|
||||||
|
dst_addr: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)),
|
||||||
|
is_local: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PROXY command (0x01) — parse address block
|
||||||
|
if command != 0x01 {
|
||||||
|
anyhow::bail!("Unknown PROXY protocol command: {}", command);
|
||||||
|
}
|
||||||
|
|
||||||
|
match addr_family {
|
||||||
|
// AF_INET (IPv4): 4 src + 4 dst + 2 src_port + 2 dst_port = 12 bytes
|
||||||
|
1 => {
|
||||||
|
if addr_data.len() < 12 {
|
||||||
|
anyhow::bail!("IPv4 address block too short: {} bytes", addr_data.len());
|
||||||
|
}
|
||||||
|
let src_ip = Ipv4Addr::new(addr_data[0], addr_data[1], addr_data[2], addr_data[3]);
|
||||||
|
let dst_ip = Ipv4Addr::new(addr_data[4], addr_data[5], addr_data[6], addr_data[7]);
|
||||||
|
let src_port = u16::from_be_bytes([addr_data[8], addr_data[9]]);
|
||||||
|
let dst_port = u16::from_be_bytes([addr_data[10], addr_data[11]]);
|
||||||
|
Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V4(SocketAddrV4::new(src_ip, src_port)),
|
||||||
|
dst_addr: SocketAddr::V4(SocketAddrV4::new(dst_ip, dst_port)),
|
||||||
|
is_local: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// AF_INET6 (IPv6): 16 src + 16 dst + 2 src_port + 2 dst_port = 36 bytes
|
||||||
|
2 => {
|
||||||
|
if addr_data.len() < 36 {
|
||||||
|
anyhow::bail!("IPv6 address block too short: {} bytes", addr_data.len());
|
||||||
|
}
|
||||||
|
let src_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[0..16]).unwrap());
|
||||||
|
let dst_ip = Ipv6Addr::from(<[u8; 16]>::try_from(&addr_data[16..32]).unwrap());
|
||||||
|
let src_port = u16::from_be_bytes([addr_data[32], addr_data[33]]);
|
||||||
|
let dst_port = u16::from_be_bytes([addr_data[34], addr_data[35]]);
|
||||||
|
Ok(ProxyHeader {
|
||||||
|
src_addr: SocketAddr::V6(SocketAddrV6::new(src_ip, src_port, 0, 0)),
|
||||||
|
dst_addr: SocketAddr::V6(SocketAddrV6::new(dst_ip, dst_port, 0, 0)),
|
||||||
|
is_local: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// AF_UNSPEC or unknown
|
||||||
|
_ => {
|
||||||
|
anyhow::bail!("Unsupported address family: {}", addr_family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a PROXY protocol v2 header (for testing / proxy implementations).
|
||||||
|
pub fn build_pp_v2_header(src: SocketAddr, dst: SocketAddr) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&PP_V2_SIGNATURE);
|
||||||
|
|
||||||
|
match (src, dst) {
|
||||||
|
(SocketAddr::V4(s), SocketAddr::V4(d)) => {
|
||||||
|
buf.push(0x21); // version 2 | PROXY command
|
||||||
|
buf.push(0x11); // AF_INET | STREAM
|
||||||
|
buf.extend_from_slice(&12u16.to_be_bytes()); // addr length
|
||||||
|
buf.extend_from_slice(&s.ip().octets());
|
||||||
|
buf.extend_from_slice(&d.ip().octets());
|
||||||
|
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||||
|
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||||
|
}
|
||||||
|
(SocketAddr::V6(s), SocketAddr::V6(d)) => {
|
||||||
|
buf.push(0x21); // version 2 | PROXY command
|
||||||
|
buf.push(0x21); // AF_INET6 | STREAM
|
||||||
|
buf.extend_from_slice(&36u16.to_be_bytes()); // addr length
|
||||||
|
buf.extend_from_slice(&s.ip().octets());
|
||||||
|
buf.extend_from_slice(&d.ip().octets());
|
||||||
|
buf.extend_from_slice(&s.port().to_be_bytes());
|
||||||
|
buf.extend_from_slice(&d.port().to_be_bytes());
|
||||||
|
}
|
||||||
|
_ => panic!("Mismatched address families"),
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a PROXY protocol v2 LOCAL header (health check probe).
|
||||||
|
pub fn build_pp_v2_local() -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
buf.extend_from_slice(&PP_V2_SIGNATURE);
|
||||||
|
buf.push(0x20); // version 2 | LOCAL command
|
||||||
|
buf.push(0x00); // AF_UNSPEC
|
||||||
|
buf.extend_from_slice(&0u16.to_be_bytes()); // no address data
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
/// Helper: create a TCP pair and write data to the client side, then parse from server side.
|
||||||
|
async fn parse_header_from_bytes(header_bytes: &[u8]) -> Result<ProxyHeader> {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let data = header_bytes.to_vec();
|
||||||
|
let client_task = tokio::spawn(async move {
|
||||||
|
let mut client = TcpStream::connect(addr).await.unwrap();
|
||||||
|
client.write_all(&data).await.unwrap();
|
||||||
|
client // keep alive
|
||||||
|
});
|
||||||
|
|
||||||
|
let (mut server_stream, _) = listener.accept().await.unwrap();
|
||||||
|
let result = read_proxy_header(&mut server_stream).await;
|
||||||
|
let _client = client_task.await.unwrap();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_valid_ipv4_header() {
|
||||||
|
let src = "203.0.113.50:12345".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "10.0.0.1:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(!parsed.is_local);
|
||||||
|
assert_eq!(parsed.src_addr, src);
|
||||||
|
assert_eq!(parsed.dst_addr, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_valid_ipv6_header() {
|
||||||
|
let src = "[2001:db8::1]:54321".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "[2001:db8::2]:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(!parsed.is_local);
|
||||||
|
assert_eq!(parsed.src_addr, src);
|
||||||
|
assert_eq!(parsed.dst_addr, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parse_local_command() {
|
||||||
|
let header = build_pp_v2_local();
|
||||||
|
let parsed = parse_header_from_bytes(&header).await.unwrap();
|
||||||
|
assert!(parsed.is_local);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_invalid_signature() {
|
||||||
|
let mut header = build_pp_v2_local();
|
||||||
|
header[0] = 0xFF; // corrupt signature
|
||||||
|
let result = parse_header_from_bytes(&header).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_wrong_version() {
|
||||||
|
let mut header = build_pp_v2_local();
|
||||||
|
header[12] = 0x10; // version 1 instead of 2
|
||||||
|
let result = parse_header_from_bytes(&header).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reject_truncated_header() {
|
||||||
|
// Only 10 bytes — not even the full signature
|
||||||
|
let result = parse_header_from_bytes(&[0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49]).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipv4_header_is_exactly_28_bytes() {
|
||||||
|
let src = "1.2.3.4:80".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "5.6.7.8:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
// 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 12 addrs = 28
|
||||||
|
assert_eq!(header.len(), 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ipv6_header_is_exactly_52_bytes() {
|
||||||
|
let src = "[::1]:80".parse::<SocketAddr>().unwrap();
|
||||||
|
let dst = "[::2]:443".parse::<SocketAddr>().unwrap();
|
||||||
|
let header = build_pp_v2_header(src, dst);
|
||||||
|
// 12 sig + 1 ver/cmd + 1 fam/proto + 2 len + 36 addrs = 52
|
||||||
|
assert_eq!(header.len(), 52);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
/// Configuration for creating a TUN device.
|
/// Configuration for creating a TUN device.
|
||||||
@@ -80,6 +80,26 @@ pub fn check_tun_mtu(packet: &[u8], mtu_config: &crate::mtu::MtuConfig) -> TunMt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract destination IP from a raw IP packet header.
|
||||||
|
pub fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
|
||||||
|
if packet.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = packet[0] >> 4;
|
||||||
|
match version {
|
||||||
|
4 if packet.len() >= 20 => {
|
||||||
|
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
||||||
|
Some(IpAddr::V4(dst))
|
||||||
|
}
|
||||||
|
6 if packet.len() >= 40 => {
|
||||||
|
let mut octets = [0u8; 16];
|
||||||
|
octets.copy_from_slice(&packet[24..40]);
|
||||||
|
Some(IpAddr::V6(Ipv6Addr::from(octets)))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a route.
|
/// Remove a route.
|
||||||
pub async fn remove_route(subnet: &str, device_name: &str) -> Result<()> {
|
pub async fn remove_route(subnet: &str, device_name: &str) -> Result<()> {
|
||||||
let output = tokio::process::Command::new("ip")
|
let output = tokio::process::Command::new("ip")
|
||||||
|
|||||||
720
rust/src/userspace_nat.rs
Normal file
720
rust/src/userspace_nat.rs
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use smoltcp::iface::{Config, Interface, SocketHandle, SocketSet};
|
||||||
|
use smoltcp::phy::{self, Device, DeviceCapabilities, Medium};
|
||||||
|
use smoltcp::socket::{tcp, udp};
|
||||||
|
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpStream, UdpSocket};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::acl;
|
||||||
|
use crate::server::{DestinationPolicyConfig, ServerState};
|
||||||
|
use crate::tunnel;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Virtual IP device for smoltcp
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct VirtualIpDevice {
|
||||||
|
rx_queue: VecDeque<Vec<u8>>,
|
||||||
|
tx_queue: VecDeque<Vec<u8>>,
|
||||||
|
mtu: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VirtualIpDevice {
|
||||||
|
pub fn new(mtu: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
rx_queue: VecDeque::new(),
|
||||||
|
tx_queue: VecDeque::new(),
|
||||||
|
mtu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inject_packet(&mut self, packet: Vec<u8>) {
|
||||||
|
self.rx_queue.push_back(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_tx(&mut self) -> impl Iterator<Item = Vec<u8>> + '_ {
|
||||||
|
self.tx_queue.drain(..)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VirtualRxToken {
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl phy::RxToken for VirtualRxToken {
|
||||||
|
fn consume<R, F>(self, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&[u8]) -> R,
|
||||||
|
{
|
||||||
|
f(&self.buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VirtualTxToken<'a> {
|
||||||
|
queue: &'a mut VecDeque<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> phy::TxToken for VirtualTxToken<'a> {
|
||||||
|
fn consume<R, F>(self, len: usize, f: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut [u8]) -> R,
|
||||||
|
{
|
||||||
|
let mut buffer = vec![0u8; len];
|
||||||
|
let result = f(&mut buffer);
|
||||||
|
self.queue.push_back(buffer);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Device for VirtualIpDevice {
|
||||||
|
type RxToken<'a> = VirtualRxToken;
|
||||||
|
type TxToken<'a> = VirtualTxToken<'a>;
|
||||||
|
|
||||||
|
fn receive(
|
||||||
|
&mut self,
|
||||||
|
_timestamp: smoltcp::time::Instant,
|
||||||
|
) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
||||||
|
self.rx_queue.pop_front().map(|buffer| {
|
||||||
|
let rx = VirtualRxToken { buffer };
|
||||||
|
let tx = VirtualTxToken {
|
||||||
|
queue: &mut self.tx_queue,
|
||||||
|
};
|
||||||
|
(rx, tx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transmit(&mut self, _timestamp: smoltcp::time::Instant) -> Option<Self::TxToken<'_>> {
|
||||||
|
Some(VirtualTxToken {
|
||||||
|
queue: &mut self.tx_queue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> DeviceCapabilities {
|
||||||
|
let mut caps = DeviceCapabilities::default();
|
||||||
|
caps.medium = Medium::Ip;
|
||||||
|
caps.max_transmission_unit = self.mtu;
|
||||||
|
caps.max_burst_size = Some(1);
|
||||||
|
caps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||||
|
struct SessionKey {
|
||||||
|
src_ip: Ipv4Addr,
|
||||||
|
src_port: u16,
|
||||||
|
dst_ip: Ipv4Addr,
|
||||||
|
dst_port: u16,
|
||||||
|
protocol: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TcpSession {
|
||||||
|
smoltcp_handle: SocketHandle,
|
||||||
|
bridge_data_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
client_ip: Ipv4Addr,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UdpSession {
|
||||||
|
smoltcp_handle: SocketHandle,
|
||||||
|
bridge_data_tx: mpsc::Sender<Vec<u8>>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
client_ip: Ipv4Addr,
|
||||||
|
last_activity: tokio::time::Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BridgeMessage {
|
||||||
|
TcpData { key: SessionKey, data: Vec<u8> },
|
||||||
|
TcpClosed { key: SessionKey },
|
||||||
|
UdpData { key: SessionKey, data: Vec<u8> },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IP packet parsing helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn parse_ipv4_header(packet: &[u8]) -> Option<(u8, Ipv4Addr, Ipv4Addr, u8)> {
|
||||||
|
if packet.len() < 20 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = packet[0] >> 4;
|
||||||
|
if version != 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ihl = (packet[0] & 0x0F) as usize * 4;
|
||||||
|
let protocol = packet[9];
|
||||||
|
let src = Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
|
||||||
|
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
||||||
|
Some((ihl as u8, src, dst, protocol))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_tcp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16, u8)> {
|
||||||
|
if packet.len() < ihl + 14 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
|
||||||
|
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
|
||||||
|
let flags = packet[ihl + 13];
|
||||||
|
Some((src_port, dst_port, flags))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_udp_ports(packet: &[u8], ihl: usize) -> Option<(u16, u16)> {
|
||||||
|
if packet.len() < ihl + 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let src_port = u16::from_be_bytes([packet[ihl], packet[ihl + 1]]);
|
||||||
|
let dst_port = u16::from_be_bytes([packet[ihl + 2], packet[ihl + 3]]);
|
||||||
|
Some((src_port, dst_port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NAT Engine
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct NatEngine {
|
||||||
|
device: VirtualIpDevice,
|
||||||
|
iface: Interface,
|
||||||
|
sockets: SocketSet<'static>,
|
||||||
|
tcp_sessions: HashMap<SessionKey, TcpSession>,
|
||||||
|
udp_sessions: HashMap<SessionKey, UdpSession>,
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
bridge_rx: mpsc::Receiver<BridgeMessage>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
start_time: std::time::Instant,
|
||||||
|
/// When true, outbound TCP connections prepend PROXY protocol v2 headers
|
||||||
|
/// with the VPN client's tunnel IP as source address.
|
||||||
|
proxy_protocol: bool,
|
||||||
|
/// Destination routing policy: forceTarget, block, or allow.
|
||||||
|
destination_policy: Option<DestinationPolicyConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of destination policy evaluation.
|
||||||
|
enum DestinationAction {
|
||||||
|
/// Connect to the original destination.
|
||||||
|
PassThrough(SocketAddr),
|
||||||
|
/// Redirect to a target IP, preserving original port.
|
||||||
|
ForceTarget(SocketAddr),
|
||||||
|
/// Drop the packet silently.
|
||||||
|
Drop,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatEngine {
|
||||||
|
pub fn new(gateway_ip: Ipv4Addr, mtu: usize, state: Arc<ServerState>, proxy_protocol: bool, destination_policy: Option<DestinationPolicyConfig>) -> Self {
|
||||||
|
let mut device = VirtualIpDevice::new(mtu);
|
||||||
|
let config = Config::new(HardwareAddress::Ip);
|
||||||
|
let now = smoltcp::time::Instant::from_millis(0);
|
||||||
|
let mut iface = Interface::new(config, &mut device, now);
|
||||||
|
|
||||||
|
// Accept packets to ANY destination IP (essential for NAT)
|
||||||
|
iface.set_any_ip(true);
|
||||||
|
|
||||||
|
// Assign the gateway IP as the interface address
|
||||||
|
iface.update_ip_addrs(|addrs| {
|
||||||
|
addrs
|
||||||
|
.push(IpCidr::new(IpAddress::Ipv4(gateway_ip.into()), 24))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a default route so smoltcp knows where to send packets
|
||||||
|
iface.routes_mut().add_default_ipv4_route(gateway_ip.into()).unwrap();
|
||||||
|
|
||||||
|
let sockets = SocketSet::new(Vec::with_capacity(256));
|
||||||
|
let (bridge_tx, bridge_rx) = mpsc::channel(4096);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
device,
|
||||||
|
iface,
|
||||||
|
sockets,
|
||||||
|
tcp_sessions: HashMap::new(),
|
||||||
|
udp_sessions: HashMap::new(),
|
||||||
|
state,
|
||||||
|
bridge_rx,
|
||||||
|
bridge_tx,
|
||||||
|
start_time: std::time::Instant::now(),
|
||||||
|
proxy_protocol,
|
||||||
|
destination_policy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smoltcp_now(&self) -> smoltcp::time::Instant {
|
||||||
|
smoltcp::time::Instant::from_millis(self.start_time.elapsed().as_millis() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate destination policy for a packet's destination IP.
|
||||||
|
fn evaluate_destination(&self, dst_ip: Ipv4Addr, dst_port: u16) -> DestinationAction {
|
||||||
|
let policy = match &self.destination_policy {
|
||||||
|
Some(p) => p,
|
||||||
|
None => return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Block list wins (deny overrides allow)
|
||||||
|
if let Some(ref block_list) = policy.block_list {
|
||||||
|
if !block_list.is_empty() && acl::ip_matches_any(dst_ip, block_list) {
|
||||||
|
return DestinationAction::Drop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Allow list — pass through directly
|
||||||
|
if let Some(ref allow_list) = policy.allow_list {
|
||||||
|
if !allow_list.is_empty() && acl::ip_matches_any(dst_ip, allow_list) {
|
||||||
|
return DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default action
|
||||||
|
match policy.default.as_str() {
|
||||||
|
"forceTarget" => {
|
||||||
|
let target_ip = policy.target.as_deref()
|
||||||
|
.and_then(|t| t.parse::<Ipv4Addr>().ok())
|
||||||
|
.unwrap_or(Ipv4Addr::LOCALHOST);
|
||||||
|
DestinationAction::ForceTarget(SocketAddr::new(target_ip.into(), dst_port))
|
||||||
|
}
|
||||||
|
"block" => DestinationAction::Drop,
|
||||||
|
_ => DestinationAction::PassThrough(SocketAddr::new(dst_ip.into(), dst_port)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inject a raw IP packet from a VPN client and handle new session creation.
|
||||||
|
fn inject_packet(&mut self, packet: Vec<u8>) {
|
||||||
|
let Some((ihl, src_ip, dst_ip, protocol)) = parse_ipv4_header(&packet) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ihl = ihl as usize;
|
||||||
|
|
||||||
|
match protocol {
|
||||||
|
6 => {
|
||||||
|
// TCP
|
||||||
|
let Some((src_port, dst_port, flags)) = parse_tcp_ports(&packet, ihl) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = SessionKey {
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
dst_ip,
|
||||||
|
dst_port,
|
||||||
|
protocol: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
// SYN without ACK = new connection
|
||||||
|
let is_syn = (flags & 0x02) != 0 && (flags & 0x10) == 0;
|
||||||
|
if is_syn && !self.tcp_sessions.contains_key(&key) {
|
||||||
|
match self.evaluate_destination(dst_ip, dst_port) {
|
||||||
|
DestinationAction::Drop => {
|
||||||
|
debug!("NAT: destination policy blocked TCP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DestinationAction::PassThrough(addr) => self.create_tcp_session(&key, addr),
|
||||||
|
DestinationAction::ForceTarget(addr) => self.create_tcp_session(&key, addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
17 => {
|
||||||
|
// UDP
|
||||||
|
let Some((src_port, dst_port)) = parse_udp_ports(&packet, ihl) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let key = SessionKey {
|
||||||
|
src_ip,
|
||||||
|
src_port,
|
||||||
|
dst_ip,
|
||||||
|
dst_port,
|
||||||
|
protocol: 17,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !self.udp_sessions.contains_key(&key) {
|
||||||
|
match self.evaluate_destination(dst_ip, dst_port) {
|
||||||
|
DestinationAction::Drop => {
|
||||||
|
debug!("NAT: destination policy blocked UDP {}:{} -> {}:{}", src_ip, src_port, dst_ip, dst_port);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DestinationAction::PassThrough(addr) => self.create_udp_session(&key, addr),
|
||||||
|
DestinationAction::ForceTarget(addr) => self.create_udp_session(&key, addr),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_activity for existing sessions
|
||||||
|
if let Some(session) = self.udp_sessions.get_mut(&key) {
|
||||||
|
session.last_activity = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// ICMP and other protocols — not forwarded in socket mode
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.device.inject_packet(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tcp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) {
|
||||||
|
// Create smoltcp TCP socket
|
||||||
|
let tcp_rx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||||
|
let tcp_tx_buf = tcp::SocketBuffer::new(vec![0u8; 65535]);
|
||||||
|
let mut socket = tcp::Socket::new(tcp_rx_buf, tcp_tx_buf);
|
||||||
|
|
||||||
|
// Listen on the destination address so smoltcp accepts the SYN
|
||||||
|
let endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.dst_ip.into()),
|
||||||
|
key.dst_port,
|
||||||
|
);
|
||||||
|
if socket.listen(endpoint).is_err() {
|
||||||
|
warn!("NAT: failed to listen on {:?}", endpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = self.sockets.add(socket);
|
||||||
|
|
||||||
|
// Channel for sending data from NAT engine to bridge task
|
||||||
|
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
|
||||||
|
let session = TcpSession {
|
||||||
|
smoltcp_handle: handle,
|
||||||
|
bridge_data_tx: data_tx,
|
||||||
|
client_ip: key.src_ip,
|
||||||
|
};
|
||||||
|
self.tcp_sessions.insert(key.clone(), session);
|
||||||
|
|
||||||
|
// Spawn bridge task that connects to the resolved destination
|
||||||
|
let bridge_tx = self.bridge_tx.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
let proxy_protocol = self.proxy_protocol;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tcp_bridge_task(key_clone, data_rx, bridge_tx, proxy_protocol, connect_addr).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"NAT: new TCP session {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_udp_session(&mut self, key: &SessionKey, connect_addr: SocketAddr) {
|
||||||
|
// Create smoltcp UDP socket
|
||||||
|
let udp_rx_buf = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; 65535],
|
||||||
|
);
|
||||||
|
let udp_tx_buf = udp::PacketBuffer::new(
|
||||||
|
vec![udp::PacketMetadata::EMPTY; 32],
|
||||||
|
vec![0u8; 65535],
|
||||||
|
);
|
||||||
|
let mut socket = udp::Socket::new(udp_rx_buf, udp_tx_buf);
|
||||||
|
|
||||||
|
let endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.dst_ip.into()),
|
||||||
|
key.dst_port,
|
||||||
|
);
|
||||||
|
if socket.bind(endpoint).is_err() {
|
||||||
|
warn!("NAT: failed to bind UDP on {:?}", endpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = self.sockets.add(socket);
|
||||||
|
|
||||||
|
let (data_tx, data_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
|
||||||
|
let session = UdpSession {
|
||||||
|
smoltcp_handle: handle,
|
||||||
|
bridge_data_tx: data_tx,
|
||||||
|
client_ip: key.src_ip,
|
||||||
|
last_activity: tokio::time::Instant::now(),
|
||||||
|
};
|
||||||
|
self.udp_sessions.insert(key.clone(), session);
|
||||||
|
|
||||||
|
let bridge_tx = self.bridge_tx.clone();
|
||||||
|
let key_clone = key.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
udp_bridge_task(key_clone, data_rx, bridge_tx, connect_addr).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"NAT: new UDP session {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll smoltcp, bridge data between smoltcp sockets and bridge tasks,
|
||||||
|
/// and dispatch outgoing packets to VPN clients.
|
||||||
|
async fn process(&mut self) {
|
||||||
|
let now = self.smoltcp_now();
|
||||||
|
self.iface
|
||||||
|
.poll(now, &mut self.device, &mut self.sockets);
|
||||||
|
|
||||||
|
// Bridge: read data from smoltcp TCP sockets → send to bridge tasks
|
||||||
|
let mut closed_tcp: Vec<SessionKey> = Vec::new();
|
||||||
|
for (key, session) in &self.tcp_sessions {
|
||||||
|
let socket = self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
if socket.can_recv() {
|
||||||
|
let _ = socket.recv(|data| {
|
||||||
|
let _ = session.bridge_data_tx.try_send(data.to_vec());
|
||||||
|
(data.len(), ())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Detect closed connections
|
||||||
|
if !socket.is_open() && !socket.is_listening() {
|
||||||
|
closed_tcp.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up closed TCP sessions
|
||||||
|
for key in closed_tcp {
|
||||||
|
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||||
|
self.sockets.remove(session.smoltcp_handle);
|
||||||
|
debug!("NAT: TCP session closed {}:{} -> {}:{}", key.src_ip, key.src_port, key.dst_ip, key.dst_port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge: read data from smoltcp UDP sockets → send to bridge tasks
|
||||||
|
for (_key, session) in &self.udp_sessions {
|
||||||
|
let socket = self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
|
||||||
|
while let Ok((data, _meta)) = socket.recv() {
|
||||||
|
let _ = session.bridge_data_tx.try_send(data.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch outgoing packets from smoltcp to VPN clients
|
||||||
|
let routes = self.state.tun_routes.read().await;
|
||||||
|
for packet in self.device.drain_tx() {
|
||||||
|
if let Some(std::net::IpAddr::V4(dst_ip)) = tunnel::extract_dst_ip(&packet) {
|
||||||
|
if let Some(sender) = routes.get(&dst_ip) {
|
||||||
|
let _ = sender.try_send(packet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_bridge_message(&mut self, msg: BridgeMessage) {
|
||||||
|
match msg {
|
||||||
|
BridgeMessage::TcpData { key, data } => {
|
||||||
|
if let Some(session) = self.tcp_sessions.get(&key) {
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
if socket.can_send() {
|
||||||
|
let _ = socket.send_slice(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessage::TcpClosed { key } => {
|
||||||
|
if let Some(session) = self.tcp_sessions.remove(&key) {
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<tcp::Socket>(session.smoltcp_handle);
|
||||||
|
socket.close();
|
||||||
|
// Don't remove from SocketSet yet — let smoltcp send FIN
|
||||||
|
// It will be cleaned up in process() when is_open() returns false
|
||||||
|
self.tcp_sessions.insert(key, session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BridgeMessage::UdpData { key, data } => {
|
||||||
|
if let Some(session) = self.udp_sessions.get_mut(&key) {
|
||||||
|
session.last_activity = tokio::time::Instant::now();
|
||||||
|
let socket =
|
||||||
|
self.sockets.get_mut::<udp::Socket>(session.smoltcp_handle);
|
||||||
|
let dst_endpoint = IpEndpoint::new(
|
||||||
|
IpAddress::Ipv4(key.src_ip.into()),
|
||||||
|
key.src_port,
|
||||||
|
);
|
||||||
|
// Send response: from the "server" (dst) back to the "client" (src)
|
||||||
|
let _ = socket.send_slice(&data, dst_endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_idle_udp_sessions(&mut self) {
|
||||||
|
let timeout = Duration::from_secs(60);
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let expired: Vec<SessionKey> = self
|
||||||
|
.udp_sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| now.duration_since(s.last_activity) > timeout)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for key in expired {
|
||||||
|
if let Some(session) = self.udp_sessions.remove(&key) {
|
||||||
|
self.sockets.remove(session.smoltcp_handle);
|
||||||
|
debug!(
|
||||||
|
"NAT: UDP session timed out {}:{} -> {}:{}",
|
||||||
|
key.src_ip, key.src_port, key.dst_ip, key.dst_port
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main async event loop for the NAT engine.
|
||||||
|
pub async fn run(
|
||||||
|
mut self,
|
||||||
|
mut packet_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
mut shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
) -> Result<()> {
|
||||||
|
info!("Userspace NAT engine started");
|
||||||
|
let mut timer = tokio::time::interval(Duration::from_millis(50));
|
||||||
|
let mut cleanup_timer = tokio::time::interval(Duration::from_secs(10));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(packet) = packet_rx.recv() => {
|
||||||
|
self.inject_packet(packet);
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
Some(msg) = self.bridge_rx.recv() => {
|
||||||
|
self.handle_bridge_message(msg);
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
_ = timer.tick() => {
|
||||||
|
// Periodic poll for smoltcp maintenance (TCP retransmit, etc.)
|
||||||
|
self.process().await;
|
||||||
|
}
|
||||||
|
_ = cleanup_timer.tick() => {
|
||||||
|
self.cleanup_idle_udp_sessions();
|
||||||
|
}
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("Userspace NAT engine shutting down");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Bridge tasks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async fn tcp_bridge_task(
|
||||||
|
key: SessionKey,
|
||||||
|
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
proxy_protocol: bool,
|
||||||
|
connect_addr: SocketAddr,
|
||||||
|
) {
|
||||||
|
// Connect to resolved destination (may differ from key.dst_ip if policy rewrote it)
|
||||||
|
let stream = match tokio::time::timeout(Duration::from_secs(30), TcpStream::connect(connect_addr)).await
|
||||||
|
{
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
debug!("NAT TCP connect to {} failed: {}", connect_addr, e);
|
||||||
|
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
debug!("NAT TCP connect to {} timed out", connect_addr);
|
||||||
|
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut reader, mut writer) = stream.into_split();
|
||||||
|
|
||||||
|
// Send PROXY protocol v2 header with VPN client's tunnel IP as source
|
||||||
|
if proxy_protocol {
|
||||||
|
let src = SocketAddr::new(key.src_ip.into(), key.src_port);
|
||||||
|
let dst = SocketAddr::new(key.dst_ip.into(), key.dst_port);
|
||||||
|
let pp_header = crate::proxy_protocol::build_pp_v2_header(src, dst);
|
||||||
|
if let Err(e) = writer.write_all(&pp_header).await {
|
||||||
|
debug!("NAT: failed to send PP v2 header to {}: {}", connect_addr, e);
|
||||||
|
let _ = bridge_tx.send(BridgeMessage::TcpClosed { key }).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from real socket → send to NAT engine
|
||||||
|
let bridge_tx2 = bridge_tx.clone();
|
||||||
|
let key2 = key.clone();
|
||||||
|
let read_task = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
match reader.read(&mut buf).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
if bridge_tx2
|
||||||
|
.send(BridgeMessage::TcpData {
|
||||||
|
key: key2.clone(),
|
||||||
|
data: buf[..n].to_vec(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = bridge_tx2
|
||||||
|
.send(BridgeMessage::TcpClosed { key: key2 })
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Receive from NAT engine → write to real socket
|
||||||
|
while let Some(data) = data_rx.recv().await {
|
||||||
|
if writer.write_all(&data).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
read_task.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn udp_bridge_task(
|
||||||
|
key: SessionKey,
|
||||||
|
mut data_rx: mpsc::Receiver<Vec<u8>>,
|
||||||
|
bridge_tx: mpsc::Sender<BridgeMessage>,
|
||||||
|
connect_addr: SocketAddr,
|
||||||
|
) {
|
||||||
|
let socket = match UdpSocket::bind("0.0.0.0:0").await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("NAT UDP bind failed: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dest = connect_addr;
|
||||||
|
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
let socket2 = socket.clone();
|
||||||
|
let bridge_tx2 = bridge_tx.clone();
|
||||||
|
let key2 = key.clone();
|
||||||
|
|
||||||
|
// Read responses from real socket
|
||||||
|
let read_task = tokio::spawn(async move {
|
||||||
|
let mut buf = vec![0u8; 65536];
|
||||||
|
loop {
|
||||||
|
match socket2.recv_from(&mut buf).await {
|
||||||
|
Ok((n, _src)) => {
|
||||||
|
if bridge_tx2
|
||||||
|
.send(BridgeMessage::UdpData {
|
||||||
|
key: key2.clone(),
|
||||||
|
data: buf[..n].to_vec(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward data from NAT engine to real socket
|
||||||
|
while let Some(data) = data_rx.recv().await {
|
||||||
|
let _ = socket.send_to(&data, dest).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
read_task.abort();
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -17,7 +15,7 @@ use tokio::net::UdpSocket;
|
|||||||
use tokio::sync::{mpsc, oneshot, RwLock};
|
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::network;
|
use crate::server::{ClientInfo, ForwardingEngine, ServerState};
|
||||||
use crate::tunnel::{self, TunConfig};
|
use crate::tunnel::{self, TunConfig};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -29,9 +27,6 @@ const WG_BUFFER_SIZE: usize = MAX_UDP_PACKET;
|
|||||||
/// Minimum dst buffer size for boringtun encapsulate/decapsulate
|
/// Minimum dst buffer size for boringtun encapsulate/decapsulate
|
||||||
const _MIN_DST_BUF: usize = 148;
|
const _MIN_DST_BUF: usize = 148;
|
||||||
const TIMER_TICK_MS: u64 = 100;
|
const TIMER_TICK_MS: u64 = 100;
|
||||||
const DEFAULT_WG_PORT: u16 = 51820;
|
|
||||||
const DEFAULT_TUN_ADDRESS: &str = "10.8.0.1";
|
|
||||||
const DEFAULT_TUN_NETMASK: &str = "255.255.255.0";
|
|
||||||
const DEFAULT_MTU: u16 = 1420;
|
const DEFAULT_MTU: u16 = 1420;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -51,27 +46,6 @@ pub struct WgPeerConfig {
|
|||||||
pub persistent_keepalive: Option<u16>,
|
pub persistent_keepalive: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct WgServerConfig {
|
|
||||||
pub private_key: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub listen_port: Option<u16>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tun_address: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tun_netmask: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub mtu: Option<u16>,
|
|
||||||
pub peers: Vec<WgPeerConfig>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dns: Option<Vec<String>>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub enable_nat: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub subnet: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WgClientConfig {
|
pub struct WgClientConfig {
|
||||||
@@ -111,17 +85,6 @@ pub struct WgPeerInfo {
|
|||||||
pub stats: WgPeerStats,
|
pub stats: WgPeerStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct WgServerStats {
|
|
||||||
pub total_bytes_sent: u64,
|
|
||||||
pub total_bytes_received: u64,
|
|
||||||
pub total_packets_sent: u64,
|
|
||||||
pub total_packets_received: u64,
|
|
||||||
pub active_peers: usize,
|
|
||||||
pub uptime_seconds: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Key generation and parsing
|
// Key generation and parsing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -228,31 +191,11 @@ impl AllowedIp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract destination IP from an IP packet header.
|
|
||||||
fn extract_dst_ip(packet: &[u8]) -> Option<IpAddr> {
|
|
||||||
if packet.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let version = packet[0] >> 4;
|
|
||||||
match version {
|
|
||||||
4 if packet.len() >= 20 => {
|
|
||||||
let dst = Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
|
|
||||||
Some(IpAddr::V4(dst))
|
|
||||||
}
|
|
||||||
6 if packet.len() >= 40 => {
|
|
||||||
let mut octets = [0u8; 16];
|
|
||||||
octets.copy_from_slice(&packet[24..40]);
|
|
||||||
Some(IpAddr::V6(Ipv6Addr::from(octets)))
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Dynamic peer management commands
|
// Dynamic peer management commands
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
enum WgCommand {
|
pub enum WgCommand {
|
||||||
AddPeer(WgPeerConfig, oneshot::Sender<Result<()>>),
|
AddPeer(WgPeerConfig, oneshot::Sender<Result<()>>),
|
||||||
RemovePeer(String, oneshot::Sender<Result<()>>),
|
RemovePeer(String, oneshot::Sender<Result<()>>),
|
||||||
}
|
}
|
||||||
@@ -277,451 +220,6 @@ impl PeerState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// WgServer
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
pub struct WgServer {
|
|
||||||
shutdown_tx: Option<oneshot::Sender<()>>,
|
|
||||||
command_tx: Option<mpsc::Sender<WgCommand>>,
|
|
||||||
shared_stats: Arc<RwLock<HashMap<String, WgPeerStats>>>,
|
|
||||||
server_stats: Arc<RwLock<WgServerStats>>,
|
|
||||||
started_at: Option<Instant>,
|
|
||||||
listen_port: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WgServer {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
shutdown_tx: None,
|
|
||||||
command_tx: None,
|
|
||||||
shared_stats: Arc::new(RwLock::new(HashMap::new())),
|
|
||||||
server_stats: Arc::new(RwLock::new(WgServerStats::default())),
|
|
||||||
started_at: None,
|
|
||||||
listen_port: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.shutdown_tx.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start(&mut self, config: WgServerConfig) -> Result<()> {
|
|
||||||
if self.is_running() {
|
|
||||||
return Err(anyhow!("WireGuard server is already running"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let listen_port = config.listen_port.unwrap_or(DEFAULT_WG_PORT);
|
|
||||||
let tun_address = config
|
|
||||||
.tun_address
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_TUN_ADDRESS);
|
|
||||||
let tun_netmask = config
|
|
||||||
.tun_netmask
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(DEFAULT_TUN_NETMASK);
|
|
||||||
let mtu = config.mtu.unwrap_or(DEFAULT_MTU);
|
|
||||||
|
|
||||||
// Parse server private key
|
|
||||||
let server_private = parse_private_key(&config.private_key)?;
|
|
||||||
let server_public = PublicKey::from(&server_private);
|
|
||||||
|
|
||||||
// Create rate limiter for DDoS protection
|
|
||||||
let rate_limiter = Arc::new(RateLimiter::new(&server_public, TIMER_TICK_MS as u64));
|
|
||||||
|
|
||||||
// Build peer state
|
|
||||||
let peer_index = AtomicU32::new(0);
|
|
||||||
let mut peers: Vec<PeerState> = Vec::with_capacity(config.peers.len());
|
|
||||||
|
|
||||||
for peer_config in &config.peers {
|
|
||||||
let peer_public = parse_public_key(&peer_config.public_key)?;
|
|
||||||
let psk = match &peer_config.preshared_key {
|
|
||||||
Some(k) => Some(parse_preshared_key(k)?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
let idx = peer_index.fetch_add(1, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Clone the private key for each Tunn (StaticSecret doesn't implement Clone,
|
|
||||||
// so re-parse from config)
|
|
||||||
let priv_copy = parse_private_key(&config.private_key)?;
|
|
||||||
|
|
||||||
let tunn = Tunn::new(
|
|
||||||
priv_copy,
|
|
||||||
peer_public,
|
|
||||||
psk,
|
|
||||||
peer_config.persistent_keepalive,
|
|
||||||
idx,
|
|
||||||
Some(rate_limiter.clone()),
|
|
||||||
);
|
|
||||||
|
|
||||||
let allowed_ips: Vec<AllowedIp> = peer_config
|
|
||||||
.allowed_ips
|
|
||||||
.iter()
|
|
||||||
.map(|cidr| AllowedIp::parse(cidr))
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let endpoint = match &peer_config.endpoint {
|
|
||||||
Some(ep) => Some(ep.parse::<SocketAddr>()?),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
peers.push(PeerState {
|
|
||||||
tunn,
|
|
||||||
public_key_b64: peer_config.public_key.clone(),
|
|
||||||
allowed_ips,
|
|
||||||
endpoint,
|
|
||||||
persistent_keepalive: peer_config.persistent_keepalive,
|
|
||||||
stats: WgPeerStats::default(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create TUN device
|
|
||||||
let tun_config = TunConfig {
|
|
||||||
name: "wg0".to_string(),
|
|
||||||
address: tun_address.parse()?,
|
|
||||||
netmask: tun_netmask.parse()?,
|
|
||||||
mtu,
|
|
||||||
};
|
|
||||||
let tun_device = tunnel::create_tun(&tun_config)?;
|
|
||||||
info!("WireGuard TUN device created: {}", tun_config.name);
|
|
||||||
|
|
||||||
// Bind UDP socket
|
|
||||||
let udp_socket = UdpSocket::bind(format!("0.0.0.0:{}", listen_port)).await?;
|
|
||||||
info!("WireGuard server listening on UDP port {}", listen_port);
|
|
||||||
|
|
||||||
// Enable IP forwarding and NAT if requested
|
|
||||||
if config.enable_nat.unwrap_or(false) {
|
|
||||||
network::enable_ip_forwarding()?;
|
|
||||||
let subnet = config
|
|
||||||
.subnet
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("10.8.0.0/24");
|
|
||||||
let iface = network::get_default_interface()?;
|
|
||||||
network::setup_nat(subnet, &iface).await?;
|
|
||||||
info!("NAT enabled for subnet {} via {}", subnet, iface);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channels
|
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
|
||||||
let (command_tx, command_rx) = mpsc::channel::<WgCommand>(32);
|
|
||||||
|
|
||||||
let shared_stats = self.shared_stats.clone();
|
|
||||||
let server_stats = self.server_stats.clone();
|
|
||||||
let started_at = Instant::now();
|
|
||||||
|
|
||||||
// Initialize shared stats
|
|
||||||
{
|
|
||||||
let mut stats = shared_stats.write().await;
|
|
||||||
for peer in &peers {
|
|
||||||
stats.insert(peer.public_key_b64.clone(), WgPeerStats::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spawn the event loop
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(e) = wg_server_loop(
|
|
||||||
udp_socket,
|
|
||||||
tun_device,
|
|
||||||
peers,
|
|
||||||
peer_index,
|
|
||||||
rate_limiter,
|
|
||||||
config.private_key.clone(),
|
|
||||||
shared_stats,
|
|
||||||
server_stats,
|
|
||||||
started_at,
|
|
||||||
shutdown_rx,
|
|
||||||
command_rx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("WireGuard server loop error: {}", e);
|
|
||||||
}
|
|
||||||
info!("WireGuard server loop exited");
|
|
||||||
});
|
|
||||||
|
|
||||||
self.shutdown_tx = Some(shutdown_tx);
|
|
||||||
self.command_tx = Some(command_tx);
|
|
||||||
self.started_at = Some(started_at);
|
|
||||||
self.listen_port = Some(listen_port);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn stop(&mut self) -> Result<()> {
|
|
||||||
if let Some(tx) = self.shutdown_tx.take() {
|
|
||||||
let _ = tx.send(());
|
|
||||||
}
|
|
||||||
self.command_tx = None;
|
|
||||||
self.started_at = None;
|
|
||||||
self.listen_port = None;
|
|
||||||
info!("WireGuard server stopped");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_status(&self) -> serde_json::Value {
|
|
||||||
if self.is_running() {
|
|
||||||
serde_json::json!({
|
|
||||||
"state": "running",
|
|
||||||
"listenPort": self.listen_port,
|
|
||||||
"uptimeSeconds": self.started_at.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
serde_json::json!({ "state": "stopped" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_statistics(&self) -> serde_json::Value {
|
|
||||||
let mut stats = self.server_stats.write().await;
|
|
||||||
if let Some(started) = self.started_at {
|
|
||||||
stats.uptime_seconds = started.elapsed().as_secs_f64();
|
|
||||||
}
|
|
||||||
// Aggregate from peer stats
|
|
||||||
let peer_stats = self.shared_stats.read().await;
|
|
||||||
stats.active_peers = peer_stats.len();
|
|
||||||
stats.total_bytes_sent = peer_stats.values().map(|s| s.bytes_sent).sum();
|
|
||||||
stats.total_bytes_received = peer_stats.values().map(|s| s.bytes_received).sum();
|
|
||||||
stats.total_packets_sent = peer_stats.values().map(|s| s.packets_sent).sum();
|
|
||||||
stats.total_packets_received = peer_stats.values().map(|s| s.packets_received).sum();
|
|
||||||
serde_json::to_value(&*stats).unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_peers(&self) -> Vec<WgPeerInfo> {
|
|
||||||
let stats = self.shared_stats.read().await;
|
|
||||||
stats
|
|
||||||
.iter()
|
|
||||||
.map(|(key, s)| WgPeerInfo {
|
|
||||||
public_key: key.clone(),
|
|
||||||
allowed_ips: vec![], // populated from event loop snapshots
|
|
||||||
endpoint: None,
|
|
||||||
persistent_keepalive: None,
|
|
||||||
stats: s.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_peer(&self, config: WgPeerConfig) -> Result<()> {
|
|
||||||
let tx = self
|
|
||||||
.command_tx
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("Server not running"))?;
|
|
||||||
let (resp_tx, resp_rx) = oneshot::channel();
|
|
||||||
tx.send(WgCommand::AddPeer(config, resp_tx))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("Server event loop closed"))?;
|
|
||||||
resp_rx.await.map_err(|_| anyhow!("No response"))?
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove_peer(&self, public_key: &str) -> Result<()> {
|
|
||||||
let tx = self
|
|
||||||
.command_tx
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow!("Server not running"))?;
|
|
||||||
let (resp_tx, resp_rx) = oneshot::channel();
|
|
||||||
tx.send(WgCommand::RemovePeer(public_key.to_string(), resp_tx))
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow!("Server event loop closed"))?;
|
|
||||||
resp_rx.await.map_err(|_| anyhow!("No response"))?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Server event loop
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async fn wg_server_loop(
|
|
||||||
udp_socket: UdpSocket,
|
|
||||||
tun_device: tun::AsyncDevice,
|
|
||||||
mut peers: Vec<PeerState>,
|
|
||||||
peer_index: AtomicU32,
|
|
||||||
rate_limiter: Arc<RateLimiter>,
|
|
||||||
server_private_key_b64: String,
|
|
||||||
shared_stats: Arc<RwLock<HashMap<String, WgPeerStats>>>,
|
|
||||||
_server_stats: Arc<RwLock<WgServerStats>>,
|
|
||||||
_started_at: Instant,
|
|
||||||
mut shutdown_rx: oneshot::Receiver<()>,
|
|
||||||
mut command_rx: mpsc::Receiver<WgCommand>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut udp_buf = vec![0u8; MAX_UDP_PACKET];
|
|
||||||
let mut tun_buf = vec![0u8; MAX_UDP_PACKET];
|
|
||||||
let mut dst_buf = vec![0u8; WG_BUFFER_SIZE];
|
|
||||||
let mut timer = tokio::time::interval(std::time::Duration::from_millis(TIMER_TICK_MS));
|
|
||||||
|
|
||||||
// Split TUN for concurrent read/write in select
|
|
||||||
let (mut tun_reader, mut tun_writer) = tokio::io::split(tun_device);
|
|
||||||
|
|
||||||
// Stats sync interval
|
|
||||||
let mut stats_timer =
|
|
||||||
tokio::time::interval(std::time::Duration::from_secs(1));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
// --- UDP receive ---
|
|
||||||
result = udp_socket.recv_from(&mut udp_buf) => {
|
|
||||||
let (n, src_addr) = result?;
|
|
||||||
if n == 0 { continue; }
|
|
||||||
|
|
||||||
// Find which peer this packet belongs to by trying decapsulate
|
|
||||||
let mut handled = false;
|
|
||||||
for peer in peers.iter_mut() {
|
|
||||||
match peer.tunn.decapsulate(Some(src_addr.ip()), &udp_buf[..n], &mut dst_buf) {
|
|
||||||
TunnResult::WriteToNetwork(packet) => {
|
|
||||||
udp_socket.send_to(packet, src_addr).await?;
|
|
||||||
// Drain loop
|
|
||||||
loop {
|
|
||||||
match peer.tunn.decapsulate(None, &[], &mut dst_buf) {
|
|
||||||
TunnResult::WriteToNetwork(pkt) => {
|
|
||||||
let ep = peer.endpoint.unwrap_or(src_addr);
|
|
||||||
udp_socket.send_to(pkt, ep).await?;
|
|
||||||
}
|
|
||||||
_ => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
peer.endpoint = Some(src_addr);
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
TunnResult::WriteToTunnelV4(packet, addr) => {
|
|
||||||
if peer.matches_dst(IpAddr::V4(addr)) {
|
|
||||||
let pkt_len = packet.len() as u64;
|
|
||||||
tun_writer.write_all(packet).await?;
|
|
||||||
peer.stats.bytes_received += pkt_len;
|
|
||||||
peer.stats.packets_received += 1;
|
|
||||||
}
|
|
||||||
peer.endpoint = Some(src_addr);
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
TunnResult::WriteToTunnelV6(packet, addr) => {
|
|
||||||
if peer.matches_dst(IpAddr::V6(addr)) {
|
|
||||||
let pkt_len = packet.len() as u64;
|
|
||||||
tun_writer.write_all(packet).await?;
|
|
||||||
peer.stats.bytes_received += pkt_len;
|
|
||||||
peer.stats.packets_received += 1;
|
|
||||||
}
|
|
||||||
peer.endpoint = Some(src_addr);
|
|
||||||
handled = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
TunnResult::Done => {
|
|
||||||
// This peer didn't recognize the packet, try next
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
TunnResult::Err(e) => {
|
|
||||||
debug!("decapsulate error from {}: {:?}", src_addr, e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !handled {
|
|
||||||
debug!("No peer matched UDP packet from {}", src_addr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TUN read ---
|
|
||||||
result = tun_reader.read(&mut tun_buf) => {
|
|
||||||
let n = result?;
|
|
||||||
if n == 0 { continue; }
|
|
||||||
|
|
||||||
let dst_ip = match extract_dst_ip(&tun_buf[..n]) {
|
|
||||||
Some(ip) => ip,
|
|
||||||
None => { continue; }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find peer whose AllowedIPs match the destination
|
|
||||||
for peer in peers.iter_mut() {
|
|
||||||
if !peer.matches_dst(dst_ip) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match peer.tunn.encapsulate(&tun_buf[..n], &mut dst_buf) {
|
|
||||||
TunnResult::WriteToNetwork(packet) => {
|
|
||||||
if let Some(endpoint) = peer.endpoint {
|
|
||||||
let pkt_len = n as u64;
|
|
||||||
udp_socket.send_to(packet, endpoint).await?;
|
|
||||||
peer.stats.bytes_sent += pkt_len;
|
|
||||||
peer.stats.packets_sent += 1;
|
|
||||||
} else {
|
|
||||||
debug!("No endpoint for peer {}, dropping packet", peer.public_key_b64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TunnResult::Err(e) => {
|
|
||||||
debug!("encapsulate error for peer {}: {:?}", peer.public_key_b64, e);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Timer tick (100ms) for WireGuard timers ---
|
|
||||||
_ = timer.tick() => {
|
|
||||||
for peer in peers.iter_mut() {
|
|
||||||
match peer.tunn.update_timers(&mut dst_buf) {
|
|
||||||
TunnResult::WriteToNetwork(packet) => {
|
|
||||||
if let Some(endpoint) = peer.endpoint {
|
|
||||||
udp_socket.send_to(packet, endpoint).await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TunnResult::Err(e) => {
|
|
||||||
debug!("Timer error for peer {}: {:?}", peer.public_key_b64, e);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Sync stats to shared state ---
|
|
||||||
_ = stats_timer.tick() => {
|
|
||||||
let mut shared = shared_stats.write().await;
|
|
||||||
for peer in peers.iter() {
|
|
||||||
shared.insert(peer.public_key_b64.clone(), peer.stats.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Dynamic peer commands ---
|
|
||||||
cmd = command_rx.recv() => {
|
|
||||||
match cmd {
|
|
||||||
Some(WgCommand::AddPeer(config, resp_tx)) => {
|
|
||||||
let result = add_peer_to_loop(
|
|
||||||
&mut peers,
|
|
||||||
&config,
|
|
||||||
&peer_index,
|
|
||||||
&rate_limiter,
|
|
||||||
&server_private_key_b64,
|
|
||||||
);
|
|
||||||
if result.is_ok() {
|
|
||||||
let mut shared = shared_stats.write().await;
|
|
||||||
shared.insert(config.public_key.clone(), WgPeerStats::default());
|
|
||||||
}
|
|
||||||
let _ = resp_tx.send(result);
|
|
||||||
}
|
|
||||||
Some(WgCommand::RemovePeer(pubkey, resp_tx)) => {
|
|
||||||
let prev_len = peers.len();
|
|
||||||
peers.retain(|p| p.public_key_b64 != pubkey);
|
|
||||||
if peers.len() < prev_len {
|
|
||||||
let mut shared = shared_stats.write().await;
|
|
||||||
shared.remove(&pubkey);
|
|
||||||
let _ = resp_tx.send(Ok(()));
|
|
||||||
} else {
|
|
||||||
let _ = resp_tx.send(Err(anyhow!("Peer not found: {}", pubkey)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
info!("Command channel closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Shutdown ---
|
|
||||||
_ = &mut shutdown_rx => {
|
|
||||||
info!("WireGuard server shutdown signal received");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_peer_to_loop(
|
fn add_peer_to_loop(
|
||||||
peers: &mut Vec<PeerState>,
|
peers: &mut Vec<PeerState>,
|
||||||
@@ -776,6 +274,410 @@ fn add_peer_to_loop(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Integrated WG listener (shares ServerState with WS/QUIC)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Configuration for the integrated WireGuard listener.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WgListenerConfig {
|
||||||
|
pub private_key: String,
|
||||||
|
pub listen_port: u16,
|
||||||
|
pub peers: Vec<WgPeerConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the first /32 IPv4 address from a list of AllowedIp entries.
|
||||||
|
/// This is the peer's VPN IP used for return-packet routing.
|
||||||
|
fn extract_peer_vpn_ip(allowed_ips: &[AllowedIp]) -> Option<Ipv4Addr> {
|
||||||
|
for aip in allowed_ips {
|
||||||
|
if let IpAddr::V4(v4) = aip.addr {
|
||||||
|
if aip.prefix_len == 32 {
|
||||||
|
return Some(v4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Timestamp helper (mirrors server.rs timestamp_now).
|
||||||
|
fn wg_timestamp_now() -> String {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{}", duration.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a WG peer in ServerState (tun_routes, clients, ip_pool).
|
||||||
|
/// Returns the VPN IP and the per-peer return-packet receiver.
|
||||||
|
async fn register_wg_peer(
|
||||||
|
state: &Arc<ServerState>,
|
||||||
|
peer: &PeerState,
|
||||||
|
wg_return_tx: &mpsc::Sender<(String, Vec<u8>)>,
|
||||||
|
) -> Result<Option<Ipv4Addr>> {
|
||||||
|
let vpn_ip = match extract_peer_vpn_ip(&peer.allowed_ips) {
|
||||||
|
Some(ip) => ip,
|
||||||
|
None => {
|
||||||
|
warn!("WG peer {} has no /32 IPv4 in allowed_ips, skipping registration",
|
||||||
|
peer.public_key_b64);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client_id = format!("wg-{}", &peer.public_key_b64[..8.min(peer.public_key_b64.len())]);
|
||||||
|
|
||||||
|
// Reserve IP in the pool
|
||||||
|
if let Err(e) = state.ip_pool.lock().await.reserve(vpn_ip, &client_id) {
|
||||||
|
warn!("Failed to reserve IP {} for WG peer {}: {}", vpn_ip, client_id, e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create per-peer return channel and register in tun_routes
|
||||||
|
let fwd_mode = state.config.forwarding_mode.as_deref().unwrap_or("testing");
|
||||||
|
let forwarding_active = fwd_mode == "tun" || fwd_mode == "socket";
|
||||||
|
if forwarding_active {
|
||||||
|
let (peer_return_tx, mut peer_return_rx) = mpsc::channel::<Vec<u8>>(256);
|
||||||
|
state.tun_routes.write().await.insert(vpn_ip, peer_return_tx);
|
||||||
|
|
||||||
|
// Spawn relay task: per-peer channel → merged channel tagged with pubkey
|
||||||
|
let relay_tx = wg_return_tx.clone();
|
||||||
|
let pubkey = peer.public_key_b64.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(packet) = peer_return_rx.recv().await {
|
||||||
|
if relay_tx.send((pubkey.clone(), packet)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert ClientInfo
|
||||||
|
let client_info = ClientInfo {
|
||||||
|
client_id: client_id.clone(),
|
||||||
|
assigned_ip: vpn_ip.to_string(),
|
||||||
|
connected_since: wg_timestamp_now(),
|
||||||
|
bytes_sent: 0,
|
||||||
|
bytes_received: 0,
|
||||||
|
packets_dropped: 0,
|
||||||
|
bytes_dropped: 0,
|
||||||
|
last_keepalive_at: None,
|
||||||
|
keepalives_received: 0,
|
||||||
|
rate_limit_bytes_per_sec: None,
|
||||||
|
burst_bytes: None,
|
||||||
|
authenticated_key: peer.public_key_b64.clone(),
|
||||||
|
registered_client_id: client_id,
|
||||||
|
remote_addr: peer.endpoint.map(|e| e.to_string()),
|
||||||
|
transport_type: "wireguard".to_string(),
|
||||||
|
};
|
||||||
|
state.clients.write().await.insert(client_info.client_id.clone(), client_info);
|
||||||
|
|
||||||
|
Ok(Some(vpn_ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unregister a WG peer from ServerState.
|
||||||
|
async fn unregister_wg_peer(
|
||||||
|
state: &Arc<ServerState>,
|
||||||
|
pubkey: &str,
|
||||||
|
vpn_ip: Option<Ipv4Addr>,
|
||||||
|
) {
|
||||||
|
let client_id = format!("wg-{}", &pubkey[..8.min(pubkey.len())]);
|
||||||
|
|
||||||
|
if let Some(ip) = vpn_ip {
|
||||||
|
state.tun_routes.write().await.remove(&ip);
|
||||||
|
state.ip_pool.lock().await.release(&ip);
|
||||||
|
}
|
||||||
|
state.clients.write().await.remove(&client_id);
|
||||||
|
state.rate_limiters.lock().await.remove(&client_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integrated WireGuard listener that shares ServerState with WS/QUIC listeners.
|
||||||
|
/// Uses the shared ForwardingEngine for packet routing instead of its own TUN device.
|
||||||
|
pub async fn run_wg_listener(
|
||||||
|
state: Arc<ServerState>,
|
||||||
|
config: WgListenerConfig,
|
||||||
|
mut shutdown_rx: mpsc::Receiver<()>,
|
||||||
|
mut command_rx: mpsc::Receiver<WgCommand>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Parse server private key
|
||||||
|
let server_private = parse_private_key(&config.private_key)?;
|
||||||
|
let server_public = PublicKey::from(&server_private);
|
||||||
|
|
||||||
|
// Create rate limiter for DDoS protection
|
||||||
|
let rate_limiter = Arc::new(RateLimiter::new(&server_public, TIMER_TICK_MS as u64));
|
||||||
|
|
||||||
|
// Build initial peer state
|
||||||
|
let peer_index = AtomicU32::new(0);
|
||||||
|
let mut peers: Vec<PeerState> = Vec::with_capacity(config.peers.len());
|
||||||
|
|
||||||
|
for peer_config in &config.peers {
|
||||||
|
let peer_public = parse_public_key(&peer_config.public_key)?;
|
||||||
|
let psk = match &peer_config.preshared_key {
|
||||||
|
Some(k) => Some(parse_preshared_key(k)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let idx = peer_index.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let priv_copy = parse_private_key(&config.private_key)?;
|
||||||
|
|
||||||
|
let tunn = Tunn::new(
|
||||||
|
priv_copy,
|
||||||
|
peer_public,
|
||||||
|
psk,
|
||||||
|
peer_config.persistent_keepalive,
|
||||||
|
idx,
|
||||||
|
Some(rate_limiter.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed_ips: Vec<AllowedIp> = peer_config
|
||||||
|
.allowed_ips
|
||||||
|
.iter()
|
||||||
|
.map(|cidr| AllowedIp::parse(cidr))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
let endpoint = match &peer_config.endpoint {
|
||||||
|
Some(ep) => Some(ep.parse::<SocketAddr>()?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
peers.push(PeerState {
|
||||||
|
tunn,
|
||||||
|
public_key_b64: peer_config.public_key.clone(),
|
||||||
|
allowed_ips,
|
||||||
|
endpoint,
|
||||||
|
persistent_keepalive: peer_config.persistent_keepalive,
|
||||||
|
stats: WgPeerStats::default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind UDP socket
|
||||||
|
let udp_socket = UdpSocket::bind(format!("0.0.0.0:{}", config.listen_port)).await?;
|
||||||
|
info!("WireGuard listener started on UDP port {}", config.listen_port);
|
||||||
|
|
||||||
|
// Merged return-packet channel: all per-peer channels feed into this
|
||||||
|
let (wg_return_tx, mut wg_return_rx) = mpsc::channel::<(String, Vec<u8>)>(1024);
|
||||||
|
|
||||||
|
// Register initial peers in ServerState and track their VPN IPs
|
||||||
|
let mut peer_vpn_ips: HashMap<String, Ipv4Addr> = HashMap::new();
|
||||||
|
for peer in &peers {
|
||||||
|
if let Ok(Some(ip)) = register_wg_peer(&state, peer, &wg_return_tx).await {
|
||||||
|
peer_vpn_ips.insert(peer.public_key_b64.clone(), ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffers
|
||||||
|
let mut udp_buf = vec![0u8; MAX_UDP_PACKET];
|
||||||
|
let mut dst_buf = vec![0u8; WG_BUFFER_SIZE];
|
||||||
|
let mut timer = tokio::time::interval(std::time::Duration::from_millis(TIMER_TICK_MS));
|
||||||
|
let mut stats_timer = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
// --- UDP receive → decapsulate → ForwardingEngine ---
|
||||||
|
result = udp_socket.recv_from(&mut udp_buf) => {
|
||||||
|
let (n, src_addr) = result?;
|
||||||
|
if n == 0 { continue; }
|
||||||
|
|
||||||
|
let mut handled = false;
|
||||||
|
for peer in peers.iter_mut() {
|
||||||
|
match peer.tunn.decapsulate(Some(src_addr.ip()), &udp_buf[..n], &mut dst_buf) {
|
||||||
|
TunnResult::WriteToNetwork(packet) => {
|
||||||
|
udp_socket.send_to(packet, src_addr).await?;
|
||||||
|
loop {
|
||||||
|
match peer.tunn.decapsulate(None, &[], &mut dst_buf) {
|
||||||
|
TunnResult::WriteToNetwork(pkt) => {
|
||||||
|
let ep = peer.endpoint.unwrap_or(src_addr);
|
||||||
|
udp_socket.send_to(pkt, ep).await?;
|
||||||
|
}
|
||||||
|
_ => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
peer.endpoint = Some(src_addr);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TunnResult::WriteToTunnelV4(packet, addr) => {
|
||||||
|
if peer.matches_dst(IpAddr::V4(addr)) {
|
||||||
|
let pkt_len = packet.len() as u64;
|
||||||
|
// Forward via shared forwarding engine
|
||||||
|
let mut engine = state.forwarding_engine.lock().await;
|
||||||
|
match &mut *engine {
|
||||||
|
ForwardingEngine::Tun(writer) => {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
if let Err(e) = writer.write_all(packet).await {
|
||||||
|
warn!("TUN write error for WG peer: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForwardingEngine::Socket(sender) => {
|
||||||
|
let _ = sender.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
|
ForwardingEngine::Testing => {}
|
||||||
|
}
|
||||||
|
peer.stats.bytes_received += pkt_len;
|
||||||
|
peer.stats.packets_received += 1;
|
||||||
|
}
|
||||||
|
peer.endpoint = Some(src_addr);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TunnResult::WriteToTunnelV6(packet, addr) => {
|
||||||
|
if peer.matches_dst(IpAddr::V6(addr)) {
|
||||||
|
let pkt_len = packet.len() as u64;
|
||||||
|
let mut engine = state.forwarding_engine.lock().await;
|
||||||
|
match &mut *engine {
|
||||||
|
ForwardingEngine::Tun(writer) => {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
if let Err(e) = writer.write_all(packet).await {
|
||||||
|
warn!("TUN write error for WG peer: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForwardingEngine::Socket(sender) => {
|
||||||
|
let _ = sender.try_send(packet.to_vec());
|
||||||
|
}
|
||||||
|
ForwardingEngine::Testing => {}
|
||||||
|
}
|
||||||
|
peer.stats.bytes_received += pkt_len;
|
||||||
|
peer.stats.packets_received += 1;
|
||||||
|
}
|
||||||
|
peer.endpoint = Some(src_addr);
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
TunnResult::Done => { continue; }
|
||||||
|
TunnResult::Err(e) => {
|
||||||
|
debug!("decapsulate error from {}: {:?}", src_addr, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
debug!("No WG peer matched UDP packet from {}", src_addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Return packets from tun_routes → encapsulate → UDP ---
|
||||||
|
Some((pubkey, packet)) = wg_return_rx.recv() => {
|
||||||
|
if let Some(peer) = peers.iter_mut().find(|p| p.public_key_b64 == pubkey) {
|
||||||
|
match peer.tunn.encapsulate(&packet, &mut dst_buf) {
|
||||||
|
TunnResult::WriteToNetwork(out) => {
|
||||||
|
if let Some(endpoint) = peer.endpoint {
|
||||||
|
let pkt_len = packet.len() as u64;
|
||||||
|
udp_socket.send_to(out, endpoint).await?;
|
||||||
|
peer.stats.bytes_sent += pkt_len;
|
||||||
|
peer.stats.packets_sent += 1;
|
||||||
|
} else {
|
||||||
|
debug!("No endpoint for WG peer {}, dropping return packet",
|
||||||
|
peer.public_key_b64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TunnResult::Err(e) => {
|
||||||
|
debug!("encapsulate error for WG peer {}: {:?}",
|
||||||
|
peer.public_key_b64, e);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WireGuard protocol timers (100ms) ---
|
||||||
|
_ = timer.tick() => {
|
||||||
|
for peer in peers.iter_mut() {
|
||||||
|
match peer.tunn.update_timers(&mut dst_buf) {
|
||||||
|
TunnResult::WriteToNetwork(packet) => {
|
||||||
|
if let Some(endpoint) = peer.endpoint {
|
||||||
|
udp_socket.send_to(packet, endpoint).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TunnResult::Err(e) => {
|
||||||
|
debug!("Timer error for WG peer {}: {:?}",
|
||||||
|
peer.public_key_b64, e);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sync stats to ServerState (every 1s) ---
|
||||||
|
_ = stats_timer.tick() => {
|
||||||
|
let mut clients = state.clients.write().await;
|
||||||
|
let mut stats = state.stats.write().await;
|
||||||
|
for peer in peers.iter() {
|
||||||
|
let client_id = format!("wg-{}", &peer.public_key_b64[..8.min(peer.public_key_b64.len())]);
|
||||||
|
if let Some(info) = clients.get_mut(&client_id) {
|
||||||
|
// Update stats delta
|
||||||
|
let prev_sent = info.bytes_sent;
|
||||||
|
let prev_recv = info.bytes_received;
|
||||||
|
info.bytes_sent = peer.stats.bytes_sent;
|
||||||
|
info.bytes_received = peer.stats.bytes_received;
|
||||||
|
info.remote_addr = peer.endpoint.map(|e| e.to_string());
|
||||||
|
|
||||||
|
// Update aggregate stats
|
||||||
|
stats.bytes_sent += peer.stats.bytes_sent.saturating_sub(prev_sent);
|
||||||
|
stats.bytes_received += peer.stats.bytes_received.saturating_sub(prev_recv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dynamic peer commands ---
|
||||||
|
cmd = command_rx.recv() => {
|
||||||
|
match cmd {
|
||||||
|
Some(WgCommand::AddPeer(peer_config, resp_tx)) => {
|
||||||
|
let result = add_peer_to_loop(
|
||||||
|
&mut peers,
|
||||||
|
&peer_config,
|
||||||
|
&peer_index,
|
||||||
|
&rate_limiter,
|
||||||
|
&config.private_key,
|
||||||
|
);
|
||||||
|
if result.is_ok() {
|
||||||
|
// Register new peer in ServerState
|
||||||
|
let peer = peers.last().unwrap();
|
||||||
|
match register_wg_peer(&state, peer, &wg_return_tx).await {
|
||||||
|
Ok(Some(ip)) => {
|
||||||
|
peer_vpn_ips.insert(peer_config.public_key.clone(), ip);
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to register WG peer: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = resp_tx.send(result);
|
||||||
|
}
|
||||||
|
Some(WgCommand::RemovePeer(pubkey, resp_tx)) => {
|
||||||
|
let prev_len = peers.len();
|
||||||
|
peers.retain(|p| p.public_key_b64 != pubkey);
|
||||||
|
if peers.len() < prev_len {
|
||||||
|
let vpn_ip = peer_vpn_ips.remove(&pubkey);
|
||||||
|
unregister_wg_peer(&state, &pubkey, vpn_ip).await;
|
||||||
|
let _ = resp_tx.send(Ok(()));
|
||||||
|
} else {
|
||||||
|
let _ = resp_tx.send(Err(anyhow!("Peer not found: {}", pubkey)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("WG command channel closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shutdown ---
|
||||||
|
_ = shutdown_rx.recv() => {
|
||||||
|
info!("WireGuard listener shutdown signal received");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: unregister all peers from ServerState
|
||||||
|
for peer in &peers {
|
||||||
|
let vpn_ip = peer_vpn_ips.get(&peer.public_key_b64).copied();
|
||||||
|
unregister_wg_peer(&state, &peer.public_key_b64, vpn_ip).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("WireGuard listener stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WgClient
|
// WgClient
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1096,6 +998,8 @@ fn chrono_now() -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::tunnel::extract_dst_ip;
|
||||||
|
use std::net::Ipv6Addr;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_generate_wg_keypair() {
|
fn test_generate_wg_keypair() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import { VpnClient, VpnServer } from '../ts/index.js';
|
import { VpnClient, VpnServer } from '../ts/index.js';
|
||||||
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js';
|
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -40,7 +40,9 @@ let server: VpnServer;
|
|||||||
let serverPort: number;
|
let serverPort: number;
|
||||||
let keypair: IVpnKeypair;
|
let keypair: IVpnKeypair;
|
||||||
let client: VpnClient;
|
let client: VpnClient;
|
||||||
|
let clientBundle: IClientConfigBundle;
|
||||||
const extraClients: VpnClient[] = [];
|
const extraClients: VpnClient[] = [];
|
||||||
|
const extraBundles: IClientConfigBundle[] = [];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
@@ -64,7 +66,7 @@ tap.test('setup: start VPN server', async () => {
|
|||||||
expect(keypair.publicKey).toBeTypeofString();
|
expect(keypair.publicKey).toBeTypeofString();
|
||||||
expect(keypair.privateKey).toBeTypeofString();
|
expect(keypair.privateKey).toBeTypeofString();
|
||||||
|
|
||||||
// Phase 3: start the VPN listener
|
// Phase 3: start the VPN listener (empty clients, will use createClient at runtime)
|
||||||
const serverConfig: IVpnServerConfig = {
|
const serverConfig: IVpnServerConfig = {
|
||||||
listenAddr: `127.0.0.1:${serverPort}`,
|
listenAddr: `127.0.0.1:${serverPort}`,
|
||||||
privateKey: keypair.privateKey,
|
privateKey: keypair.privateKey,
|
||||||
@@ -76,6 +78,11 @@ tap.test('setup: start VPN server', async () => {
|
|||||||
// Verify server is now running
|
// Verify server is now running
|
||||||
const status = await server.getStatus();
|
const status = await server.getStatus();
|
||||||
expect(status.state).toEqual('connected');
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
|
// Phase 4: create the first client via the hub
|
||||||
|
clientBundle = await server.createClient({ clientId: 'test-client-0' });
|
||||||
|
expect(clientBundle.secrets.noisePrivateKey).toBeTypeofString();
|
||||||
|
expect(clientBundle.smartvpnConfig.clientPublicKey).toBeTypeofString();
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('single client connects and gets IP', async () => {
|
tap.test('single client connects and gets IP', async () => {
|
||||||
@@ -89,6 +96,8 @@ tap.test('single client connects and gets IP', async () => {
|
|||||||
const result = await client.connect({
|
const result = await client.connect({
|
||||||
serverUrl: `ws://127.0.0.1:${serverPort}`,
|
serverUrl: `ws://127.0.0.1:${serverPort}`,
|
||||||
serverPublicKey: keypair.publicKey,
|
serverPublicKey: keypair.publicKey,
|
||||||
|
clientPrivateKey: clientBundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: clientBundle.smartvpnConfig.clientPublicKey,
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,11 +184,15 @@ tap.test('5 concurrent clients', async () => {
|
|||||||
assignedIps.add(existingClients[0].assignedIp);
|
assignedIps.add(existingClients[0].assignedIp);
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const bundle = await server.createClient({ clientId: `test-client-${i + 1}` });
|
||||||
|
extraBundles.push(bundle);
|
||||||
const c = new VpnClient({ transport: { transport: 'stdio' } });
|
const c = new VpnClient({ transport: { transport: 'stdio' } });
|
||||||
await c.start();
|
await c.start();
|
||||||
const result = await c.connect({
|
const result = await c.connect({
|
||||||
serverUrl: `ws://127.0.0.1:${serverPort}`,
|
serverUrl: `ws://127.0.0.1:${serverPort}`,
|
||||||
serverPublicKey: keypair.publicKey,
|
serverPublicKey: keypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
expect(result.assignedIp).toStartWith('10.8.0.');
|
expect(result.assignedIp).toStartWith('10.8.0.');
|
||||||
|
|||||||
@@ -144,12 +144,17 @@ let keypair: IVpnKeypair;
|
|||||||
let throttle: ThrottleProxy;
|
let throttle: ThrottleProxy;
|
||||||
const allClients: VpnClient[] = [];
|
const allClients: VpnClient[] = [];
|
||||||
|
|
||||||
|
let clientCounter = 0;
|
||||||
async function createConnectedClient(port: number): Promise<VpnClient> {
|
async function createConnectedClient(port: number): Promise<VpnClient> {
|
||||||
|
clientCounter++;
|
||||||
|
const bundle = await server.createClient({ clientId: `load-client-${clientCounter}` });
|
||||||
const c = new VpnClient({ transport: { transport: 'stdio' } });
|
const c = new VpnClient({ transport: { transport: 'stdio' } });
|
||||||
await c.start();
|
await c.start();
|
||||||
await c.connect({
|
await c.connect({
|
||||||
serverUrl: `ws://127.0.0.1:${port}`,
|
serverUrl: `ws://127.0.0.1:${port}`,
|
||||||
serverPublicKey: keypair.publicKey,
|
serverPublicKey: keypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
allClients.push(c);
|
allClients.push(c);
|
||||||
@@ -206,8 +211,8 @@ tap.test('throttled connection: handshake succeeds through throttle', async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('sustained keepalive under throttle', async () => {
|
tap.test('sustained keepalive under throttle', async () => {
|
||||||
// Wait for at least 2 keepalive cycles (3s interval)
|
// Wait for at least 1 keepalive cycle (3s interval)
|
||||||
await delay(8000);
|
await delay(4000);
|
||||||
|
|
||||||
const client = allClients[0];
|
const client = allClients[0];
|
||||||
const stats = await client.getStatistics();
|
const stats = await client.getStatistics();
|
||||||
@@ -257,14 +262,14 @@ tap.test('rate limiting combined with network throttle', async () => {
|
|||||||
await server.removeClientRateLimit(targetId);
|
await server.removeClientRateLimit(targetId);
|
||||||
});
|
});
|
||||||
|
|
||||||
tap.test('burst waves: 3 waves of 3 clients', async () => {
|
tap.test('burst waves: 2 waves of 2 clients', async () => {
|
||||||
const initialCount = (await server.listClients()).length;
|
const initialCount = (await server.listClients()).length;
|
||||||
|
|
||||||
for (let wave = 0; wave < 3; wave++) {
|
for (let wave = 0; wave < 2; wave++) {
|
||||||
const waveClients: VpnClient[] = [];
|
const waveClients: VpnClient[] = [];
|
||||||
|
|
||||||
// Connect 3 clients
|
// Connect 2 clients
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const c = await createConnectedClient(proxyPort);
|
const c = await createConnectedClient(proxyPort);
|
||||||
waveClients.push(c);
|
waveClients.push(c);
|
||||||
}
|
}
|
||||||
@@ -272,7 +277,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
|
|||||||
// Verify all connected
|
// Verify all connected
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const all = await server.listClients();
|
const all = await server.listClients();
|
||||||
return all.length === initialCount + 3;
|
return all.length === initialCount + 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disconnect all wave clients
|
// Disconnect all wave clients
|
||||||
@@ -291,7 +296,7 @@ tap.test('burst waves: 3 waves of 3 clients', async () => {
|
|||||||
|
|
||||||
// Verify total connections accumulated
|
// Verify total connections accumulated
|
||||||
const stats = await server.getStatistics();
|
const stats = await server.getStatistics();
|
||||||
expect(stats.totalConnections).toBeGreaterThanOrEqual(9 + initialCount);
|
expect(stats.totalConnections).toBeGreaterThanOrEqual(4 + initialCount);
|
||||||
|
|
||||||
// Original clients still connected
|
// Original clients still connected
|
||||||
const remaining = await server.listClients();
|
const remaining = await server.listClients();
|
||||||
@@ -310,7 +315,7 @@ tap.test('aggressive throttle: 10 KB/s', async () => {
|
|||||||
expect(status.state).toEqual('connected');
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
// Wait for keepalive exchange (might take longer due to throttle)
|
// Wait for keepalive exchange (might take longer due to throttle)
|
||||||
await delay(10000);
|
await delay(4000);
|
||||||
|
|
||||||
const stats = await client.getStatistics();
|
const stats = await client.getStatistics();
|
||||||
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
@@ -327,7 +332,7 @@ tap.test('post-load health: direct connection still works', async () => {
|
|||||||
const status = await directClient.getStatus();
|
const status = await directClient.getStatus();
|
||||||
expect(status.state).toEqual('connected');
|
expect(status.state).toEqual('connected');
|
||||||
|
|
||||||
await delay(5000);
|
await delay(3500);
|
||||||
|
|
||||||
const stats = await directClient.getStatistics();
|
const stats = await directClient.getStatistics();
|
||||||
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
expect(stats.keepalivesSent).toBeGreaterThanOrEqual(1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|||||||
import * as net from 'net';
|
import * as net from 'net';
|
||||||
import * as dgram from 'dgram';
|
import * as dgram from 'dgram';
|
||||||
import { VpnClient, VpnServer } from '../ts/index.js';
|
import { VpnClient, VpnServer } from '../ts/index.js';
|
||||||
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig } from '../ts/index.js';
|
import type { IVpnClientOptions, IVpnServerOptions, IVpnKeypair, IVpnServerConfig, IClientConfigBundle } from '../ts/index.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -82,6 +82,8 @@ tap.test('setup: start VPN server in QUIC mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('QUIC client connects and gets IP', async () => {
|
tap.test('QUIC client connects and gets IP', async () => {
|
||||||
|
const bundle = await server.createClient({ clientId: 'quic-client-1' });
|
||||||
|
|
||||||
const options: IVpnClientOptions = {
|
const options: IVpnClientOptions = {
|
||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
};
|
};
|
||||||
@@ -92,6 +94,8 @@ tap.test('QUIC client connects and gets IP', async () => {
|
|||||||
const result = await client.connect({
|
const result = await client.connect({
|
||||||
serverUrl: `127.0.0.1:${quicPort}`,
|
serverUrl: `127.0.0.1:${quicPort}`,
|
||||||
serverPublicKey: keypair.publicKey,
|
serverPublicKey: keypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
transport: 'quic',
|
transport: 'quic',
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
@@ -162,12 +166,16 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () =
|
|||||||
const started = await client.start();
|
const started = await client.start();
|
||||||
expect(started).toBeTrue();
|
expect(started).toBeTrue();
|
||||||
|
|
||||||
|
const bundle = await dualServer.createClient({ clientId: 'dual-auto-client' });
|
||||||
|
|
||||||
// "auto" mode (default): tries QUIC first at same host:port, falls back to WS
|
// "auto" mode (default): tries QUIC first at same host:port, falls back to WS
|
||||||
// Since the WS port and QUIC port differ, auto will try QUIC on WS port (fail),
|
// Since the WS port and QUIC port differ, auto will try QUIC on WS port (fail),
|
||||||
// then fall back to WebSocket
|
// then fall back to WebSocket
|
||||||
const result = await client.connect({
|
const result = await client.connect({
|
||||||
serverUrl: `ws://127.0.0.1:${dualWsPort}`,
|
serverUrl: `ws://127.0.0.1:${dualWsPort}`,
|
||||||
serverPublicKey: dualKeypair.publicKey,
|
serverPublicKey: dualKeypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
// transport defaults to 'auto'
|
// transport defaults to 'auto'
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
@@ -187,6 +195,8 @@ tap.test('auto client connects to dual-mode server (QUIC preferred)', async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('explicit QUIC client connects to dual-mode server', async () => {
|
tap.test('explicit QUIC client connects to dual-mode server', async () => {
|
||||||
|
const bundle = await dualServer.createClient({ clientId: 'dual-quic-client' });
|
||||||
|
|
||||||
const options: IVpnClientOptions = {
|
const options: IVpnClientOptions = {
|
||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
};
|
};
|
||||||
@@ -197,6 +207,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => {
|
|||||||
const result = await client.connect({
|
const result = await client.connect({
|
||||||
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
||||||
serverPublicKey: dualKeypair.publicKey,
|
serverPublicKey: dualKeypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
transport: 'quic',
|
transport: 'quic',
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
@@ -211,6 +223,8 @@ tap.test('explicit QUIC client connects to dual-mode server', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('keepalive exchange over QUIC', async () => {
|
tap.test('keepalive exchange over QUIC', async () => {
|
||||||
|
const bundle = await dualServer.createClient({ clientId: 'dual-keepalive-client' });
|
||||||
|
|
||||||
const options: IVpnClientOptions = {
|
const options: IVpnClientOptions = {
|
||||||
transport: { transport: 'stdio' },
|
transport: { transport: 'stdio' },
|
||||||
};
|
};
|
||||||
@@ -220,6 +234,8 @@ tap.test('keepalive exchange over QUIC', async () => {
|
|||||||
await client.connect({
|
await client.connect({
|
||||||
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
serverUrl: `127.0.0.1:${dualQuicPort}`,
|
||||||
serverPublicKey: dualKeypair.publicKey,
|
serverPublicKey: dualKeypair.publicKey,
|
||||||
|
clientPrivateKey: bundle.secrets.noisePrivateKey,
|
||||||
|
clientPublicKey: bundle.smartvpnConfig.clientPublicKey,
|
||||||
transport: 'quic',
|
transport: 'quic',
|
||||||
keepaliveIntervalSecs: 3,
|
keepaliveIntervalSecs: 3,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|||||||
import { VpnConfig } from '../ts/index.js';
|
import { VpnConfig } from '../ts/index.js';
|
||||||
import type { IVpnClientConfig, IVpnServerConfig } from '../ts/index.js';
|
import type { IVpnClientConfig, IVpnServerConfig } from '../ts/index.js';
|
||||||
|
|
||||||
|
// Valid 32-byte base64 keys for testing
|
||||||
|
const TEST_KEY_A = 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=';
|
||||||
|
const TEST_KEY_B = 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI=';
|
||||||
|
const TEST_KEY_C = 'Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2M=';
|
||||||
|
|
||||||
tap.test('VpnConfig: validate valid client config', async () => {
|
tap.test('VpnConfig: validate valid client config', async () => {
|
||||||
const config: IVpnClientConfig = {
|
const config: IVpnClientConfig = {
|
||||||
serverUrl: 'wss://vpn.example.com/tunnel',
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPrivateKey: TEST_KEY_B,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
dns: ['1.1.1.1', '8.8.8.8'],
|
dns: ['1.1.1.1', '8.8.8.8'],
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
keepaliveIntervalSecs: 30,
|
keepaliveIntervalSecs: 30,
|
||||||
@@ -16,7 +23,9 @@ tap.test('VpnConfig: validate valid client config', async () => {
|
|||||||
|
|
||||||
tap.test('VpnConfig: reject client config without serverUrl', async () => {
|
tap.test('VpnConfig: reject client config without serverUrl', async () => {
|
||||||
const config = {
|
const config = {
|
||||||
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPrivateKey: TEST_KEY_B,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
} as IVpnClientConfig;
|
} as IVpnClientConfig;
|
||||||
let threw = false;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
@@ -31,7 +40,9 @@ tap.test('VpnConfig: reject client config without serverUrl', async () => {
|
|||||||
tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async () => {
|
tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async () => {
|
||||||
const config: IVpnClientConfig = {
|
const config: IVpnClientConfig = {
|
||||||
serverUrl: 'http://vpn.example.com/tunnel',
|
serverUrl: 'http://vpn.example.com/tunnel',
|
||||||
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPrivateKey: TEST_KEY_B,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
};
|
};
|
||||||
let threw = false;
|
let threw = false;
|
||||||
try {
|
try {
|
||||||
@@ -43,10 +54,28 @@ tap.test('VpnConfig: reject client config with invalid serverUrl scheme', async
|
|||||||
expect(threw).toBeTrue();
|
expect(threw).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VpnConfig: reject client config without clientPrivateKey', async () => {
|
||||||
|
const config = {
|
||||||
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
|
} as IVpnClientConfig;
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
VpnConfig.validateClientConfig(config);
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
expect((e as Error).message).toContain('clientPrivateKey');
|
||||||
|
}
|
||||||
|
expect(threw).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VpnConfig: reject client config with invalid MTU', async () => {
|
tap.test('VpnConfig: reject client config with invalid MTU', async () => {
|
||||||
const config: IVpnClientConfig = {
|
const config: IVpnClientConfig = {
|
||||||
serverUrl: 'wss://vpn.example.com/tunnel',
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPrivateKey: TEST_KEY_B,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
mtu: 100,
|
mtu: 100,
|
||||||
};
|
};
|
||||||
let threw = false;
|
let threw = false;
|
||||||
@@ -62,7 +91,9 @@ tap.test('VpnConfig: reject client config with invalid MTU', async () => {
|
|||||||
tap.test('VpnConfig: reject client config with invalid DNS', async () => {
|
tap.test('VpnConfig: reject client config with invalid DNS', async () => {
|
||||||
const config: IVpnClientConfig = {
|
const config: IVpnClientConfig = {
|
||||||
serverUrl: 'wss://vpn.example.com/tunnel',
|
serverUrl: 'wss://vpn.example.com/tunnel',
|
||||||
serverPublicKey: 'dGVzdHB1YmxpY2tleQ==',
|
serverPublicKey: TEST_KEY_A,
|
||||||
|
clientPrivateKey: TEST_KEY_B,
|
||||||
|
clientPublicKey: TEST_KEY_C,
|
||||||
dns: ['not-an-ip'],
|
dns: ['not-an-ip'],
|
||||||
};
|
};
|
||||||
let threw = false;
|
let threw = false;
|
||||||
@@ -78,12 +109,15 @@ tap.test('VpnConfig: reject client config with invalid DNS', async () => {
|
|||||||
tap.test('VpnConfig: validate valid server config', async () => {
|
tap.test('VpnConfig: validate valid server config', async () => {
|
||||||
const config: IVpnServerConfig = {
|
const config: IVpnServerConfig = {
|
||||||
listenAddr: '0.0.0.0:443',
|
listenAddr: '0.0.0.0:443',
|
||||||
privateKey: 'dGVzdHByaXZhdGVrZXk=',
|
privateKey: TEST_KEY_A,
|
||||||
publicKey: 'dGVzdHB1YmxpY2tleQ==',
|
publicKey: TEST_KEY_B,
|
||||||
subnet: '10.8.0.0/24',
|
subnet: '10.8.0.0/24',
|
||||||
dns: ['1.1.1.1'],
|
dns: ['1.1.1.1'],
|
||||||
mtu: 1420,
|
mtu: 1420,
|
||||||
enableNat: true,
|
enableNat: true,
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'test-client', publicKey: TEST_KEY_C },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
// Should not throw
|
// Should not throw
|
||||||
VpnConfig.validateServerConfig(config);
|
VpnConfig.validateServerConfig(config);
|
||||||
@@ -92,8 +126,8 @@ tap.test('VpnConfig: validate valid server config', async () => {
|
|||||||
tap.test('VpnConfig: reject server config with invalid subnet', async () => {
|
tap.test('VpnConfig: reject server config with invalid subnet', async () => {
|
||||||
const config: IVpnServerConfig = {
|
const config: IVpnServerConfig = {
|
||||||
listenAddr: '0.0.0.0:443',
|
listenAddr: '0.0.0.0:443',
|
||||||
privateKey: 'dGVzdHByaXZhdGVrZXk=',
|
privateKey: TEST_KEY_A,
|
||||||
publicKey: 'dGVzdHB1YmxpY2tleQ==',
|
publicKey: TEST_KEY_B,
|
||||||
subnet: 'invalid',
|
subnet: 'invalid',
|
||||||
};
|
};
|
||||||
let threw = false;
|
let threw = false;
|
||||||
@@ -109,7 +143,7 @@ tap.test('VpnConfig: reject server config with invalid subnet', async () => {
|
|||||||
tap.test('VpnConfig: reject server config without privateKey', async () => {
|
tap.test('VpnConfig: reject server config without privateKey', async () => {
|
||||||
const config = {
|
const config = {
|
||||||
listenAddr: '0.0.0.0:443',
|
listenAddr: '0.0.0.0:443',
|
||||||
publicKey: 'dGVzdHB1YmxpY2tleQ==',
|
publicKey: TEST_KEY_B,
|
||||||
subnet: '10.8.0.0/24',
|
subnet: '10.8.0.0/24',
|
||||||
} as IVpnServerConfig;
|
} as IVpnServerConfig;
|
||||||
let threw = false;
|
let threw = false;
|
||||||
@@ -122,4 +156,24 @@ tap.test('VpnConfig: reject server config without privateKey', async () => {
|
|||||||
expect(threw).toBeTrue();
|
expect(threw).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VpnConfig: reject server config with invalid client publicKey', async () => {
|
||||||
|
const config: IVpnServerConfig = {
|
||||||
|
listenAddr: '0.0.0.0:443',
|
||||||
|
privateKey: TEST_KEY_A,
|
||||||
|
publicKey: TEST_KEY_B,
|
||||||
|
subnet: '10.8.0.0/24',
|
||||||
|
clients: [
|
||||||
|
{ clientId: 'bad-client', publicKey: 'short-key' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
VpnConfig.validateServerConfig(config);
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
expect((e as Error).message).toContain('publicKey');
|
||||||
|
}
|
||||||
|
expect(threw).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvpn',
|
name: '@push.rocks/smartvpn',
|
||||||
version: '1.7.0',
|
version: '1.15.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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ export class VpnConfig {
|
|||||||
if (!config.serverPublicKey) {
|
if (!config.serverPublicKey) {
|
||||||
throw new Error('VpnConfig: serverPublicKey is required');
|
throw new Error('VpnConfig: serverPublicKey is required');
|
||||||
}
|
}
|
||||||
|
// Noise IK requires client keypair
|
||||||
|
if (!config.clientPrivateKey) {
|
||||||
|
throw new Error('VpnConfig: clientPrivateKey is required for Noise IK authentication');
|
||||||
|
}
|
||||||
|
VpnConfig.validateBase64Key(config.clientPrivateKey, 'clientPrivateKey');
|
||||||
|
if (!config.clientPublicKey) {
|
||||||
|
throw new Error('VpnConfig: clientPublicKey is required for Noise IK authentication');
|
||||||
|
}
|
||||||
|
VpnConfig.validateBase64Key(config.clientPublicKey, 'clientPublicKey');
|
||||||
}
|
}
|
||||||
if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) {
|
if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) {
|
||||||
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
||||||
@@ -116,6 +125,18 @@ export class VpnConfig {
|
|||||||
if (!VpnConfig.isValidSubnet(config.subnet)) {
|
if (!VpnConfig.isValidSubnet(config.subnet)) {
|
||||||
throw new Error(`VpnConfig: invalid subnet: ${config.subnet}`);
|
throw new Error(`VpnConfig: invalid subnet: ${config.subnet}`);
|
||||||
}
|
}
|
||||||
|
// Validate client entries if provided
|
||||||
|
if (config.clients) {
|
||||||
|
for (const client of config.clients) {
|
||||||
|
if (!client.clientId) {
|
||||||
|
throw new Error('VpnConfig: client entry must have a clientId');
|
||||||
|
}
|
||||||
|
if (!client.publicKey) {
|
||||||
|
throw new Error(`VpnConfig: client '${client.clientId}' must have a publicKey`);
|
||||||
|
}
|
||||||
|
VpnConfig.validateBase64Key(client.publicKey, `client '${client.clientId}' publicKey`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) {
|
if (config.mtu !== undefined && (config.mtu < 576 || config.mtu > 65535)) {
|
||||||
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
throw new Error('VpnConfig: mtu must be between 576 and 65535');
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type {
|
|||||||
IVpnClientTelemetry,
|
IVpnClientTelemetry,
|
||||||
IWgPeerConfig,
|
IWgPeerConfig,
|
||||||
IWgPeerInfo,
|
IWgPeerInfo,
|
||||||
|
IClientEntry,
|
||||||
|
IClientConfigBundle,
|
||||||
|
IDestinationPolicy,
|
||||||
TVpnServerCommands,
|
TVpnServerCommands,
|
||||||
} from './smartvpn.interfaces.js';
|
} from './smartvpn.interfaces.js';
|
||||||
|
|
||||||
@@ -19,6 +22,10 @@ import type {
|
|||||||
export class VpnServer extends plugins.events.EventEmitter {
|
export class VpnServer extends plugins.events.EventEmitter {
|
||||||
private bridge: VpnBridge<TVpnServerCommands>;
|
private bridge: VpnBridge<TVpnServerCommands>;
|
||||||
private options: IVpnServerOptions;
|
private options: IVpnServerOptions;
|
||||||
|
private nft?: plugins.smartnftables.SmartNftables;
|
||||||
|
private nftHealthInterval?: ReturnType<typeof setInterval>;
|
||||||
|
private nftSubnet?: string;
|
||||||
|
private nftPolicy?: IDestinationPolicy;
|
||||||
|
|
||||||
constructor(options: IVpnServerOptions) {
|
constructor(options: IVpnServerOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -48,6 +55,11 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|||||||
const cfg = config || this.options.config;
|
const cfg = config || this.options.config;
|
||||||
if (cfg) {
|
if (cfg) {
|
||||||
await this.bridge.sendCommand('start', { config: cfg });
|
await this.bridge.sendCommand('start', { config: cfg });
|
||||||
|
|
||||||
|
// For TUN mode with a destination policy, set up nftables rules
|
||||||
|
if (cfg.forwardingMode === 'tun' && cfg.destinationPolicy) {
|
||||||
|
await this.setupTunDestinationPolicy(cfg.subnet, cfg.destinationPolicy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +164,185 @@ export class VpnServer extends plugins.events.EventEmitter {
|
|||||||
return result.peers;
|
return result.peers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Client Registry (Hub) Methods ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new client. Generates keypairs, assigns IP, returns full config bundle.
|
||||||
|
* The secrets (private keys) are only returned at creation time.
|
||||||
|
*/
|
||||||
|
public async createClient(opts: Partial<IClientEntry>): Promise<IClientConfigBundle> {
|
||||||
|
return this.bridge.sendCommand('createClient', { client: opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a registered client (also disconnects if connected).
|
||||||
|
*/
|
||||||
|
public async removeClient(clientId: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('removeClient', { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a registered client by ID.
|
||||||
|
*/
|
||||||
|
public async getClient(clientId: string): Promise<IClientEntry> {
|
||||||
|
return this.bridge.sendCommand('getClient', { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered clients.
|
||||||
|
*/
|
||||||
|
public async listRegisteredClients(): Promise<IClientEntry[]> {
|
||||||
|
const result = await this.bridge.sendCommand('listRegisteredClients', {} as Record<string, never>);
|
||||||
|
return result.clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a registered client's fields (ACLs, tags, description, etc.).
|
||||||
|
*/
|
||||||
|
public async updateClient(clientId: string, update: Partial<IClientEntry>): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('updateClient', { clientId, update });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a previously disabled client.
|
||||||
|
*/
|
||||||
|
public async enableClient(clientId: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('enableClient', { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a client (also disconnects if connected).
|
||||||
|
*/
|
||||||
|
public async disableClient(clientId: string): Promise<void> {
|
||||||
|
await this.bridge.sendCommand('disableClient', { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate a client's keys. Returns a new config bundle with fresh keypairs.
|
||||||
|
*/
|
||||||
|
public async rotateClientKey(clientId: string): Promise<IClientConfigBundle> {
|
||||||
|
return this.bridge.sendCommand('rotateClientKey', { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a client config (without secrets) in the specified format.
|
||||||
|
*/
|
||||||
|
public async exportClientConfig(clientId: string, format: 'smartvpn' | 'wireguard'): Promise<string> {
|
||||||
|
const result = await this.bridge.sendCommand('exportClientConfig', { clientId, format });
|
||||||
|
return result.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a standalone Noise IK keypair (not tied to a client).
|
||||||
|
*/
|
||||||
|
public async generateClientKeypair(): Promise<IVpnKeypair> {
|
||||||
|
return this.bridge.sendCommand('generateClientKeypair', {} as Record<string, never>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TUN Destination Policy via nftables ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up nftables rules for TUN mode destination policy.
|
||||||
|
* Also starts a 60-second health check interval to re-apply if rules are removed externally.
|
||||||
|
*/
|
||||||
|
private async setupTunDestinationPolicy(subnet: string, policy: IDestinationPolicy): Promise<void> {
|
||||||
|
this.nftSubnet = subnet;
|
||||||
|
this.nftPolicy = policy;
|
||||||
|
this.nft = new plugins.smartnftables.SmartNftables({
|
||||||
|
tableName: 'smartvpn_tun',
|
||||||
|
dryRun: process.getuid?.() !== 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.nft.initialize();
|
||||||
|
await this.applyDestinationPolicyRules();
|
||||||
|
|
||||||
|
// Health check: re-apply rules if they disappear
|
||||||
|
this.nftHealthInterval = setInterval(async () => {
|
||||||
|
if (!this.nft) return;
|
||||||
|
try {
|
||||||
|
const exists = await this.nft.tableExists();
|
||||||
|
if (!exists) {
|
||||||
|
console.warn('[smartvpn] nftables rules missing, re-applying destination policy');
|
||||||
|
this.nft = new plugins.smartnftables.SmartNftables({
|
||||||
|
tableName: 'smartvpn_tun',
|
||||||
|
});
|
||||||
|
await this.nft.initialize();
|
||||||
|
await this.applyDestinationPolicyRules();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[smartvpn] nftables health check failed: ${err}`);
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply destination policy as nftables rules.
|
||||||
|
* Order: blockList (drop) → allowList (accept) → default action.
|
||||||
|
*/
|
||||||
|
private async applyDestinationPolicyRules(): Promise<void> {
|
||||||
|
if (!this.nft || !this.nftSubnet || !this.nftPolicy) return;
|
||||||
|
|
||||||
|
const subnet = this.nftSubnet;
|
||||||
|
const policy = this.nftPolicy;
|
||||||
|
const family = 'ip';
|
||||||
|
const table = 'smartvpn_tun';
|
||||||
|
const commands: string[] = [];
|
||||||
|
|
||||||
|
// 1. Block list (deny wins — evaluated first)
|
||||||
|
if (policy.blockList) {
|
||||||
|
for (const dest of policy.blockList) {
|
||||||
|
commands.push(
|
||||||
|
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} drop`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Allow list (pass through directly — skip DNAT)
|
||||||
|
if (policy.allowList) {
|
||||||
|
for (const dest of policy.allowList) {
|
||||||
|
commands.push(
|
||||||
|
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} ip daddr ${dest} accept`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Default action
|
||||||
|
switch (policy.default) {
|
||||||
|
case 'forceTarget': {
|
||||||
|
const target = policy.target || '127.0.0.1';
|
||||||
|
commands.push(
|
||||||
|
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} dnat to ${target}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'block':
|
||||||
|
commands.push(
|
||||||
|
`nft add rule ${family} ${table} prerouting ip saddr ${subnet} drop`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'allow':
|
||||||
|
// No rule needed — kernel default allows
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commands.length > 0) {
|
||||||
|
await this.nft.applyRuleGroup('vpn-destination-policy', commands);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the daemon bridge.
|
* Stop the daemon bridge.
|
||||||
*/
|
*/
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
|
// Clean up nftables rules
|
||||||
|
if (this.nftHealthInterval) {
|
||||||
|
clearInterval(this.nftHealthInterval);
|
||||||
|
this.nftHealthInterval = undefined;
|
||||||
|
}
|
||||||
|
if (this.nft) {
|
||||||
|
this.nft.cleanup().catch(() => {}); // best-effort cleanup
|
||||||
|
this.nft = undefined;
|
||||||
|
}
|
||||||
this.bridge.stop();
|
this.bridge.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ export type TVpnTransportOptions = IVpnTransportStdio | IVpnTransportSocket;
|
|||||||
export interface IVpnClientConfig {
|
export interface IVpnClientConfig {
|
||||||
/** Server WebSocket URL, e.g. wss://vpn.example.com/tunnel */
|
/** Server WebSocket URL, e.g. wss://vpn.example.com/tunnel */
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
/** Server's static public key (base64) for Noise NK handshake */
|
/** Server's static public key (base64) for Noise IK handshake */
|
||||||
serverPublicKey: string;
|
serverPublicKey: string;
|
||||||
|
/** Client's Noise IK private key (base64) — required for SmartVPN native transport */
|
||||||
|
clientPrivateKey: string;
|
||||||
|
/** Client's Noise IK public key (base64) — for reference/display */
|
||||||
|
clientPublicKey: string;
|
||||||
/** Optional DNS servers to use while connected */
|
/** Optional DNS servers to use while connected */
|
||||||
dns?: string[];
|
dns?: string[];
|
||||||
/** Optional MTU for the TUN device */
|
/** Optional MTU for the TUN device */
|
||||||
@@ -36,6 +40,9 @@ export interface IVpnClientConfig {
|
|||||||
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
|
transport?: 'auto' | 'websocket' | 'quic' | 'wireguard';
|
||||||
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
|
/** For QUIC: SHA-256 hash of server certificate (base64) for cert pinning */
|
||||||
serverCertHash?: string;
|
serverCertHash?: string;
|
||||||
|
/** Forwarding mode: 'tun' (TUN device, requires root) or 'testing' (no TUN).
|
||||||
|
* Default: 'testing'. */
|
||||||
|
forwardingMode?: 'tun' | 'testing';
|
||||||
/** WireGuard: client private key (base64, X25519) */
|
/** WireGuard: client private key (base64, X25519) */
|
||||||
wgPrivateKey?: string;
|
wgPrivateKey?: string;
|
||||||
/** WireGuard: client TUN address (e.g. 10.8.0.2) */
|
/** WireGuard: client TUN address (e.g. 10.8.0.2) */
|
||||||
@@ -50,6 +57,8 @@ export interface IVpnClientConfig {
|
|||||||
wgEndpoint?: string;
|
wgEndpoint?: string;
|
||||||
/** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */
|
/** WireGuard: allowed IPs (CIDR strings, e.g. ['0.0.0.0/0']) */
|
||||||
wgAllowedIps?: string[];
|
wgAllowedIps?: string[];
|
||||||
|
/** Client-defined tags reported to the server after connection (informational, not for access control) */
|
||||||
|
clientDefinedClientTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnClientOptions {
|
export interface IVpnClientOptions {
|
||||||
@@ -82,20 +91,61 @@ export interface IVpnServerConfig {
|
|||||||
keepaliveIntervalSecs?: number;
|
keepaliveIntervalSecs?: number;
|
||||||
/** Enable NAT/masquerade for client traffic */
|
/** Enable NAT/masquerade for client traffic */
|
||||||
enableNat?: boolean;
|
enableNat?: boolean;
|
||||||
|
/** Forwarding mode: 'tun' (kernel TUN, requires root), 'socket' (userspace NAT),
|
||||||
|
* or 'testing' (monitoring only). Default: 'testing'. */
|
||||||
|
forwardingMode?: 'tun' | 'socket' | '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. */
|
||||||
defaultBurstBytes?: number;
|
defaultBurstBytes?: number;
|
||||||
/** Transport mode: 'both' (default, WS+QUIC), 'websocket', 'quic', or 'wireguard' */
|
/** Transport mode: 'all' (default, WS+QUIC+WG if configured), 'both' (WS+QUIC),
|
||||||
transportMode?: 'websocket' | 'quic' | 'both' | 'wireguard';
|
* 'websocket', 'quic', or 'wireguard' */
|
||||||
|
transportMode?: 'websocket' | 'quic' | 'both' | 'all' | 'wireguard';
|
||||||
/** QUIC listen address (host:port). Defaults to listenAddr. */
|
/** QUIC listen address (host:port). Defaults to listenAddr. */
|
||||||
quicListenAddr?: string;
|
quicListenAddr?: string;
|
||||||
/** QUIC idle timeout in seconds (default: 30) */
|
/** QUIC idle timeout in seconds (default: 30) */
|
||||||
quicIdleTimeoutSecs?: number;
|
quicIdleTimeoutSecs?: number;
|
||||||
|
/** WireGuard: server X25519 private key (base64). Required when transport includes WG. */
|
||||||
|
wgPrivateKey?: string;
|
||||||
/** WireGuard: UDP listen port (default: 51820) */
|
/** WireGuard: UDP listen port (default: 51820) */
|
||||||
wgListenPort?: number;
|
wgListenPort?: number;
|
||||||
/** WireGuard: configured peers */
|
/** WireGuard: configured peers */
|
||||||
wgPeers?: IWgPeerConfig[];
|
wgPeers?: IWgPeerConfig[];
|
||||||
|
/** Pre-registered clients for Noise IK authentication */
|
||||||
|
clients?: IClientEntry[];
|
||||||
|
/** Enable PROXY protocol v2 on incoming WebSocket connections.
|
||||||
|
* Required when behind a reverse proxy that sends PP v2 headers (HAProxy, SmartProxy).
|
||||||
|
* SECURITY: Must be false when accepting direct client connections. */
|
||||||
|
proxyProtocol?: boolean;
|
||||||
|
/** Server-level IP block list — applied at TCP accept, before Noise handshake.
|
||||||
|
* Supports exact IPs, CIDR, wildcards, ranges. */
|
||||||
|
connectionIpBlockList?: string[];
|
||||||
|
/** When true and forwardingMode is 'socket', the userspace NAT engine prepends
|
||||||
|
* PROXY protocol v2 headers on outbound TCP connections, conveying the VPN client's
|
||||||
|
* tunnel IP as the source address. This allows downstream services (e.g. SmartProxy)
|
||||||
|
* to see the real VPN client identity instead of 127.0.0.1. */
|
||||||
|
socketForwardProxyProtocol?: boolean;
|
||||||
|
/** Destination routing policy for VPN client traffic (socket mode).
|
||||||
|
* Controls where decrypted traffic goes: allow through, block, or redirect to a target.
|
||||||
|
* Default: all traffic passes through (backward compatible). */
|
||||||
|
destinationPolicy?: IDestinationPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destination routing policy for VPN client traffic.
|
||||||
|
* Evaluated per-packet in the NAT engine before per-client ACLs.
|
||||||
|
*/
|
||||||
|
export interface IDestinationPolicy {
|
||||||
|
/** Default action for traffic not matching allow/block lists */
|
||||||
|
default: 'forceTarget' | 'block' | 'allow';
|
||||||
|
/** Target IP address for 'forceTarget' mode (e.g. '127.0.0.1'). Required when default is 'forceTarget'. */
|
||||||
|
target?: string;
|
||||||
|
/** Destinations that pass through directly — not rewritten, not blocked.
|
||||||
|
* Supports: exact IP, CIDR, wildcards (192.168.190.*), ranges. */
|
||||||
|
allowList?: string[];
|
||||||
|
/** Destinations that are always blocked. Overrides allowList (deny wins).
|
||||||
|
* Supports: exact IP, CIDR, wildcards, ranges. */
|
||||||
|
blockList?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnServerOptions {
|
export interface IVpnServerOptions {
|
||||||
@@ -146,6 +196,14 @@ export interface IVpnClientInfo {
|
|||||||
keepalivesReceived: number;
|
keepalivesReceived: number;
|
||||||
rateLimitBytesPerSec?: number;
|
rateLimitBytesPerSec?: number;
|
||||||
burstBytes?: number;
|
burstBytes?: number;
|
||||||
|
/** Client's authenticated Noise IK public key (base64) */
|
||||||
|
authenticatedKey: string;
|
||||||
|
/** Registered client ID from the client registry */
|
||||||
|
registeredClientId: string;
|
||||||
|
/** Real client IP:port (from PROXY protocol or direct TCP connection) */
|
||||||
|
remoteAddr?: string;
|
||||||
|
/** Transport used: "websocket", "quic", or "wireguard" */
|
||||||
|
transportType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVpnServerStatistics extends IVpnStatistics {
|
export interface IVpnServerStatistics extends IVpnStatistics {
|
||||||
@@ -205,6 +263,88 @@ export interface IVpnClientTelemetry {
|
|||||||
burstBytes?: number;
|
burstBytes?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Client Registry (Hub) types — aligned with SmartProxy IRouteSecurity pattern
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Per-client rate limiting. */
|
||||||
|
export interface IClientRateLimit {
|
||||||
|
/** Max throughput in bytes/sec */
|
||||||
|
bytesPerSec: number;
|
||||||
|
/** Burst allowance in bytes */
|
||||||
|
burstBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-client security settings.
|
||||||
|
* Mirrors SmartProxy's IRouteSecurity: ipAllowList/ipBlockList naming + deny-overrides-allow.
|
||||||
|
* Adds VPN-specific destination filtering.
|
||||||
|
*/
|
||||||
|
export interface IClientSecurity {
|
||||||
|
/** Source IPs/CIDRs the client may connect FROM (empty = any).
|
||||||
|
* Supports: exact IP, CIDR, wildcard (192.168.1.*), ranges (1.1.1.1-1.1.1.5). */
|
||||||
|
ipAllowList?: string[];
|
||||||
|
/** Source IPs blocked — overrides ipAllowList (deny wins). */
|
||||||
|
ipBlockList?: string[];
|
||||||
|
/** Destination IPs/CIDRs the client may reach through the VPN (empty = all). */
|
||||||
|
destinationAllowList?: string[];
|
||||||
|
/** Destination IPs blocked — overrides destinationAllowList (deny wins). */
|
||||||
|
destinationBlockList?: string[];
|
||||||
|
/** Max concurrent connections from this client. */
|
||||||
|
maxConnections?: number;
|
||||||
|
/** Per-client rate limiting. */
|
||||||
|
rateLimit?: IClientRateLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side client definition — the central config object for the Hub.
|
||||||
|
* Naming and structure aligned with SmartProxy's IRouteConfig / IRouteSecurity.
|
||||||
|
*/
|
||||||
|
export interface IClientEntry {
|
||||||
|
/** Human-readable client ID (e.g. "alice-laptop") */
|
||||||
|
clientId: string;
|
||||||
|
/** Client's Noise IK public key (base64) — for SmartVPN native transport */
|
||||||
|
publicKey: string;
|
||||||
|
/** Client's WireGuard public key (base64) — for WireGuard transport */
|
||||||
|
wgPublicKey?: string;
|
||||||
|
/** Security settings (ACLs, rate limits) */
|
||||||
|
security?: IClientSecurity;
|
||||||
|
/** Traffic priority (lower = higher priority, default: 100) */
|
||||||
|
priority?: number;
|
||||||
|
/** Whether this client is enabled (default: true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Tags assigned by the server admin — trusted, used for access control (e.g. ["engineering", "office"]) */
|
||||||
|
serverDefinedClientTags?: string[];
|
||||||
|
/** Tags reported by the connecting client — informational only, never used for access control */
|
||||||
|
clientDefinedClientTags?: string[];
|
||||||
|
/** @deprecated Use serverDefinedClientTags instead. Legacy field kept for backward compatibility. */
|
||||||
|
tags?: string[];
|
||||||
|
/** Optional description */
|
||||||
|
description?: string;
|
||||||
|
/** Optional expiry (ISO 8601 timestamp, omit = never expires) */
|
||||||
|
expiresAt?: string;
|
||||||
|
/** Assigned VPN IP address (set by server) */
|
||||||
|
assignedIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete client config bundle — returned by createClient() and rotateClientKey().
|
||||||
|
* Contains everything the client needs to connect.
|
||||||
|
*/
|
||||||
|
export interface IClientConfigBundle {
|
||||||
|
/** The server-side client entry */
|
||||||
|
entry: IClientEntry;
|
||||||
|
/** Ready-to-use SmartVPN client config (typed object) */
|
||||||
|
smartvpnConfig: IVpnClientConfig;
|
||||||
|
/** Ready-to-use WireGuard .conf file content (string) */
|
||||||
|
wireguardConfig: string;
|
||||||
|
/** Client's private keys (ONLY returned at creation time, not stored server-side) */
|
||||||
|
secrets: {
|
||||||
|
noisePrivateKey: string;
|
||||||
|
wgPrivateKey: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WireGuard-specific types
|
// WireGuard-specific types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -262,6 +402,17 @@ export type TVpnServerCommands = {
|
|||||||
addWgPeer: { params: { peer: IWgPeerConfig }; result: void };
|
addWgPeer: { params: { peer: IWgPeerConfig }; result: void };
|
||||||
removeWgPeer: { params: { publicKey: string }; result: void };
|
removeWgPeer: { params: { publicKey: string }; result: void };
|
||||||
listWgPeers: { params: Record<string, never>; result: { peers: IWgPeerInfo[] } };
|
listWgPeers: { params: Record<string, never>; result: { peers: IWgPeerInfo[] } };
|
||||||
|
// Client Registry (Hub) commands
|
||||||
|
createClient: { params: { client: Partial<IClientEntry> }; result: IClientConfigBundle };
|
||||||
|
removeClient: { params: { clientId: string }; result: void };
|
||||||
|
getClient: { params: { clientId: string }; result: IClientEntry };
|
||||||
|
listRegisteredClients: { params: Record<string, never>; result: { clients: IClientEntry[] } };
|
||||||
|
updateClient: { params: { clientId: string; update: Partial<IClientEntry> }; result: void };
|
||||||
|
enableClient: { params: { clientId: string }; result: void };
|
||||||
|
disableClient: { params: { clientId: string }; result: void };
|
||||||
|
rotateClientKey: { params: { clientId: string }; result: IClientConfigBundle };
|
||||||
|
exportClientConfig: { params: { clientId: string; format: 'smartvpn' | 'wireguard' }; result: { config: string } };
|
||||||
|
generateClientKeypair: { params: Record<string, never>; result: IVpnKeypair };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import * as events from 'events';
|
|||||||
export { path, fs, os, url, events };
|
export { path, fs, os, url, events };
|
||||||
|
|
||||||
// @push.rocks
|
// @push.rocks
|
||||||
|
import * as smartnftables from '@push.rocks/smartnftables';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
import * as smartrust from '@push.rocks/smartrust';
|
import * as smartrust from '@push.rocks/smartrust';
|
||||||
|
|
||||||
export { smartpath, smartrust };
|
export { smartnftables, smartpath, smartrust };
|
||||||
|
|||||||
Reference in New Issue
Block a user