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>
This commit is contained in:
@@ -11,8 +11,9 @@
|
||||
//! * [`ClientConfigFile::build_route_table`] turns `[tunnel.split]` into a [`RouteTable`] (CIDR
|
||||
//! rules applied directly; domain rules recorded for later DNS resolution).
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
@@ -23,6 +24,8 @@ use aura_tunnel::{RouteAction, RouteTable};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::pool::PoolStrategy;
|
||||
|
||||
// ---- server.toml ----------------------------------------------------------------------------
|
||||
|
||||
/// Top-level `server.toml` document.
|
||||
@@ -42,6 +45,50 @@ pub struct ServerConfigFile {
|
||||
pub transport: TransportSection,
|
||||
}
|
||||
|
||||
/// `[server.pool]` section: the v2 per-client IP pool + static reservations.
|
||||
///
|
||||
/// Optional for backwards compatibility. When the section is omitted the server falls back to
|
||||
/// `[tunnel] pool_cidr` interpreted as a [`PoolStrategy::DynamicOnly`] pool. The server's own IP
|
||||
/// (the network-address + 1) is implicit; it is reserved automatically by [`crate::pool::IpPool`].
|
||||
///
|
||||
/// Example:
|
||||
/// ```toml
|
||||
/// [server.pool]
|
||||
/// cidr = "10.8.0.0/24"
|
||||
/// strategy = "static_or_dynamic" # or "static_only" / "dynamic_only"
|
||||
///
|
||||
/// [server.pool.static]
|
||||
/// "phone-1" = "10.8.0.2"
|
||||
/// "laptop-1" = "10.8.0.3"
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ServerPoolSection {
|
||||
/// Optional pool CIDR; when omitted the section's existence still selects the v2 path but the
|
||||
/// CIDR falls back to `[tunnel] pool_cidr`. (The two-keys-are-fine semantics keeps editing
|
||||
/// a `pool_cidr`-style config painless.)
|
||||
pub cidr: Option<String>,
|
||||
/// Allocation strategy: `"static_only"`, `"dynamic_only"`, or `"static_or_dynamic"`.
|
||||
pub strategy: Option<String>,
|
||||
/// `client_id -> ip` reservations applied under StaticOnly / StaticOrDynamic. The map key is
|
||||
/// the verified Common Name from the client's certificate; the value is an IP in `cidr`.
|
||||
#[serde(rename = "static")]
|
||||
pub static_map: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Fully resolved [`ServerPoolSection`] with parsed CIDR + strategy + static map.
|
||||
///
|
||||
/// Built by [`ServerConfigFile::resolve_pool_config`]; fed to [`crate::pool::IpPool::new`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPoolConfig {
|
||||
/// Pool CIDR.
|
||||
pub cidr: IpNetwork,
|
||||
/// Allocation strategy.
|
||||
pub strategy: PoolStrategy,
|
||||
/// Parsed `client_id -> ip` static reservations.
|
||||
pub static_map: HashMap<String, IpAddr>,
|
||||
}
|
||||
|
||||
/// `[server]` section.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ServerSection {
|
||||
@@ -53,6 +100,10 @@ pub struct ServerSection {
|
||||
/// Number of accept workers (advisory in v1).
|
||||
#[serde(default = "default_workers")]
|
||||
pub workers: usize,
|
||||
/// `[server.pool]` sub-section: v2 per-client IP pool. Omitting it triggers a v1-compatible
|
||||
/// fallback that interprets `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool.
|
||||
#[serde(default)]
|
||||
pub pool: ServerPoolSection,
|
||||
}
|
||||
|
||||
/// `[tunnel]` section of `server.toml`.
|
||||
@@ -388,6 +439,71 @@ impl ServerConfigFile {
|
||||
.with_context(|| format!("invalid [tunnel] pool_cidr '{}'", self.tunnel.pool_cidr))
|
||||
}
|
||||
|
||||
/// Resolve the v2 `[server.pool]` configuration with v1 fallback.
|
||||
///
|
||||
/// Resolution order:
|
||||
///
|
||||
/// 1. If `[server.pool]` is non-empty (any of `cidr`, `strategy`, or a static entry), use it.
|
||||
/// The `cidr` defaults to `[tunnel] pool_cidr` if unset; the `strategy` defaults to
|
||||
/// [`PoolStrategy::StaticOrDynamic`].
|
||||
/// 2. Otherwise, fall back to `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool
|
||||
/// with no static reservations. This is the v1-compatible path so old configs still work.
|
||||
///
|
||||
/// Errors are returned as readable strings on bad CIDRs / strategies / static-IP parses.
|
||||
pub fn resolve_pool_config(&self) -> anyhow::Result<ResolvedPoolConfig> {
|
||||
let section = &self.server.pool;
|
||||
let section_is_empty =
|
||||
section.cidr.is_none() && section.strategy.is_none() && section.static_map.is_empty();
|
||||
|
||||
// Pick the CIDR: [server.pool] cidr wins, then [tunnel] pool_cidr.
|
||||
let cidr_str = section
|
||||
.cidr
|
||||
.as_deref()
|
||||
.unwrap_or(self.tunnel.pool_cidr.as_str());
|
||||
if cidr_str.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"neither [server.pool] cidr nor [tunnel] pool_cidr is set — \
|
||||
the server needs an address pool to allocate per-client IPs"
|
||||
));
|
||||
}
|
||||
let cidr: IpNetwork = cidr_str
|
||||
.parse()
|
||||
.with_context(|| format!("invalid pool cidr '{cidr_str}'"))?;
|
||||
|
||||
// Pick the strategy. When the section is wholly absent, fall back to DynamicOnly so
|
||||
// old [tunnel] pool_cidr-only configs keep working without per-client static pinning.
|
||||
let strategy = if section_is_empty {
|
||||
PoolStrategy::DynamicOnly
|
||||
} else {
|
||||
match section.strategy.as_deref().unwrap_or("static_or_dynamic") {
|
||||
"static_only" => PoolStrategy::StaticOnly,
|
||||
"dynamic_only" => PoolStrategy::DynamicOnly,
|
||||
"static_or_dynamic" => PoolStrategy::StaticOrDynamic,
|
||||
other => {
|
||||
return Err(anyhow!(
|
||||
"invalid [server.pool] strategy '{other}' \
|
||||
(expected 'static_only' | 'dynamic_only' | 'static_or_dynamic')"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the static map.
|
||||
let mut static_map: HashMap<String, IpAddr> = HashMap::new();
|
||||
for (cid, ip_str) in §ion.static_map {
|
||||
let ip: IpAddr = ip_str
|
||||
.parse()
|
||||
.with_context(|| format!("invalid IP '{ip_str}' for static reservation '{cid}'"))?;
|
||||
static_map.insert(cid.clone(), ip);
|
||||
}
|
||||
|
||||
Ok(ResolvedPoolConfig {
|
||||
cidr,
|
||||
strategy,
|
||||
static_map,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the `[pki]` PEM files and build an [`aura_proto::ServerConfig`].
|
||||
pub fn to_proto(&self) -> anyhow::Result<aura_proto::ServerConfig> {
|
||||
Ok(aura_proto::ServerConfig {
|
||||
@@ -860,6 +976,159 @@ local_ip = "10.7.0.2"
|
||||
assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:444");
|
||||
}
|
||||
|
||||
/// `[server.pool]` is parsed in full (cidr + strategy + static reservations) and
|
||||
/// `resolve_pool_config` builds a usable `ResolvedPoolConfig`.
|
||||
#[test]
|
||||
fn parses_full_server_pool_section() {
|
||||
let s = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[server.pool]
|
||||
cidr = "10.8.0.0/24"
|
||||
strategy = "static_or_dynamic"
|
||||
[server.pool.static]
|
||||
"phone-1" = "10.8.0.20"
|
||||
"laptop-1" = "10.8.0.21"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s).expect("parse");
|
||||
// Raw section state.
|
||||
assert_eq!(cfg.server.pool.cidr.as_deref(), Some("10.8.0.0/24"));
|
||||
assert_eq!(
|
||||
cfg.server.pool.strategy.as_deref(),
|
||||
Some("static_or_dynamic")
|
||||
);
|
||||
assert_eq!(cfg.server.pool.static_map.len(), 2);
|
||||
assert_eq!(
|
||||
cfg.server
|
||||
.pool
|
||||
.static_map
|
||||
.get("phone-1")
|
||||
.map(String::as_str),
|
||||
Some("10.8.0.20")
|
||||
);
|
||||
|
||||
// Resolved view honours [server.pool] over [tunnel] pool_cidr.
|
||||
let resolved = cfg.resolve_pool_config().expect("resolve");
|
||||
assert_eq!(resolved.cidr.to_string(), "10.8.0.0/24");
|
||||
assert_eq!(resolved.strategy, PoolStrategy::StaticOrDynamic);
|
||||
assert_eq!(resolved.static_map.len(), 2);
|
||||
assert_eq!(
|
||||
resolved.static_map.get("laptop-1").copied(),
|
||||
Some("10.8.0.21".parse::<IpAddr>().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
/// `[server.pool]` strategies parse: static_only / dynamic_only / static_or_dynamic.
|
||||
#[test]
|
||||
fn parses_pool_strategies() {
|
||||
for (raw, expected) in [
|
||||
("static_only", PoolStrategy::StaticOnly),
|
||||
("dynamic_only", PoolStrategy::DynamicOnly),
|
||||
("static_or_dynamic", PoolStrategy::StaticOrDynamic),
|
||||
] {
|
||||
let s = format!(
|
||||
r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[server.pool]
|
||||
cidr = "10.8.0.0/24"
|
||||
strategy = "{raw}"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#
|
||||
);
|
||||
let cfg = ServerConfigFile::parse(&s).expect("parse");
|
||||
let resolved = cfg.resolve_pool_config().expect("resolve");
|
||||
assert_eq!(resolved.strategy, expected, "strategy {raw}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown strategy string is a hard error with a readable message.
|
||||
#[test]
|
||||
fn rejects_unknown_pool_strategy() {
|
||||
let s = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[server.pool]
|
||||
cidr = "10.8.0.0/24"
|
||||
strategy = "nonsense"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s).expect("parse");
|
||||
let err = cfg.resolve_pool_config().unwrap_err().to_string();
|
||||
assert!(err.contains("strategy"), "{err}");
|
||||
assert!(err.contains("nonsense"), "{err}");
|
||||
}
|
||||
|
||||
/// Backwards compat: an old server.toml without `[server.pool]` resolves to
|
||||
/// dynamic_only over `[tunnel] pool_cidr` — the v1-compatible fallback.
|
||||
#[test]
|
||||
fn pool_fallback_when_section_omitted() {
|
||||
let s = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s).expect("parse minimal v1 server.toml");
|
||||
// No [server.pool] at all.
|
||||
assert!(cfg.server.pool.cidr.is_none());
|
||||
assert!(cfg.server.pool.strategy.is_none());
|
||||
assert!(cfg.server.pool.static_map.is_empty());
|
||||
|
||||
let resolved = cfg.resolve_pool_config().expect("v1 fallback resolves");
|
||||
assert_eq!(resolved.cidr.to_string(), "10.7.0.0/24");
|
||||
assert_eq!(
|
||||
resolved.strategy,
|
||||
PoolStrategy::DynamicOnly,
|
||||
"fallback strategy is dynamic_only"
|
||||
);
|
||||
assert!(resolved.static_map.is_empty());
|
||||
}
|
||||
|
||||
/// `[server.pool]` without `cidr` reuses `[tunnel] pool_cidr`. Strategy still defaults to
|
||||
/// static_or_dynamic when only the section header is present (i.e. some pool field exists,
|
||||
/// e.g. a static reservation).
|
||||
#[test]
|
||||
fn pool_cidr_defaults_to_tunnel_pool_cidr_when_section_partial() {
|
||||
let s = r#"
|
||||
[server]
|
||||
name = "edge"
|
||||
[server.pool.static]
|
||||
"phone-1" = "10.7.0.20"
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
[tunnel]
|
||||
pool_cidr = "10.7.0.0/24"
|
||||
"#;
|
||||
let cfg = ServerConfigFile::parse(s).expect("parse");
|
||||
let resolved = cfg.resolve_pool_config().expect("resolve");
|
||||
assert_eq!(resolved.cidr.to_string(), "10.7.0.0/24");
|
||||
assert_eq!(resolved.strategy, PoolStrategy::StaticOrDynamic);
|
||||
assert_eq!(resolved.static_map.len(), 1);
|
||||
}
|
||||
|
||||
/// UDP and QUIC share the UDP socket layer; configuring the same port for both must be rejected.
|
||||
#[test]
|
||||
fn rejects_udp_quic_port_collision() {
|
||||
|
||||
Reference in New Issue
Block a user