Files
xah30 0a73d5298b feat(cli): server IP pool + per-client routing (multi-client VPN concentrator)
Server now assigns each connected client an IP from a configurable pool and
maintains a client_ip -> AuraConnection map so packets read from the shared
TUN are dispatched to the right client (and each client's recv loop writes
back to the TUN). Removes v1's "single shared TUN, no NAT/pool" limitation;
turns the server into a proper multi-client VPN concentrator (paired with the
already-landed UDP multi-client demux).

- aura_cli::pool: IpPool + PoolStrategy {StaticOnly, DynamicOnly,
  StaticOrDynamic}; reserves network/broadcast/server-own IP; 15 tests.
- aura_cli::server_router: ServerRouter + ServerRoutes (Arc<RwLock<HashMap>>);
  central TUN read loop dispatching by dst_ip; spawn_inbound_forwarder per
  conn auto-unregisters and releases the IP on disconnect; 4 tests via
  MockTun + MockConn.
- aura_cli::config: [server.pool] {cidr, strategy, static} added with
  serde(default); legacy configs (only [tunnel] pool_cidr) fall back to a
  DynamicOnly pool (backward compatible, tested).
- aura_cli::server: accept loop now: pool.assign(peer_id) -> register ->
  spawn_inbound_forwarder; rejected static_only mismatches dropped+logged.
- config/server.toml.example: documented [server.pool] section.

Workspace: 141 tests passed (+24), clippy -D warnings clean, fmt clean. No
new workspace deps (async-trait added to cli dev-deps for mock traits in tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 01:41:29 +03:00

102 lines
3.3 KiB
Rust

//! Integration-level coverage of [`aura_cli::pool::IpPool`].
//!
//! The unit tests inside `src/pool.rs` cover the strategy matrix in isolation. These tests
//! exercise the same surface as an external crate would — through the public re-exports — so
//! that the API contract stays usable for downstream consumers (the `aura server` runtime).
use std::collections::HashMap;
use std::net::IpAddr;
use aura_cli::pool::{IpPool, PoolStrategy};
use ipnetwork::IpNetwork;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
fn net(s: &str) -> IpNetwork {
s.parse().unwrap()
}
/// Smoke: build a tiny /29 pool, allocate, release, re-allocate the same address.
#[tokio::test]
async fn pool_allocate_release_cycle() {
let pool = IpPool::new(
net("10.8.0.0/29"),
PoolStrategy::DynamicOnly,
HashMap::new(),
ip("10.8.0.1"),
)
.expect("pool");
let a = pool.assign("a").await.expect("first");
let b = pool.assign("b").await.expect("second");
assert_ne!(a, b, "distinct clients must get distinct addresses");
let snap_before = pool.in_use_snapshot().await;
assert!(snap_before.contains(&a));
assert!(snap_before.contains(&b));
pool.release(a).await;
let snap_after = pool.in_use_snapshot().await;
assert!(!snap_after.contains(&a));
// Next dynamic allocation can hand back `a`'s address.
let again = pool.assign("c").await.expect("recycled");
assert_eq!(again, a, "released ip should be reusable");
}
/// `StaticOrDynamic` honours a statically reserved address and never hands it to anyone else.
#[tokio::test]
async fn pool_static_reservation_is_pinned() {
let mut statics = HashMap::new();
statics.insert("phone-1".to_string(), ip("10.8.0.20"));
let pool = IpPool::new(
net("10.8.0.0/24"),
PoolStrategy::StaticOrDynamic,
statics,
ip("10.8.0.1"),
)
.expect("pool");
assert_eq!(pool.assign("phone-1").await, Some(ip("10.8.0.20")));
// Many dynamic allocations: none of them must collide with the static reservation.
for cid in ["a", "b", "c", "d", "e"] {
let got = pool.assign(cid).await.expect("dynamic");
assert_ne!(got, ip("10.8.0.20"), "{cid} got the static reservation");
}
}
/// `StaticOnly` refuses unknown ids — the strict deployment mode.
#[tokio::test]
async fn pool_static_only_refuses_unknown() {
let mut statics = HashMap::new();
statics.insert("phone-1".to_string(), ip("10.8.0.20"));
let pool = IpPool::new(
net("10.8.0.0/24"),
PoolStrategy::StaticOnly,
statics,
ip("10.8.0.1"),
)
.expect("pool");
assert!(pool.assign("phone-1").await.is_some());
assert!(
pool.assign("randomer").await.is_none(),
"StaticOnly must refuse unknown ids"
);
}
/// Exhausting the pool returns `None` instead of looping forever or panicking.
#[tokio::test]
async fn pool_exhaustion_returns_none() {
// /30 -> 4 total, .0 net, .1 server, .2 usable, .3 broadcast => 1 dynamic slot.
let pool = IpPool::new(
net("10.8.0.0/30"),
PoolStrategy::DynamicOnly,
HashMap::new(),
ip("10.8.0.1"),
)
.expect("pool");
assert_eq!(pool.assign("a").await, Some(ip("10.8.0.2")));
assert!(pool.assign("b").await.is_none(), "pool exhausted");
}