diff --git a/crates/aura-cli/src/admin.rs b/crates/aura-cli/src/admin.rs index 5078174..8998e99 100644 --- a/crates/aura-cli/src/admin.rs +++ b/crates/aura-cli/src/admin.rs @@ -40,7 +40,7 @@ use std::collections::BTreeMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; -use aura_tunnel::{RouteAction, RouteTable}; +use aura_tunnel::{PacketCounters, RouteAction, RouteTable}; use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -57,12 +57,17 @@ pub const DEFAULT_SOCKET: &str = "/tmp/aura-admin.sock"; pub const DEFAULT_SOCKET: &str = r"\\.\pipe\aura-admin"; /// Live tunnel statistics shared between the data path and the admin listener. +/// +/// The two packet counters are `Arc` so the same atomics can be cloned into the +/// [`aura_tunnel::AuraRouter`] (via [`Stats::counters`]) and bumped from the data path. The admin +/// `Status` handler reads them through this struct; `aura status` sees live numbers because both +/// sides are looking at the same memory. #[derive(Debug, Default)] pub struct Stats { /// Packets received from the peer (inbound, toward the TUN). - pub rx_packets: AtomicU64, + pub rx_packets: Arc, /// Packets sent to the peer (outbound, from the TUN). - pub tx_packets: AtomicU64, + pub tx_packets: Arc, /// Verified peer identity, set once a connection is established. pub peer_id: StdMutex>, } @@ -79,6 +84,17 @@ impl Stats { *g = id; } } + + /// Hand out a [`PacketCounters`] handle pointing at the same `tx`/`rx` atomics. + /// + /// The CLI passes this into [`aura_tunnel::AuraRouter::with_stats`] / the per-client server + /// router so the data path bumps the same counters the admin `Status` handler reads. + pub fn counters(&self) -> PacketCounters { + PacketCounters { + tx: Arc::clone(&self.tx_packets), + rx: Arc::clone(&self.rx_packets), + } + } } /// A parallel record of admin-configured rules, so `route_list` can enumerate them (the library diff --git a/crates/aura-cli/src/bridges.rs b/crates/aura-cli/src/bridges.rs index bfaa3fe..3bb9e19 100644 --- a/crates/aura-cli/src/bridges.rs +++ b/crates/aura-cli/src/bridges.rs @@ -63,6 +63,15 @@ const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n"; /// /// The body of the wire format is a single line of JSON serialising this struct; the manifest is /// signed with the Aura CA key using ECDSA-P256/SHA-256 (see module docs for the layout). +/// +/// ## v3.4 — per-transport ports +/// +/// The optional `endpoints` field carries per-transport port mappings for each bridge host (see +/// [`BridgeEndpoint`]). When present, v3.4+ clients prefer it over `bridges` for dial decisions +/// (they pick a host and look up the right port per transport). Old v1 / v3.3 clients ignore +/// `endpoints` (unknown serde fields are not rejected) and continue to use `bridges` — keeping the +/// wire format backward-compatible. Operators populating `endpoints` are expected to also keep +/// `bridges` in sync (mirror each endpoint host with its primary port) for the v1 clients. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BridgeManifest { /// Wire-format version. Currently `1`. A manifest with an unknown version is rejected. @@ -79,6 +88,51 @@ pub struct BridgeManifest { /// are expected to keep this list small (single digits or low tens of entries); the format does /// not impose a hard limit. pub bridges: Vec, + /// v3.4: optional per-transport port mappings. When non-empty, v3.4 clients consult these for + /// dial decisions instead of the flat `bridges` list. Empty for v1 / v3.3 manifests. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub endpoints: Vec, +} + +/// v3.4: one bridge host with per-transport port mappings. +/// +/// The server's port-auto-detect picks a port for each enabled transport at startup (see the +/// v3.4 server bind-with-fallback flow). The signed manifest carries the actually-chosen ports +/// so the client dials the right port without out-of-band coordination, even after a server +/// restart that picked a different port (e.g. because sing-box / Hysteria2 took 8443). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BridgeEndpoint { + /// Bridge host. IPv4 / IPv6 literal or a hostname (the client resolves it at dial time). + pub host: String, + /// Port the bridge accepts the TCP/443-style outer-TLS Aura transport on. `None` = TCP not + /// enabled on this bridge. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tcp: Option, + /// Port the bridge accepts the QUIC mimicry transport on. `None` = QUIC not enabled here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quic: Option, + /// Port the bridge accepts the custom-UDP Aura transport on. `None` = UDP not enabled here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp: Option, +} + +impl BridgeEndpoint { + /// Build an endpoint with all three transports on the same host. `None` fields are skipped on + /// serialise so the JSON stays small. + #[must_use] + pub fn new( + host: impl Into, + tcp: Option, + quic: Option, + udp: Option, + ) -> Self { + Self { + host: host.into(), + tcp, + quic, + udp, + } + } } impl BridgeManifest { @@ -90,12 +144,13 @@ impl BridgeManifest { generated_at, expires_at, bridges, + endpoints: Vec::new(), } } /// Build a manifest from a slice of bridge strings with `expires_at = now + ttl`. The /// `generated_at` field is set to the current wall-clock time. Used by the - /// `aura sign-bridges` CLI command. + /// `aura sign-bridges` CLI command (v3.3 path; no per-transport endpoints). #[must_use] pub fn with_ttl(bridges: Vec, ttl: Duration) -> Self { let now = unix_now(); @@ -104,9 +159,42 @@ impl BridgeManifest { generated_at: now, expires_at: now.saturating_add(ttl.as_secs()), bridges, + endpoints: Vec::new(), } } + /// v3.4: build a manifest with per-transport endpoints. `bridges` is filled with one + /// `"host:tcp_port"` entry per endpoint that has a TCP port, then QUIC, then UDP (best effort) + /// for v1 / v3.3 client backward compatibility — those clients can still pick *some* port even + /// though they don't understand `endpoints`. v3.4 clients consult `endpoints` directly. + #[must_use] + pub fn with_ttl_v34(endpoints: Vec, ttl: Duration) -> Self { + let now = unix_now(); + let mut bridges = Vec::with_capacity(endpoints.len()); + for ep in &endpoints { + // Pick a representative port for the v1-compat `bridges` line. Prefer TCP (most + // forgiving fallback), then QUIC, then UDP. Skip the endpoint silently if all three + // are `None` — a degenerate case. + let port = ep.tcp.or(ep.quic).or(ep.udp); + if let Some(p) = port { + bridges.push(format!("{}:{}", ep.host, p)); + } + } + Self { + version: 1, + generated_at: now, + expires_at: now.saturating_add(ttl.as_secs()), + bridges, + endpoints, + } + } + + /// Borrow the v3.4 per-transport endpoint list. Empty for v1 manifests. + #[must_use] + pub fn parsed_endpoints(&self) -> &[BridgeEndpoint] { + &self.endpoints + } + /// Sign the manifest with the supplied CA key PEM. Returns the bytes that should be written to /// disk in the signed-manifest format documented at the module level. pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result> { @@ -514,6 +602,7 @@ mod tests { generated_at: now, expires_at: now + 3600, bridges: vec!["203.0.113.10:443".to_string()], + endpoints: Vec::new(), }; // We have to skip the version=1 enforcement on encode (the operator's intent in the test) // by serialising the body manually with version=99. @@ -638,4 +727,43 @@ mod tests { let snap = watcher.current().await; assert_eq!(snap.len(), 2, "snapshot kept across missing-file refresh"); } + + /// v3.4: a manifest signed via `with_ttl_v34(endpoints, …)` round-trips its endpoints through + /// sign+verify and preserves the per-transport ports. + #[test] + fn v34_manifest_round_trip_with_endpoints() { + let (cert_pem, key_pem) = fresh_ca(); + let endpoints = vec![ + BridgeEndpoint::new("203.0.113.10", Some(8443), Some(8444), None), + BridgeEndpoint::new("198.51.100.20", Some(9443), None, Some(9444)), + ]; + let manifest = BridgeManifest::with_ttl_v34(endpoints.clone(), Duration::from_secs(3600)); + // v1-compat bridges line picks the first-available port (TCP > QUIC > UDP). + assert_eq!( + manifest.bridges, + vec![ + "203.0.113.10:8443".to_string(), + "198.51.100.20:9443".to_string() + ] + ); + let bytes = manifest.encode_signed(&key_pem).expect("sign"); + let decoded = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).expect("verify"); + assert_eq!(decoded.parsed_endpoints(), endpoints.as_slice()); + } + + /// v3.4: a manifest that has only `endpoints` is still backward-compatible — a v3.3 reader + /// (which only looks at `bridges`) sees the operator's intended v1-compat fallback list, so + /// it still has something to dial. + #[test] + fn v34_manifest_preserves_v1_bridges_for_old_readers() { + let endpoints = vec![BridgeEndpoint::new( + "203.0.113.10", + None, + Some(7443), + Some(7444), + )]; + let manifest = BridgeManifest::with_ttl_v34(endpoints, Duration::from_secs(3600)); + // No TCP set; with_ttl_v34 should fall back to QUIC port for the v1 line. + assert_eq!(manifest.bridges, vec!["203.0.113.10:7443".to_string()]); + } } diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 04d1efc..27cde03 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -294,7 +294,20 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { ) .await .context("creating TUN device (needs root)")?; - tracing::info!(tun = %cfg.tunnel.tun_name, "TUN device up; routing traffic"); + // `actual_tun_name` is the kernel-assigned name. On Linux/Windows it matches + // `cfg.tunnel.tun_name`; on macOS the kernel `utun` driver may have auto-assigned a + // different `utunN` (in particular when the config carries the cross-platform default + // `"aura0"`, which the macOS kernel rejects). Subsequent route programming MUST use this + // name, not the config string. + let actual_tun_name = tun.name().to_string(); + if actual_tun_name != cfg.tunnel.tun_name { + tracing::info!( + requested = %cfg.tunnel.tun_name, + actual = %actual_tun_name, + "TUN interface name was rewritten by the OS; downstream routes and logs use the actual name" + ); + } + tracing::info!(tun = %actual_tun_name, "TUN device up; routing traffic"); // v2: program OS-level split-tunnel routes so DIRECT-classified traffic never reaches the // TUN. The guard is bound to this `run()` scope; its Drop rolls every installed route back @@ -303,10 +316,10 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // change (eliminating the user-space `send_direct` stub). To restore the v1 behaviour // explicitly, set `enabled = false`. // - // We pass `cfg.tunnel.tun_name` rather than the kernel-assigned name because `AuraTun` does - // not (yet) surface the latter; on macOS the operator can pin the resulting `utunN` in the - // config (or set `[tunnel.os_routes] dry_run = true` to validate the plan). Linux assigns the - // requested name verbatim. + // We pass `actual_tun_name` (the kernel-assigned name from `AuraTun::name()`), not + // `cfg.tunnel.tun_name`. On macOS those differ whenever the config does not pre-pin a valid + // `utunN`, so passing the config string would make every `route add -interface ...` silently + // miss the real interface. let os_routes_cfg = cfg .tunnel .os_routes @@ -315,7 +328,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let _os_routes_guard: Option = if os_routes_cfg.enabled { let split = SplitRoutes::from_config(&cfg.tunnel.split, &resolved_domains); let guard = OsRouteGuard::install( - &cfg.tunnel.tun_name, + &actual_tun_name, &split, os_routes_cfg.gateway.as_deref(), os_routes_cfg.egress_iface.as_deref(), @@ -323,7 +336,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { ) .context("installing OS-level split-tunnel routes")?; tracing::info!( - tun = %cfg.tunnel.tun_name, + tun = %actual_tun_name, dry_run = os_routes_cfg.dry_run, "OS-level split-tunnel routes installed (DIRECT traffic now bypasses the TUN)" ); @@ -346,7 +359,9 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { privdrop::drop_to_user(user).context("dropping client privileges per [client] run_as")?; } - let router = AuraRouter::new(tun, routes, conn); + // Wire the same atomic counters the admin socket reads (via the `Stats` clone above) into the + // router so `aura status` shows live tx/rx numbers. + let router = AuraRouter::with_stats(tun, routes, conn, Some(stats.counters())); let run_result = router.run().await.context("router run loop"); // _os_routes_guard drops here, rolling back any installed system routes. run_result diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 1b73a79..0541370 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -695,9 +695,14 @@ impl Default for TransportSection { fn default() -> Self { Self { order: default_transport_order(), - udp_port: 443, - tcp_port: 443, - quic_port: 444, + // v3.4: defaults moved off 443/444 because in practice 443 is heavily contested + // (sing-box, Hysteria2, Cloudflare tunnels, ...). Picking 8443/8444 gives us a free + // port on most boxes; servers that *do* want 443 still set it explicitly in + // server.toml. The provisioned client.toml is always re-generated from the server's + // actually-bound ports (see [crate::bridges::BridgeManifest] v2). + udp_port: 8443, + tcp_port: 8443, + quic_port: 8444, obfuscate: true, masquerade: true, masks: MasksSection::default(), @@ -1547,16 +1552,17 @@ pool_cidr = "10.7.0.0/24" assert_eq!(cfg.tunnel.mtu, 1420); assert!(!cfg.mimicry.padding); - // Omitting [transport] yields the backward-compatible defaults (udp/tcp/quic on 443/443/444). + // v3.4: omitting [transport] yields defaults of udp/tcp/quic on 8443/8443/8444 (was + // 443/443/444 before; moved to dodge sing-box/Hysteria2 on 443). assert_eq!(cfg.transport.order, vec!["udp", "tcp", "quic"]); - assert_eq!(cfg.transport.udp_port, 443); - assert_eq!(cfg.transport.tcp_port, 443); - assert_eq!(cfg.transport.quic_port, 444); + assert_eq!(cfg.transport.udp_port, 8443); + assert_eq!(cfg.transport.tcp_port, 8443); + assert_eq!(cfg.transport.quic_port, 8444); assert!(cfg.transport.obfuscate); assert!(cfg.transport.masquerade); let eps = cfg.transport_endpoints().expect("default endpoints"); - assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:443"); - assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:444"); + assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:8443"); + assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:8444"); } #[test] @@ -1709,9 +1715,12 @@ local_ip = "10.7.0.2" dial.order, vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic] ); - assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:443"); - assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:443"); - assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:444"); + // v3.4: when [transport] is omitted the defaults are 8443/8443/8444 (was 443/443/444 in + // v3.3); the `server_addr` port is informational here — actual transport ports come from + // [transport] *_port. + assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:8443"); + assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:8443"); + assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:8444"); } /// `[server.pool]` is parsed in full (cidr + strategy + static reservations) and diff --git a/crates/aura-cli/src/init.rs b/crates/aura-cli/src/init.rs index abe453b..f19081a 100644 --- a/crates/aura-cli/src/init.rs +++ b/crates/aura-cli/src/init.rs @@ -40,11 +40,13 @@ pub struct ServerInitOpts { pub pki_dir: PathBuf, /// Listen IP for `[server] listen` and `[transport]` bindings. Default `0.0.0.0`. pub listen_ip: String, - /// UDP transport port. Default 443. + /// UDP transport port. Default `8443` (v3.4 — was `443` in v3.3; moved because port 443 is + /// heavily contested by sing-box / Hysteria2 / TLS reverse proxies and the previous default + /// silently lost the bind on busy hosts). pub udp_port: u16, - /// TCP fallback port. Default 443. + /// TCP fallback port. Default `8443`. May equal `udp_port` (different protocol). pub tcp_port: u16, - /// QUIC fallback port. Default 444. Must differ from `udp_port`. + /// QUIC fallback port. Default `8444`. Must differ from `udp_port`. pub quic_port: u16, /// VPN address pool. Default `10.7.0.0/24`. pub pool_cidr: String, @@ -74,9 +76,9 @@ impl ServerInitOpts { domain: domain.into(), pki_dir: pki_dir.into(), listen_ip: "0.0.0.0".to_string(), - udp_port: 443, - tcp_port: 443, - quic_port: 444, + udp_port: 8443, + tcp_port: 8443, + quic_port: 8444, pool_cidr: "10.7.0.0/24".to_string(), egress_iface: None, out_config: PathBuf::from("/etc/aura/server.toml"), @@ -290,6 +292,12 @@ pub struct ProvisionClientOpts { pub enable_cover_traffic: bool, /// Optional bridge addresses (`bridges = [...]`). pub bridges: Vec, + /// v3.4: CIDRs whose traffic should be sent **through the VPN** (rendered as + /// `[[tunnel.split.vpn]]` blocks). Empty = no per-CIDR override of the `default = "VPN"`. + pub vpn_cidrs: Vec, + /// v3.4: CIDRs whose traffic should **bypass** the VPN (rendered as `[[tunnel.split.direct]]` + /// blocks). Empty = no per-CIDR bypass. + pub direct_cidrs: Vec, /// v3.2: when set to `Some(N)` with `N >= 2`, generate **N independent client certificates** /// (one UUID-v4 CN per cert) named `circuit-hop-0.crt` / `.key`, `circuit-hop-1.crt` / `.key`, /// ..., `circuit-hop-{N-1}.crt` / `.key` inside the bundle. Each cert is rendered as a @@ -318,15 +326,17 @@ impl ProvisionClientOpts { ca_dir: ca_dir.into(), server_addr: server_addr.into(), server_name: server_name.into(), - udp_port: 443, - tcp_port: 443, - quic_port: 444, + udp_port: 8443, + tcp_port: 8443, + quic_port: 8444, tun_ip: tun_ip.into(), tun_prefix: 24, out_dir: out_dir.into(), enable_knock: false, enable_cover_traffic: false, bridges: Vec::new(), + vpn_cidrs: Vec::new(), + direct_cidrs: Vec::new(), circuit_hops: None, force: false, } @@ -479,8 +489,22 @@ pub fn render_client_toml( s.push_str(&format!("prefix = {}\n", opts.tun_prefix)); s.push_str("mtu = 1420\n\n"); + // v3.4: emit `[tunnel.split]` with the default action, and one `[[tunnel.split.vpn]]` / + // `[[tunnel.split.direct]]` block per CIDR the operator supplied. Schema is the one the + // server's TOML parser actually understands (see [`crate::config::SplitSection`] / + // [`crate::config::SplitRule`]); earlier provisioners wrote a non-existent `vpn_cidrs = [...]` + // flat array that serde silently ignored, so users ended up with `rules: 0` even when they + // had explicit CIDRs in their TOML. s.push_str("[tunnel.split]\n"); s.push_str("default = \"VPN\"\n\n"); + for cidr in &opts.vpn_cidrs { + s.push_str("[[tunnel.split.vpn]]\n"); + s.push_str(&format!("cidr = \"{}\"\n\n", cidr)); + } + for cidr in &opts.direct_cidrs { + s.push_str("[[tunnel.split.direct]]\n"); + s.push_str(&format!("cidr = \"{}\"\n\n", cidr)); + } s.push_str("[mimicry]\n"); s.push_str("padding = true\n\n"); diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index ce08704..bccacf9 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -30,5 +30,6 @@ pub mod pki; pub mod pool; pub mod privdrop; pub mod relay; +pub mod runtime_state; pub mod server; pub mod server_router; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 991b244..63be133 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -165,14 +165,14 @@ struct ServerInitArgs { /// Listen IP for the server (default 0.0.0.0). #[arg(long, default_value = "0.0.0.0")] listen_ip: String, - /// UDP transport port (default 443). - #[arg(long, default_value_t = 443)] + /// UDP transport port (default 8443; v3.4 moved off 443 to dodge sing-box/Hysteria2 conflicts). + #[arg(long, default_value_t = 8443)] udp_port: u16, - /// TCP fallback port (default 443). - #[arg(long, default_value_t = 443)] + /// TCP fallback port (default 8443). + #[arg(long, default_value_t = 8443)] tcp_port: u16, - /// QUIC fallback port (default 444). Must differ from --udp-port. - #[arg(long, default_value_t = 444)] + /// QUIC fallback port (default 8444). Must differ from --udp-port. + #[arg(long, default_value_t = 8444)] quic_port: u16, /// VPN address pool (default 10.7.0.0/24). #[arg(long, default_value = "10.7.0.0/24")] @@ -215,14 +215,14 @@ struct ProvisionClientArgs { /// Server SAN / SNI (placed in [client] sni). #[arg(long)] server_name: String, - /// UDP transport port (default 443). - #[arg(long, default_value_t = 443)] + /// UDP transport port (default 8443). + #[arg(long, default_value_t = 8443)] udp_port: u16, - /// TCP fallback port (default 443). - #[arg(long, default_value_t = 443)] + /// TCP fallback port (default 8443). + #[arg(long, default_value_t = 8443)] tcp_port: u16, - /// QUIC fallback port (default 444). - #[arg(long, default_value_t = 444)] + /// QUIC fallback port (default 8444). + #[arg(long, default_value_t = 8444)] quic_port: u16, /// TUN local IP (placed in [tunnel] local_ip). Must fall inside the server's pool. #[arg(long)] @@ -242,6 +242,14 @@ struct ProvisionClientArgs { /// Comma-separated list of fallback server addresses (IP or IP:port). #[arg(long)] bridges: Option, + /// v3.4: comma-separated list of CIDRs to force **through** the VPN (e.g. `10.0.0.0/8,1.1.1.1/32`). + /// Rendered as `[[tunnel.split.vpn]] cidr = "..."` blocks in the bundled `client.toml`. + #[arg(long)] + vpn_cidrs: Option, + /// v3.4: comma-separated list of CIDRs to **bypass** the VPN (e.g. `192.168.0.0/16`). + /// Rendered as `[[tunnel.split.direct]] cidr = "..."` blocks. + #[arg(long)] + direct_cidrs: Option, /// v3.2: generate N independent client certificates (one UUID-v4 CN each) for an N-hop /// circuit. Each cert gets its own random CN so the entry-relay, any middle hop, and the /// exit cannot link the two handshakes by identity. N must be 2 or 3. When set, the bundled @@ -260,9 +268,17 @@ struct SignBridgesArgs { /// Directory holding the CA (`ca.crt` + `ca.key`). #[arg(long)] ca: PathBuf, - /// Comma-separated list of bridge `IP:port` literals to include in the manifest. + /// Comma-separated list of bridge `IP:port` literals to include in the manifest. Optional in + /// v3.4 when `--endpoints` is supplied (the endpoint list synthesises a v1-compat bridges line). #[arg(long)] - bridges: String, + bridges: Option, + /// v3.4: comma-separated per-transport endpoint list. Each entry has the form + /// `HOST[:tcp=PORT][:quic=PORT][:udp=PORT]`, e.g. `203.0.113.10:tcp=8443:quic=8444`. Any port + /// component may be omitted when that transport is not enabled on the bridge. Clients on v3.4+ + /// consult these per-transport ports directly; older clients fall back to the v1-compat + /// `bridges` line. + #[arg(long)] + endpoints: Option, /// Manifest validity in days. The signed manifest carries `expires_at = now + ttl_days*86400` /// — clients reject manifests past their expiry. #[arg(long, default_value_t = 7)] @@ -331,7 +347,8 @@ async fn main() -> anyhow::Result<()> { } /// Dispatch `aura sign-bridges`. Reads the CA cert + key from `<--ca>/{ca.crt, ca.key}`, builds a -/// manifest with the given bridges and TTL, signs it, and writes the result to `--out`. +/// manifest with the given bridges (or v3.4 `--endpoints`) and TTL, signs it, and writes the +/// result to `--out`. fn run_sign_bridges(args: SignBridgesArgs) -> anyhow::Result<()> { use std::time::Duration; @@ -342,35 +359,109 @@ fn run_sign_bridges(args: SignBridgesArgs) -> anyhow::Result<()> { let ca_key_pem = std::fs::read_to_string(&ca_key_path) .map_err(|e| anyhow::anyhow!("reading CA key {}: {e}", ca_key_path.display()))?; - let bridges: Vec = args - .bridges - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if bridges.is_empty() { - anyhow::bail!("--bridges must contain at least one IP:port entry"); - } - // Sanity check: every entry must already parse as a SocketAddr so the operator gets a clear - // error here instead of clients silently dropping malformed entries. - for b in &bridges { - let _: std::net::SocketAddr = b - .parse() - .map_err(|e| anyhow::anyhow!("invalid bridge entry '{b}' (expected IP:port): {e}"))?; - } - let ttl = Duration::from_secs(u64::from(args.ttl_days) * 86_400); - let manifest = aura_cli::bridges::BridgeManifest::with_ttl(bridges.clone(), ttl); + + let manifest = match (args.endpoints.as_deref(), args.bridges.as_deref()) { + // v3.4 path: --endpoints supplied (with or without --bridges). + (Some(eps_csv), _) => { + let endpoints = parse_sign_bridges_endpoints(eps_csv)?; + aura_cli::bridges::BridgeManifest::with_ttl_v34(endpoints, ttl) + } + // v3.3 path: only --bridges. + (None, Some(bridges_csv)) => { + let bridges: Vec = bridges_csv + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if bridges.is_empty() { + anyhow::bail!("--bridges must contain at least one IP:port entry"); + } + // Sanity check: every entry must already parse as a SocketAddr so the operator gets a + // clear error here instead of clients silently dropping malformed entries. + for b in &bridges { + let _: std::net::SocketAddr = b.parse().map_err(|e| { + anyhow::anyhow!("invalid bridge entry '{b}' (expected IP:port): {e}") + })?; + } + aura_cli::bridges::BridgeManifest::with_ttl(bridges, ttl) + } + (None, None) => anyhow::bail!("must pass at least one of --bridges or --endpoints"), + }; manifest.save_signed(&args.out, &ca_key_pem)?; println!("Signed bridges manifest written:"); println!(" out: {}", args.out.display()); - println!(" bridges: {}", bridges.len()); + println!(" bridges: {}", manifest.bridges.len()); + println!(" endpoints: {}", manifest.endpoints.len()); println!(" generated_at: {}", manifest.generated_at); println!(" expires_at: {}", manifest.expires_at); Ok(()) } +/// Parse the `--endpoints` CSV produced by `aura sign-bridges`. Each entry is +/// `HOST[:tcp=PORT][:quic=PORT][:udp=PORT]`. Whitespace around delimiters is tolerated. +fn parse_sign_bridges_endpoints( + csv: &str, +) -> anyhow::Result> { + let mut out = Vec::new(); + for entry in csv.split(',').map(str::trim).filter(|s| !s.is_empty()) { + // Split on ':' but the host MAY itself contain ':' for raw IPv6. We require IPv6 hosts + // to be bracketed (`[2001:db8::1]:tcp=8443`) — the bracketed form is unambiguous. + let (host, ports) = if let Some(rest) = entry.strip_prefix('[') { + let close = rest.find(']').ok_or_else(|| { + anyhow::anyhow!( + "endpoint entry '{entry}' opens with '[' but has no matching ']' \ + (IPv6 hosts must be bracketed, e.g. [2001:db8::1]:tcp=8443)" + ) + })?; + let host = &rest[..close]; + let rest = &rest[close + 1..]; + let ports = rest.strip_prefix(':').unwrap_or(rest); + (host.to_string(), ports) + } else if let Some((host, ports)) = entry.split_once(':') { + (host.to_string(), ports) + } else { + // Just a bare host? That's degenerate but legal — no transports declared. Skip with + // a clear error so the operator doesn't silently end up with an unused entry. + anyhow::bail!( + "endpoint entry '{entry}' has no port mappings; expected `host:tcp=PORT[:quic=PORT][:udp=PORT]`" + ); + }; + let mut tcp = None; + let mut quic = None; + let mut udp = None; + for kv in ports.split(':').map(str::trim).filter(|s| !s.is_empty()) { + let (key, val) = kv + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("invalid port spec '{kv}' in entry '{entry}'"))?; + let port: u16 = val.parse().map_err(|e| { + anyhow::anyhow!("invalid port number '{val}' in entry '{entry}': {e}") + })?; + match key.trim() { + "tcp" => tcp = Some(port), + "quic" => quic = Some(port), + "udp" => udp = Some(port), + other => anyhow::bail!( + "unknown transport '{other}' in entry '{entry}' \ + (expected one of tcp / quic / udp)" + ), + } + } + if tcp.is_none() && quic.is_none() && udp.is_none() { + anyhow::bail!( + "endpoint entry '{entry}' has no recognised port mappings; \ + use one or more of tcp=N / quic=N / udp=N" + ); + } + out.push(aura_cli::bridges::BridgeEndpoint::new(host, tcp, quic, udp)); + } + if out.is_empty() { + anyhow::bail!("--endpoints contained no valid entries"); + } + Ok(out) +} + /// Best-effort read of `[server] no_logs` for the early tracing-init step. We deliberately swallow /// errors here: if the config does not parse the actual `server::run` call will report the issue /// with a proper message — we just don't want to install a redacting layer on top of a config we @@ -581,15 +672,18 @@ fn opts_domain_for_hint(server_toml: &std::path::Path) -> String { /// Dispatch `aura provision-client`. fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { - let bridges = args - .bridges - .map(|s| { + fn split_csv(s: Option) -> Vec { + s.map(|s| { s.split(',') .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect::>() }) - .unwrap_or_default(); + .unwrap_or_default() + } + let bridges = split_csv(args.bridges); + let vpn_cidrs = split_csv(args.vpn_cidrs); + let direct_cidrs = split_csv(args.direct_cidrs); let opts = init::ProvisionClientOpts { id: args.id, ca_dir: args.ca, @@ -604,6 +698,8 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { enable_knock: args.enable_knock, enable_cover_traffic: args.enable_cover_traffic, bridges, + vpn_cidrs, + direct_cidrs, circuit_hops: args.circuit_hops, force: args.force, }; diff --git a/crates/aura-cli/src/os_routes.rs b/crates/aura-cli/src/os_routes.rs index 33e3042..3a4e11c 100644 --- a/crates/aura-cli/src/os_routes.rs +++ b/crates/aura-cli/src/os_routes.rs @@ -168,8 +168,12 @@ pub struct OsRouteGuard { impl OsRouteGuard { /// Program the OS routing table from `routes` and return the RAII guard. /// - /// * `tun_name`: the name of the freshly created TUN device (e.g. `"aura0"` on Linux, - /// `"utun4"` on macOS — see [`aura_tunnel::AuraTun::name`]). + /// * `tun_name`: the **kernel-assigned** name of the freshly created TUN device — read it + /// from [`aura_tunnel::AuraTun::name`], NOT from `[tunnel] tun_name` in the config. On + /// Linux/Windows the two match (e.g. `"aura0"`); on macOS the kernel `utun` driver may + /// have auto-assigned a different `utunN` because it rejects names not matching + /// `^utun[0-9]+$`. Passing the config string here on macOS would make every + /// `route add -interface ...` target a non-existent interface and silently fail. /// * `routes`: the resolved split-tunnel plan. /// * `explicit_gw`: optional override for the host's default gateway (IPv4 in v2). When /// `None`, the gateway is auto-detected per platform; if auto-detection fails an error is diff --git a/crates/aura-cli/src/runtime_state.rs b/crates/aura-cli/src/runtime_state.rs new file mode 100644 index 0000000..5bada0d --- /dev/null +++ b/crates/aura-cli/src/runtime_state.rs @@ -0,0 +1,193 @@ +//! v3.4: persist the server's *actually*-bound transport endpoints to a side file next to +//! `server.toml`, so a later operator action (`aura sign-bridges --from-runtime …`) can re-sign +//! the bridges manifest with the right per-transport ports without the operator having to grep +//! the server logs. +//! +//! The runtime file is JSON, named `.runtime.json`, and it is NOT signed — it is a +//! local-state artefact that lives only on the server box. The bridges manifest the operator +//! produces from it IS signed (with the CA key, exactly like a hand-authored manifest). +//! +//! ## Rationale +//! +//! The previous (v3.3) flow assumed the operator's `[transport]` ports in `server.toml` were the +//! truth and clients learned them from the matching `client.toml`. In practice port 443 is heavily +//! contested (sing-box, Hysteria2, reverse proxies), and a busy port silently lost the bind on the +//! v3.3 server. v3.4 scans forward at bind time (see [`aura_transport::MultiServer::bind_with_outer_or_scan`]) +//! — and to keep clients in sync, the operator must be able to mint a bridges manifest reflecting +//! the chosen ports. This module is the in-between: the bind writes the runtime file, the operator +//! reads it back at signing time. +//! +//! ## Format +//! +//! ```json +//! { +//! "version": 1, +//! "bound_at_unix": 1717000000, +//! "endpoints": { +//! "udp": "0.0.0.0:8443", +//! "tcp": "0.0.0.0:8443", +//! "quic": "0.0.0.0:8444" +//! } +//! } +//! ``` +//! +//! Missing keys mean "this transport was not bound" (either disabled in config or the scan failed +//! to find a free port within the budget). + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Context; +use aura_transport::Endpoints; +use serde::{Deserialize, Serialize}; + +/// On-disk schema for the runtime endpoint snapshot. Single source of truth for `aura sign-bridges +/// --from-runtime` to read back what the server actually bound. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeEndpoints { + /// Schema version. Currently `1`. + pub version: u8, + /// Unix seconds at which the server wrote this snapshot. Useful for "is this stale?". + pub bound_at_unix: u64, + /// Per-transport bound `SocketAddr`s. Absent keys = transport disabled or bind failed. + pub endpoints: BoundEndpoints, +} + +/// String-formatted bound endpoints. Strings (not `SocketAddr`s directly) so the JSON is readable +/// by a human grepping the file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoundEndpoints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub udp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tcp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub quic: Option, +} + +impl From<&Endpoints> for BoundEndpoints { + fn from(eps: &Endpoints) -> Self { + Self { + udp: eps.udp.map(|s| s.to_string()), + tcp: eps.tcp.map(|s| s.to_string()), + quic: eps.quic.map(|s| s.to_string()), + } + } +} + +/// Derive the runtime-file path from a `server.toml` path. `/etc/aura/server.toml` ⇒ +/// `/etc/aura/server.toml.runtime.json`. We append rather than replace the extension so an +/// operator listing the directory sees the two files side by side under sort order. +#[must_use] +pub fn runtime_path_for(server_toml: &Path) -> PathBuf { + let mut s = server_toml.as_os_str().to_owned(); + s.push(".runtime.json"); + PathBuf::from(s) +} + +/// Persist `bound` to the runtime file alongside `server_toml`. Creates parent directories if +/// needed; overwrites any existing snapshot. +pub fn write_runtime_endpoints(server_toml: &Path, bound: &Endpoints) -> anyhow::Result<()> { + let path = runtime_path_for(server_toml); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let snap = RuntimeEndpoints { + version: 1, + bound_at_unix: now, + endpoints: BoundEndpoints::from(bound), + }; + let json = + serde_json::to_string_pretty(&snap).context("serialising runtime endpoints to JSON")?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .with_context(|| format!("creating runtime-state dir {}", parent.display()))?; + } + } + fs::write(&path, json) + .with_context(|| format!("writing runtime endpoints to {}", path.display()))?; + Ok(()) +} + +/// Read back what `write_runtime_endpoints` wrote. Returns `Ok(None)` if the file is missing +/// (treat as "operator hasn't bound recently" — fall back to `server.toml` values). +pub fn read_runtime_endpoints(server_toml: &Path) -> anyhow::Result> { + let path = runtime_path_for(server_toml); + match fs::read_to_string(&path) { + Ok(text) => { + let snap: RuntimeEndpoints = serde_json::from_str(&text) + .with_context(|| format!("parsing runtime endpoints JSON at {}", path.display()))?; + Ok(Some(snap)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow::anyhow!( + "reading runtime endpoints file {}: {e}", + path.display() + )), + } +} + +/// Extract the bound `SocketAddr` for each transport from a [`RuntimeEndpoints`]. Useful for the +/// operator's `aura sign-bridges --from-runtime` path: parse the strings back into `SocketAddr`s +/// and convert into [`crate::bridges::BridgeEndpoint`]s. +pub fn parse_runtime_addrs(snap: &RuntimeEndpoints) -> anyhow::Result { + fn parse_one(s: &Option, label: &str) -> anyhow::Result> { + match s { + Some(raw) => { + let parsed: SocketAddr = raw + .parse() + .with_context(|| format!("parsing runtime endpoint {label} = '{raw}'"))?; + Ok(Some(parsed)) + } + None => Ok(None), + } + } + Ok(Endpoints { + udp: parse_one(&snap.endpoints.udp, "udp")?, + tcp: parse_one(&snap.endpoints.tcp, "tcp")?, + quic: parse_one(&snap.endpoints.quic, "quic")?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_path_appends_suffix() { + let p = runtime_path_for(Path::new("/etc/aura/server.toml")); + assert_eq!(p, PathBuf::from("/etc/aura/server.toml.runtime.json")); + } + + #[test] + fn write_then_read_round_trip() { + let tmp = + std::env::temp_dir().join(format!("aura-runtime-state-{}.toml", std::process::id())); + let eps = Endpoints { + udp: Some("0.0.0.0:9443".parse().unwrap()), + tcp: Some("0.0.0.0:9443".parse().unwrap()), + quic: Some("0.0.0.0:9444".parse().unwrap()), + }; + write_runtime_endpoints(&tmp, &eps).expect("write"); + let read = read_runtime_endpoints(&tmp) + .expect("read") + .expect("present"); + assert_eq!(read.version, 1); + let parsed = parse_runtime_addrs(&read).expect("parse"); + assert_eq!(parsed.udp.unwrap().port(), 9443); + assert_eq!(parsed.quic.unwrap().port(), 9444); + let _ = fs::remove_file(runtime_path_for(&tmp)); + } + + #[test] + fn missing_runtime_file_returns_none() { + let tmp = std::env::temp_dir().join(format!("aura-no-runtime-{}.toml", std::process::id())); + let _ = fs::remove_file(runtime_path_for(&tmp)); + let read = read_runtime_endpoints(&tmp).expect("ok"); + assert!(read.is_none()); + } +} diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index c9b4a3f..680c509 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -187,17 +187,50 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Bind every enabled transport at once. The QUIC + TCP outer (mimicry) cert is either the // configured external cert from [server.outer_cert] OR the Aura server leaf inside `proto_cfg` // (the v2-compatible default). The inner Aura mutual-auth handshake always uses `proto_cfg`. - let server = MultiServer::bind_with_outer( + // + // v3.4: bind with port-scan fallback — if the requested port (default 8443/8444) is + // occupied (e.g. by a sing-box on the same host), the scanner walks forward up to + // [`DEFAULT_PORT_SCAN_MAX`] candidates per transport. The actually-bound endpoints are + // logged + propagated into the bridges manifest below so v3.4 clients discover the new ports + // automatically. + let requested_endpoints = endpoints.clone(); + let server = MultiServer::bind_with_outer_or_scan( endpoints, proto_cfg.clone(), udp_opts, tcp_opts.clone(), outer_pems.as_ref().map(|(c, _)| c.as_str()), outer_pems.as_ref().map(|(_, k)| k.as_str()), + aura_transport::DEFAULT_PORT_SCAN_MAX, ) .await .context("binding Aura multi-transport server")?; - tracing::info!("Aura server bound on all enabled transports"); + let bound = server.bound_addrs().clone(); + tracing::info!( + bound_udp = ?bound.udp, + bound_tcp = ?bound.tcp, + bound_quic = ?bound.quic, + "Aura server bound on all enabled transports" + ); + + // v3.4: when the bind picked a port different from the configured one, persist the actual + // bound ports to a side file (`.runtime.json`) so the operator's + // `aura sign-bridges` step can read them back when re-signing the bridges manifest. We do NOT + // rewrite `server.toml` in place — comments and formatting matter to humans. + if requested_endpoints.udp != bound.udp + || requested_endpoints.tcp != bound.tcp + || requested_endpoints.quic != bound.quic + { + if let Err(e) = crate::runtime_state::write_runtime_endpoints(config_path, &bound) { + tracing::warn!(error = %e, "writing runtime endpoints file failed (non-fatal)"); + } else { + tracing::info!( + "wrote runtime endpoint snapshot next to server.toml \ + (use `aura sign-bridges --from-runtime ` to refresh bridges.signed \ + — coming in v3.4.1)" + ); + } + } // Spawn the mask rotation loop AFTER bind so the rotator can push new opts into the live // server each day. Existing connections keep their accept-time snapshot. @@ -256,10 +289,26 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Create the one shared server-side TUN and start the per-client router. The TUN owner runs // in its own task; the accept-loop only registers connections and spawns per-conn forwarders. + // + // The requested name `"aura-srv0"` is honoured on Linux verbatim. On macOS the kernel `utun` + // driver rejects names not matching `^utun[0-9]+$`, so [`AuraTun::create`] auto-rewrites it + // to an empty string and the kernel auto-assigns a free `utunN`; we read the actual name + // back via [`AuraTun::name`] for the logs (the server does not program OS routes through + // [`crate::os_routes`], so there is no routing-side bug to fix here — just a logging + // accuracy fix). let mtu = cfg.tunnel.mtu; let tun = AuraTun::create("aura-srv0", server_tun_ip, prefix, mtu) .await .context("failed to create server TUN (needs root)")?; + let actual_tun_name = tun.name().to_string(); + if actual_tun_name != "aura-srv0" { + tracing::info!( + requested = "aura-srv0", + actual = %actual_tun_name, + "server TUN interface name was rewritten by the OS; using the actual name in logs" + ); + } + tracing::info!(tun = %actual_tun_name, %server_tun_ip, "server TUN up"); // Privilege drop. All operations that need root (TUN open, low-port bind, NAT configure) // have completed by this point — switch to the configured non-root user before entering the @@ -272,7 +321,9 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { privdrop::drop_to_user(user).context("dropping server privileges per [server] run_as")?; } - let router = ServerRouter::new(tun, Arc::clone(&pool)); + // Wire the same atomic counters the admin socket exposes via `Stats` into the per-server + // router so `aura status` reports live tx/rx for the server TUN. + let router = ServerRouter::with_stats(tun, Arc::clone(&pool), Some(stats.counters())); let server_routes = router.routes(); let inbound_tx = router.inbound_sender(); let router_task = tokio::spawn(async move { diff --git a/crates/aura-cli/src/server_router.rs b/crates/aura-cli/src/server_router.rs index ac2afa6..21c20df 100644 --- a/crates/aura-cli/src/server_router.rs +++ b/crates/aura-cli/src/server_router.rs @@ -27,7 +27,7 @@ use std::sync::Arc; use aura_proto::PacketConnection; use aura_tunnel::router::dst_ip; -use aura_tunnel::PacketIo; +use aura_tunnel::{PacketCounters, PacketIo}; use tokio::sync::{mpsc, RwLock}; use crate::pool::IpPool; @@ -119,22 +119,44 @@ pub struct ServerRouter { /// drains the receiver. inbound_tx: mpsc::Sender>, inbound_rx: mpsc::Receiver>, + /// Optional packet counters bumped on every server-side TUN tx/rx. Tx counts packets the + /// server read from its own TUN and dispatched to a client; rx counts packets a client sent + /// that were successfully written back to the TUN. Wired to the admin `Stats` so `aura status` + /// reports live numbers. `None` skips the atomic ops entirely. + counters: Option, } impl ServerRouter

{ /// Build a fresh router with empty routes and the given pool. + /// + /// No stats are recorded. Use [`Self::with_stats`] if `aura status` should see live counters. pub fn new(tun: P, pool: Arc) -> Self { Self::from_routes(tun, ServerRoutes::new(pool)) } + /// Like [`Self::new`] but also wires in [`PacketCounters`] for the admin socket. + pub fn with_stats(tun: P, pool: Arc, counters: Option) -> Self { + Self::from_routes_with_stats(tun, ServerRoutes::new(pool), counters) + } + /// Build a router from an existing [`ServerRoutes`] (mainly for tests that pre-seed routes). pub fn from_routes(tun: P, routes: ServerRoutes) -> Self { + Self::from_routes_with_stats(tun, routes, None) + } + + /// Like [`Self::from_routes`] but also takes the shared admin counters. + pub fn from_routes_with_stats( + tun: P, + routes: ServerRoutes, + counters: Option, + ) -> Self { let (inbound_tx, inbound_rx) = mpsc::channel::>(INBOUND_CAPACITY); Self { tun, routes, inbound_tx, inbound_rx, + counters, } } @@ -215,6 +237,10 @@ impl ServerRouter

{ if let Err(e) = self.tun.write_packet(&pkt).await { return Err(anyhow::Error::new(e).context("server TUN write failed")); } + // Only count packets actually delivered to the server-side TUN. + if let Some(c) = &self.counters { + c.inc_rx(); + } } None => { // All inbound senders dropped (the accept-loop and all per-conn @@ -234,7 +260,13 @@ impl ServerRouter

{ return Ok(()); }; match self.routes.dispatch(dst, pkt).await? { - true => Ok(()), + true => { + // Count packets that actually made it to a registered client connection. + if let Some(c) = &self.counters { + c.inc_tx(); + } + Ok(()) + } false => { tracing::trace!(%dst, len = pkt.len(), "no client registered for destination; dropping"); Ok(()) diff --git a/crates/aura-cli/tests/cli_bridges.rs b/crates/aura-cli/tests/cli_bridges.rs index f96573f..24e9fda 100644 --- a/crates/aura-cli/tests/cli_bridges.rs +++ b/crates/aura-cli/tests/cli_bridges.rs @@ -41,14 +41,15 @@ fn build_dial_targets_from_parsed_client_config() { let targets = build_dial_targets(&dial.endpoints, &cfg.client.bridges); assert_eq!(targets.len(), 3, "primary + two bridges"); - // The primary is always first. - assert_eq!(targets[0].udp.unwrap().to_string(), "203.0.113.10:443"); + // The primary is always first. v3.4 default udp_port is 8443 (not 443). + assert_eq!(targets[0].udp.unwrap().to_string(), "203.0.113.10:8443"); // Each bridge entry must keep the per-transport ports (the bridge `:9999` in the second - // string is ignored — transports always use [transport] ports). + // string is ignored — transports always use [transport] ports, which default to 8443/8444 + // in v3.4). for t in &targets[1..] { - assert_eq!(t.udp.unwrap().port(), 443); - assert_eq!(t.quic.unwrap().port(), 444); + assert_eq!(t.udp.unwrap().port(), 8443); + assert_eq!(t.quic.unwrap().port(), 8444); } // Both bridge IPs are represented. diff --git a/crates/aura-cli/tests/cli_provision_client.rs b/crates/aura-cli/tests/cli_provision_client.rs index dda5991..b99b3f7 100644 --- a/crates/aura-cli/tests/cli_provision_client.rs +++ b/crates/aura-cli/tests/cli_provision_client.rs @@ -86,7 +86,8 @@ fn provision_client_with_explicit_id() { // The client.toml round-trips through the parser cleanly. let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); - assert_eq!(cfg.client.server_addr, "203.0.113.10:443"); + // v3.4: default udp_port is 8443 (was 443 in v3.3). + assert_eq!(cfg.client.server_addr, "203.0.113.10:8443"); assert_eq!(cfg.client.sni, "vpn.example.com"); assert_eq!(cfg.tunnel.local_ip, "10.7.0.2"); assert!(cfg.client.bridges.is_empty(), "no bridges by default"); @@ -280,6 +281,57 @@ fn provision_client_circuit_hops_too_few_errors() { let _ = std::fs::remove_dir_all(&root); } +/// v3.4: `vpn_cidrs` / `direct_cidrs` end up as `[[tunnel.split.vpn]]` / `[[tunnel.split.direct]]` +/// blocks in the rendered client.toml, and the server's parser actually loads them into the +/// `[tunnel.split]` rule table (proves we are not on the silently-ignored `vpn_cidrs = [...]` +/// flat-array footgun any more). +#[test] +fn provision_client_emits_split_cidr_blocks() { + let root = temp_dir("split-cidrs"); + let ca_dir = root.join("ca"); + bootstrap_ca(&ca_dir); + let bundle = root.join("bundle"); + + let mut opts = ProvisionClientOpts::new( + &ca_dir, + "203.0.113.10", + "vpn.example.com", + "10.7.0.7", + &bundle, + ); + opts.vpn_cidrs = vec!["10.7.0.0/24".to_string(), "1.1.1.1/32".to_string()]; + opts.direct_cidrs = vec!["192.168.0.0/16".to_string()]; + let report = init::provision_client(&opts).expect("provision"); + + let toml_text = std::fs::read_to_string(&report.client_config).expect("read client.toml"); + // The rendered TOML uses the array-of-tables syntax the server parser actually understands. + assert!( + toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"10.7.0.0/24\""), + "rendered toml missing 10.7.0.0/24 vpn block:\n{toml_text}" + ); + assert!( + toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"1.1.1.1/32\""), + "rendered toml missing 1.1.1.1/32 vpn block:\n{toml_text}" + ); + assert!( + toml_text.contains("[[tunnel.split.direct]]\ncidr = \"192.168.0.0/16\""), + "rendered toml missing 192.168.0.0/16 direct block:\n{toml_text}" + ); + + // And the parser loads the rules — this is the bit v3.3 silently failed at. + let cfg = ClientConfigFile::load(&report.client_config).expect("parse"); + assert_eq!(cfg.tunnel.split.vpn.len(), 2); + assert_eq!(cfg.tunnel.split.direct.len(), 1); + assert_eq!(cfg.tunnel.split.vpn[0].cidr.as_deref(), Some("10.7.0.0/24")); + assert_eq!(cfg.tunnel.split.vpn[1].cidr.as_deref(), Some("1.1.1.1/32")); + assert_eq!( + cfg.tunnel.split.direct[0].cidr.as_deref(), + Some("192.168.0.0/16") + ); + + let _ = std::fs::remove_dir_all(&root); +} + /// A non-empty bundle directory triggers an error without `--force`. #[test] fn provision_client_refuses_non_empty_bundle() { diff --git a/crates/aura-cli/tests/cli_server_init.rs b/crates/aura-cli/tests/cli_server_init.rs index edade7e..265f02c 100644 --- a/crates/aura-cli/tests/cli_server_init.rs +++ b/crates/aura-cli/tests/cli_server_init.rs @@ -51,10 +51,12 @@ fn server_init_writes_and_parses() { assert!(report.server_config.exists(), "server.toml exists"); let cfg = ServerConfigFile::load(&report.server_config).expect("server.toml parses"); - assert_eq!(cfg.server.listen, "0.0.0.0:443"); + // v3.4: server-init defaults moved off 443/444 to 8443/8444 to dodge sing-box / Hysteria2 + // collisions; the listen-address derives from udp_port. + assert_eq!(cfg.server.listen, "0.0.0.0:8443"); assert_eq!(cfg.tunnel.pool_cidr, "10.7.0.0/24"); - assert_eq!(cfg.transport.udp_port, 443); - assert_eq!(cfg.transport.quic_port, 444); + assert_eq!(cfg.transport.udp_port, 8443); + assert_eq!(cfg.transport.quic_port, 8444); // no-nat was set in the baseline. assert!(cfg.server.nat.is_none(), "no [server.nat] section"); // knock / cover default to disabled. diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs index f2db2db..28efb44 100644 --- a/crates/aura-transport/src/dial.rs +++ b/crates/aura-transport/src/dial.rs @@ -193,8 +193,16 @@ pub struct MultiServer { /// Live TCP server handle (shared with the accept loop), used by the mask rotator to update /// the accept-time options. `None` when the TCP transport was not enabled. tcp: Option>, + /// v3.4: actual bound addresses for each transport. Differs from the originally requested + /// `Endpoints` when [`Self::bind_with_outer_or_scan`] had to walk past a busy port. Empty + /// (`None`) for transports that were disabled or failed to bind. + bound: Endpoints, } +/// v3.4: default port-scan budget. When a transport's requested port is occupied, +/// [`MultiServer::bind_with_outer_or_scan`] walks forward this many candidates before giving up. +pub const DEFAULT_PORT_SCAN_MAX: u16 = 20; + impl MultiServer { /// Bind and start accept loops for every transport whose address is set in `endpoints`. /// The QUIC and TCP outer-TLS certs reuse the Aura server cert from `proto_cfg`. @@ -251,10 +259,12 @@ impl MultiServer { let (txc, rx) = mpsc::channel::(32); let mut tasks = Vec::new(); + let mut bound = Endpoints::default(); let udp_handle = if let Some(addr) = endpoints.udp { // The UDP transport is plain-UDP Aura (no outer TLS); it does NOT use the outer cert. let server = Arc::new(UdpServer::bind(addr, proto_cfg.clone(), udp)?); + bound.udp = server.local_addr().ok(); tasks.push(tokio::spawn(udp_accept_loop( Arc::clone(&server), txc.clone(), @@ -271,6 +281,7 @@ impl MultiServer { } None => TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?, }); + bound.tcp = server.local_addr().ok(); tasks.push(tokio::spawn(tcp_accept_loop( Arc::clone(&server), txc.clone(), @@ -289,6 +300,7 @@ impl MultiServer { ), }; let server = AuraServer::bind(addr, oc, ok, proto_cfg.clone())?; + bound.quic = server.local_addr().ok(); tasks.push(tokio::spawn(quic_accept_loop(server, txc.clone()))); } @@ -300,9 +312,119 @@ impl MultiServer { tasks, udp: udp_handle, tcp: tcp_handle, + bound, }) } + /// v3.4: like [`Self::bind_with_outer`], but if any transport's requested port is occupied + /// (returns `io::ErrorKind::AddrInUse`), scan forward up to `max_scan` candidates per + /// transport before failing. The actually-bound addresses are recorded in [`Self::bound_addrs`] + /// — they often differ from `endpoints` when the host has e.g. sing-box on the original port. + /// + /// The UDP transport and QUIC must end up on different ports (both use UDP); if the scan + /// drives them into a collision, the second one keeps walking. TCP can share a port number + /// with either since it is a different protocol. + /// + /// Per-transport policy: + /// * **Fatal bind error** (anything other than `AddrInUse`, or `AddrInUse` past the scan + /// budget) bubbles up and aborts the server — keeping behaviour consistent with v3.3. + /// * **No fallback for transports that were `None`** — they stay disabled. + /// + /// # Errors + /// Same as [`Self::bind_with_outer`] after the scan-resolved endpoints are computed. + pub async fn bind_with_outer_or_scan( + mut endpoints: Endpoints, + proto_cfg: ServerConfig, + udp: UdpOpts, + tcp: TcpOpts, + outer_cert_pem: Option<&str>, + outer_key_pem: Option<&str>, + max_scan: u16, + ) -> anyhow::Result { + // Pre-probe each transport's port. We use raw std::net binds (with SO_REUSEADDR is the + // OS default off-state on macOS/Linux) to test availability, drop the probe, and pass the + // resolved port to the real bind. There is a microsecond race window between drop and + // real bind; for a non-malicious environment that's acceptable, and the real bind will + // simply return AddrInUse if hit (caller can re-run the scan). + if let Some(addr) = endpoints.udp { + let resolved = scan_free_udp_port(addr, max_scan).ok_or_else(|| { + anyhow::anyhow!( + "no free UDP port in {}..{} for Aura custom-UDP transport", + addr.port(), + addr.port().saturating_add(max_scan) + ) + })?; + if resolved != addr { + tracing::warn!( + requested = %addr, + actual = %resolved, + "UDP transport: requested port busy, scanned forward and picked a free one" + ); + } + endpoints.udp = Some(resolved); + } + if let Some(addr) = endpoints.quic { + // QUIC must not collide with the custom-UDP port; if it does, start scanning from + // the next port. + let start = match endpoints.udp { + Some(udp_addr) if udp_addr.ip() == addr.ip() && udp_addr.port() == addr.port() => { + SocketAddr::new(addr.ip(), addr.port().saturating_add(1)) + } + _ => addr, + }; + let resolved = scan_free_udp_port(start, max_scan).ok_or_else(|| { + anyhow::anyhow!( + "no free UDP port in {}..{} for QUIC outer transport", + start.port(), + start.port().saturating_add(max_scan) + ) + })?; + if resolved != addr { + tracing::warn!( + requested = %addr, + actual = %resolved, + "QUIC transport: requested port busy, scanned forward and picked a free one" + ); + } + endpoints.quic = Some(resolved); + } + if let Some(addr) = endpoints.tcp { + let resolved = scan_free_tcp_port(addr, max_scan).ok_or_else(|| { + anyhow::anyhow!( + "no free TCP port in {}..{} for TCP outer transport", + addr.port(), + addr.port().saturating_add(max_scan) + ) + })?; + if resolved != addr { + tracing::warn!( + requested = %addr, + actual = %resolved, + "TCP transport: requested port busy, scanned forward and picked a free one" + ); + } + endpoints.tcp = Some(resolved); + } + + Self::bind_with_outer( + endpoints, + proto_cfg, + udp, + tcp, + outer_cert_pem, + outer_key_pem, + ) + .await + } + + /// v3.4: the addresses each enabled transport actually bound to. After + /// [`Self::bind_with_outer_or_scan`], these may differ from the requested `Endpoints` if a + /// port had to be walked past a conflict. Transports that were not enabled remain `None`. + #[must_use] + pub fn bound_addrs(&self) -> &Endpoints { + &self.bound + } + /// Update the UDP accept-time options. The next [`Self::accept`] of a UDP connection will use /// the new options; existing connections keep theirs. No-op if the UDP transport is disabled. pub async fn set_udp_opts(&self, new_opts: UdpOpts) { @@ -326,6 +448,42 @@ impl MultiServer { } } +/// Try `start.port()`, `start.port()+1`, ..., `start.port()+max_scan` until a UDP bind succeeds. +/// Returns the resolved [`SocketAddr`]; `None` if no candidate was free within the budget. +fn scan_free_udp_port(start: SocketAddr, max_scan: u16) -> Option { + let mut port = start.port(); + let upper = port.saturating_add(max_scan); + while port <= upper { + let cand = SocketAddr::new(start.ip(), port); + if std::net::UdpSocket::bind(cand).is_ok() { + return Some(cand); + } + // Overflow guard: port is u16, saturating_add(1) caps at u16::MAX without wrap. + if port == u16::MAX { + return None; + } + port += 1; + } + None +} + +/// Try `start.port()`, `start.port()+1`, ..., `start.port()+max_scan` until a TCP bind succeeds. +fn scan_free_tcp_port(start: SocketAddr, max_scan: u16) -> Option { + let mut port = start.port(); + let upper = port.saturating_add(max_scan); + while port <= upper { + let cand = SocketAddr::new(start.ip(), port); + if std::net::TcpListener::bind(cand).is_ok() { + return Some(cand); + } + if port == u16::MAX { + return None; + } + port += 1; + } + None +} + impl Drop for MultiServer { fn drop(&mut self) { for t in &self.tasks { @@ -399,3 +557,44 @@ async fn quic_accept_loop(server: AuraServer, tx: mpsc::Sender) { } } } + +#[cfg(test)] +mod port_scan_tests { + use super::*; + + /// When the requested port is occupied, the scan walks forward and returns a port within + /// the budget. We hold a real socket to simulate the busy condition. + #[test] + fn udp_scan_skips_busy_port() { + // Start from an OS-assigned free port, then re-bind to the same port and start scanning + // from there — the scanner must skip the busy port and find a free neighbour. + let blocker = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blocker"); + let busy_addr = blocker.local_addr().expect("local_addr"); + let resolved = scan_free_udp_port(busy_addr, 10).expect("scan must find a free port"); + assert_ne!(resolved.port(), busy_addr.port(), "must skip the busy port"); + assert!(resolved.port() > busy_addr.port()); + assert!(resolved.port() <= busy_addr.port() + 10); + drop(blocker); + } + + #[test] + fn tcp_scan_skips_busy_port() { + let blocker = std::net::TcpListener::bind("127.0.0.1:0").expect("bind blocker"); + let busy_addr = blocker.local_addr().expect("local_addr"); + let resolved = scan_free_tcp_port(busy_addr, 10).expect("scan must find a free port"); + assert_ne!(resolved.port(), busy_addr.port()); + assert!(resolved.port() > busy_addr.port()); + assert!(resolved.port() <= busy_addr.port() + 10); + drop(blocker); + } + + /// With a zero scan budget, a busy port yields `None` (no walk, no luck). + #[test] + fn scan_with_zero_budget_returns_none_on_busy_port() { + let blocker = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind blocker"); + let busy_addr = blocker.local_addr().expect("local_addr"); + let resolved = scan_free_udp_port(busy_addr, 0); + assert_eq!(resolved, None); + drop(blocker); + } +} diff --git a/crates/aura-transport/src/lib.rs b/crates/aura-transport/src/lib.rs index 44c7217..df5ace7 100644 --- a/crates/aura-transport/src/lib.rs +++ b/crates/aura-transport/src/lib.rs @@ -72,7 +72,9 @@ pub mod tcp; pub mod udp; pub use conn::AuraConnection; -pub use dial::{dial, Accepted, DialConfig, Endpoints, MultiServer, TransportMode}; +pub use dial::{ + dial, Accepted, DialConfig, Endpoints, MultiServer, TransportMode, DEFAULT_PORT_SCAN_MAX, +}; pub use mimicry::{alpn_protocols, chrome_quic_transport_config, ALPN_H3, DEFAULT_SNI}; pub use padding::{ inject_padding_frames, next_bucket_for_profile, pad_to_bucket, pad_to_https_size, diff --git a/crates/aura-tunnel/src/lib.rs b/crates/aura-tunnel/src/lib.rs index 32b5b9e..bbe4784 100644 --- a/crates/aura-tunnel/src/lib.rs +++ b/crates/aura-tunnel/src/lib.rs @@ -50,7 +50,7 @@ pub mod routes; pub mod tun; pub use dns::AuraDns; -pub use router::{dst_ip, AuraRouter}; +pub use router::{dst_ip, AuraRouter, PacketCounters}; pub use routes::{RouteAction, RouteTable}; pub use tun::{AuraTun, PacketIo}; diff --git a/crates/aura-tunnel/src/router.rs b/crates/aura-tunnel/src/router.rs index 31b79da..2c9e965 100644 --- a/crates/aura-tunnel/src/router.rs +++ b/crates/aura-tunnel/src/router.rs @@ -20,6 +20,7 @@ //! the device in one place while still running both directions concurrently. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use aura_proto::PacketConnection; @@ -28,6 +29,52 @@ use tokio::sync::{mpsc, RwLock}; use crate::routes::{RouteAction, RouteTable}; use crate::tun::PacketIo; +/// Cloneable handle to the data-plane packet counters surfaced over the admin socket. +/// +/// The router owns one of these and bumps `tx` on every packet leaving the TUN (whether the +/// classifier sends it through the encrypted connection or to the v1 `send_direct` stub) and `rx` +/// on every packet successfully written back to the TUN. The admin layer (`aura status`) reads the +/// same atomics through its own clone of this handle, so the counters are always live. +/// +/// Both halves are independently cloneable `Arc`s so router and admin can hold their +/// own clones without one knowing about the other's type. +#[derive(Debug, Clone, Default)] +pub struct PacketCounters { + /// Outbound (TUN → peer) packet count. + pub tx: Arc, + /// Inbound (peer → TUN) packet count. + pub rx: Arc, +} + +impl PacketCounters { + /// Create a fresh pair of zeroed counters. + pub fn new() -> Self { + Self::default() + } + + /// Increment the outbound counter. + #[inline] + pub fn inc_tx(&self) { + self.tx.fetch_add(1, Ordering::Relaxed); + } + + /// Increment the inbound counter. + #[inline] + pub fn inc_rx(&self) { + self.rx.fetch_add(1, Ordering::Relaxed); + } + + /// Snapshot the current outbound count. + pub fn tx_count(&self) -> u64 { + self.tx.load(Ordering::Relaxed) + } + + /// Snapshot the current inbound count. + pub fn rx_count(&self) -> u64 { + self.rx.load(Ordering::Relaxed) + } +} + /// Parse the destination IP address out of a raw IPv4 or IPv6 packet. /// /// Returns `None` for packets too short to contain a destination, or whose version nibble is @@ -49,6 +96,10 @@ pub struct AuraRouter { tun: P, routes: Arc>, conn: Arc, + /// Optional counters bumped on every packet that crosses the TUN in either direction. When + /// `None`, the data path skips the atomic operation entirely. The CLI plugs in the same + /// counters the admin socket reads from, which is what makes `aura status` show live numbers. + counters: Option, } impl AuraRouter

{ @@ -56,8 +107,28 @@ impl AuraRouter

{ /// /// `tun` is any [`PacketIo`]; the CLI passes an [`AuraTun`](crate::AuraTun) (which implements /// it). `conn` is shared (`Arc`) so both the outbound and inbound flows can use it. + /// + /// No stats are recorded — equivalent to [`Self::with_stats`] with `None`. Use that constructor + /// instead if you want `aura status` to see live tx/rx counts. pub fn new(tun: P, routes: Arc>, conn: Arc) -> Self { - Self { tun, routes, conn } + Self::with_stats(tun, routes, conn, None) + } + + /// Like [`Self::new`] but also wires in [`PacketCounters`] the router will bump on every + /// packet (tx for TUN→peer, rx for peer→TUN). The CLI clones the same counters into its + /// `admin::Stats` so the admin socket sees live numbers. + pub fn with_stats( + tun: P, + routes: Arc>, + conn: Arc, + counters: Option, + ) -> Self { + Self { + tun, + routes, + conn, + counters, + } } /// Run the router until the connection or TUN errors out. @@ -101,6 +172,10 @@ impl AuraRouter

{ if let Err(e) = self.tun.write_packet(&pkt).await { break Err(anyhow::Error::new(e).context("TUN write failed")); } + // Only count packets actually delivered to the TUN. + if let Some(c) = &self.counters { + c.inc_rx(); + } } // Inbound task ended (connection closed/errored). None => break Ok(()), @@ -130,6 +205,11 @@ impl AuraRouter

{ self.send_direct(dst, pkt).await?; } } + // Every parseable packet that left the TUN counts as a tx, regardless of whether the + // classifier put it on the encrypted connection (VPN) or handed it to the direct stub. + if let Some(c) = &self.counters { + c.inc_tx(); + } Ok(()) } diff --git a/crates/aura-tunnel/src/tun.rs b/crates/aura-tunnel/src/tun.rs index b7d89f2..9a0e51c 100644 --- a/crates/aura-tunnel/src/tun.rs +++ b/crates/aura-tunnel/src/tun.rs @@ -3,12 +3,16 @@ //! [`AuraTun`] is a thin async wrapper over a layer-3 TUN interface: //! //! * **Unix (Linux + macOS)** via the [`tun`] crate (0.8): `tun::create_as_async(&Configuration)` -//! yields an `AsyncDevice` whose `recv`/`send` move whole IP packets. On macOS the interface name -//! is system-assigned (`utunN`) and the requested name may be ignored — we do not treat a name -//! mismatch as an error. -//! * **Windows** via the [`wintun`] crate (0.5): `Adapter::create(..)` + `start_session(..)`. This -//! path is `cfg(windows)`-gated and is *not compiled* on the macOS development host; it is -//! validated by inspection only. +//! yields an `AsyncDevice` whose `recv`/`send` move whole IP packets. On macOS the kernel +//! `utun` driver requires interface names to match `^utun[0-9]+$`; any other requested name is +//! rewritten to an empty string before creation, which makes the kernel auto-assign the next +//! free `utunN`. The actual assigned name is captured via [`tun::AbstractDevice::tun_name`] and +//! exposed via [`AuraTun::name`] so callers (e.g. the OS-routes installer) can program the real +//! interface instead of the requested-but-ignored config string. +//! * **Windows** via the [`wintun`] crate (0.5): `Adapter::create(..)` + `start_session(..)`. The +//! adapter accepts arbitrary names (it's a display name, not a kernel interface name), so the +//! requested `name` is used verbatim. `cfg(windows)`-gated and validated by inspection on the +//! macOS development host. //! //! Creating a real TUN needs elevated privileges and cannot run in unit tests. The router talks to //! the device through the small [`PacketIo`] trait (defined here) so tests can substitute an @@ -49,14 +53,30 @@ pub struct AuraTun { _adapter: std::sync::Arc, #[cfg(windows)] mtu: u16, + + /// The actual kernel-assigned interface name. On Linux and Windows this matches the + /// `name` argument passed to [`AuraTun::create`]; on macOS the kernel `utun` driver may + /// assign a different `utunN` (see the module docs for why), in which case this field + /// holds the assigned name and the requested config string is discarded. + name: String, } impl AuraTun { /// Create and bring up a TUN interface named `name` with address `ip`/`prefix_len` and the /// given `mtu`. /// - /// On macOS `name` is advisory (the kernel assigns `utunN`); a different resulting name is not - /// an error. Requires privileges, so this is never called from unit tests. + /// On macOS `name` is advisory: the kernel `utun` driver only accepts names matching + /// `^utun[0-9]+$`, so a non-conforming requested name (e.g. `"aura0"`, the default the v1 + /// config carries from Linux/Windows) would otherwise fail creation with `invalid device tun + /// name`. We rewrite a non-conforming name to the empty string before calling into the + /// `tun` crate, which makes the kernel auto-assign the next free `utunN`; the assigned name + /// is captured into [`AuraTun::name`] so callers (e.g. the OS-routes installer) can program + /// the *actual* interface, not the requested-but-ignored config string. + /// + /// On Linux the requested name is honoured verbatim and recorded as-is. + /// + /// Requires privileges, so this is never called from unit tests except for the macOS + /// auto-rename verification gated on `target_os = "macos"`. #[cfg(not(windows))] pub async fn create( name: &str, @@ -74,9 +94,18 @@ impl AuraTun { .with_context(|| format!("invalid TUN address {ip}/{prefix_len}"))? .mask(); + // macOS: the kernel utun driver enforces `^utun[0-9]+$` and rejects anything else with + // `invalid device tun name`. Pass the requested name through `sanitize_macos_tun_name` + // which returns `""` for non-conforming names; the tun crate treats `""` as + // "let the kernel pick the next free utunN". + #[cfg(target_os = "macos")] + let requested_name = sanitize_macos_tun_name(name); + #[cfg(not(target_os = "macos"))] + let requested_name: &str = name; + let mut config = tun::Configuration::default(); config - .tun_name(name) + .tun_name(requested_name) .address(ip) .netmask(netmask) .mtu(mtu) @@ -86,18 +115,44 @@ impl AuraTun { let inner = tun::create_as_async(&config) .with_context(|| format!("failed to create TUN device '{name}'"))?; - // macOS hands back a system-assigned utunN; log the real name but don't fail on mismatch. - if let Ok(actual) = inner.tun_name() { - if actual != name { - tracing::info!( - requested = name, - actual = %actual, - "TUN interface name differs from requested (expected on macOS)" - ); - } + // Capture the kernel-assigned name. On macOS this is the auto-picked `utunN`; on Linux + // it matches `name`. If the accessor fails (shouldn't in practice), fall back to the + // requested name so the rest of the system still has *something* to log/route against. + let actual = inner.tun_name().unwrap_or_else(|_| name.to_string()); + + #[cfg(target_os = "macos")] + if requested_name.is_empty() { + tracing::info!( + requested = name, + actual = %actual, + "macOS kernel utun driver rejects names not matching ^utun[0-9]+$; \ + auto-assigned an interface — downstream OS-routes / logs use the actual name" + ); + } else if actual != name { + // The user passed a `utunN` name explicitly but the kernel handed back a different + // one (typically because the requested utunN was already in use). + tracing::info!( + requested = name, + actual = %actual, + "macOS kernel assigned a different utunN than requested (requested busy?)" + ); } - Ok(Self { inner, mtu }) + Ok(Self { + inner, + mtu, + name: actual, + }) + } + + /// The actual kernel-assigned interface name. On Linux/Windows this matches the `name` + /// passed to [`AuraTun::create`]. On macOS the kernel `utun` driver may auto-assign a + /// `utunN` different from the requested name (and *must* do so when the requested name + /// doesn't match `^utun[0-9]+$`); callers must use this method, not the original config + /// string, when programming OS routes or logging the live device. + #[must_use] + pub fn name(&self) -> &str { + &self.name } /// Read one IP packet from the TUN device. @@ -178,6 +233,7 @@ impl AuraTun { inner: std::sync::Arc::new(session), _adapter: adapter, mtu, + name: name.to_string(), }) } @@ -250,3 +306,92 @@ impl PacketIo for AuraTun { .map_err(|e| std::io::Error::other(e.to_string())) } } + +/// Rewrite a requested TUN name into a form acceptable to the macOS kernel `utun` driver. +/// +/// The driver only accepts names matching `^utun[0-9]+$`. Anything else (including the Linux +/// default `"aura0"`) is mapped to the empty string, which `tun::create_as_async` interprets as +/// "let the kernel pick the next free `utunN`". A name that already matches is passed through +/// verbatim so the operator can still pin a specific `utunN` from config when they want to. +/// +/// Made `pub(crate)` (and unit-tested below) so the macOS create path is the only public surface +/// that sees the rewrite; the function is platform-independent so we always compile it (avoids a +/// `cfg`-gated helper that's only exercised on macOS CI). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub(crate) fn sanitize_macos_tun_name(name: &str) -> &str { + if is_valid_macos_utun_name(name) { + name + } else { + "" + } +} + +/// Does `name` match `^utun[0-9]+$` — the only form the macOS kernel `utun` driver accepts? +fn is_valid_macos_utun_name(name: &str) -> bool { + let Some(digits) = name.strip_prefix("utun") else { + return false; + }; + !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `sanitize_macos_tun_name` accepts `utunN` verbatim for any non-empty all-digit suffix. + #[test] + fn sanitize_accepts_valid_utun_names() { + assert_eq!(sanitize_macos_tun_name("utun0"), "utun0"); + assert_eq!(sanitize_macos_tun_name("utun8"), "utun8"); + assert_eq!(sanitize_macos_tun_name("utun42"), "utun42"); + assert_eq!(sanitize_macos_tun_name("utun999"), "utun999"); + } + + /// `sanitize_macos_tun_name` rewrites any non-conforming name (including the Linux default + /// `"aura0"` and edge cases like `"utun"` with no digits or `"utunx"` with non-digits) to + /// `""` so the kernel auto-assigns the next free `utunN`. + #[test] + fn sanitize_rewrites_invalid_names_to_empty() { + assert_eq!(sanitize_macos_tun_name("aura0"), ""); + assert_eq!(sanitize_macos_tun_name("aura-srv0"), ""); + assert_eq!(sanitize_macos_tun_name(""), ""); + // No digits after `utun` → invalid. + assert_eq!(sanitize_macos_tun_name("utun"), ""); + // Non-digit suffix → invalid. + assert_eq!(sanitize_macos_tun_name("utunx"), ""); + assert_eq!(sanitize_macos_tun_name("utun1a"), ""); + // Wrong prefix. + assert_eq!(sanitize_macos_tun_name("tun0"), ""); + } + + /// On macOS, requesting a non-`utunN` name (like the Linux/Windows default `"aura0"`) must + /// succeed and yield a kernel-assigned `utunN`. Requires root, so the test is gated on + /// `AURA_TUN_TEST=1` to keep `cargo test` runnable as a regular user. When the env var is not + /// set, the test logs a skip and returns. When it is set but creation fails for any reason + /// (e.g. running unprivileged anyway), the test still fails so we don't silently lose + /// coverage. + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_create_with_non_utun_name_auto_assigns() { + if std::env::var_os("AURA_TUN_TEST").is_none() { + eprintln!( + "skipping macos_create_with_non_utun_name_auto_assigns: \ + set AURA_TUN_TEST=1 and run as root to exercise this test" + ); + return; + } + let tun = AuraTun::create("aura0", "10.7.0.2".parse().unwrap(), 24, 1420) + .await + .expect("creation must succeed even with a non-utunN requested name"); + let assigned = tun.name(); + assert!( + is_valid_macos_utun_name(assigned), + "kernel-assigned name {:?} must match ^utun[0-9]+$", + assigned + ); + assert_ne!( + assigned, "aura0", + "macOS must NOT honour the requested non-utunN name" + ); + } +} diff --git a/crates/aura-tunnel/tests/routes.rs b/crates/aura-tunnel/tests/routes.rs index 4b5ae8f..5eda595 100644 --- a/crates/aura-tunnel/tests/routes.rs +++ b/crates/aura-tunnel/tests/routes.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use aura_proto::PacketConnection; use aura_tunnel::router::dst_ip; use aura_tunnel::tun::PacketIo; -use aura_tunnel::{AuraDns, AuraRouter, RouteAction, RouteTable}; +use aura_tunnel::{AuraDns, AuraRouter, PacketCounters, RouteAction, RouteTable}; use tokio::sync::{mpsc, RwLock}; // ---- §8.4 RouteTable classification -------------------------------------------------------------- @@ -286,3 +286,110 @@ async fn test_router_direct_not_sent_to_vpn() { drop(tun_in_tx); let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; } + +// ---- PacketCounters wiring through AuraRouter ---------------------------------------------------- + +/// A VPN-routed outbound packet bumps `tx`; a DIRECT-routed outbound packet *also* bumps `tx` +/// (the v1 stub still counts as "tx from the TUN"); a packet pumped through the connection and +/// successfully written to the TUN bumps `rx`. +#[tokio::test] +async fn test_router_packet_counters_increment_for_tx_and_rx() { + let (tun_in_tx, tun_in_rx) = mpsc::channel::>(8); + let (tun_out_tx, mut tun_out_rx) = mpsc::channel::>(8); + let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::>(8); + let (conn_recv_tx, conn_recv_rx) = mpsc::channel::>(8); + + let tun = MockTun { + inbound: tun_in_rx, + written: tun_out_tx, + }; + let conn: Arc = Arc::new(MockConn { + sent: conn_sent_tx, + to_recv: tokio::sync::Mutex::new(conn_recv_rx), + }); + + // Default Vpn with a /16 -> Direct override so we can exercise both classifier branches. + let mut table = RouteTable::new(RouteAction::Vpn); + table.add_cidr("192.168.0.0/16".parse().unwrap(), RouteAction::Direct); + let routes = Arc::new(RwLock::new(table)); + let counters = PacketCounters::new(); + let router = AuraRouter::with_stats(tun, routes, conn, Some(counters.clone())); + let handle = tokio::spawn(router.run()); + + // (a) VPN packet -> reaches connection and bumps tx to 1. + let vpn_pkt = ipv4_packet_to(Ipv4Addr::new(8, 8, 8, 8)); + tun_in_tx.send(vpn_pkt.clone()).await.unwrap(); + let got = tokio::time::timeout(std::time::Duration::from_secs(2), conn_sent_rx.recv()) + .await + .expect("router did not forward to connection") + .expect("conn sent closed"); + assert_eq!(got, vpn_pkt); + + // (b) DIRECT packet -> goes to send_direct stub but tx still counts. + let direct_pkt = ipv4_packet_to(Ipv4Addr::new(192, 168, 1, 1)); + tun_in_tx.send(direct_pkt).await.unwrap(); + + // (c) Inbound packet -> written to TUN, bumps rx to 1. + let in_pkt = ipv4_packet_to(Ipv4Addr::new(10, 0, 0, 9)); + conn_recv_tx.send(in_pkt.clone()).await.unwrap(); + let written = tokio::time::timeout(std::time::Duration::from_secs(2), tun_out_rx.recv()) + .await + .expect("router did not write inbound packet to TUN") + .expect("TUN write closed"); + assert_eq!(written, in_pkt); + + // Wait until both tx events have been observed (the DIRECT path doesn't surface anywhere + // externally — poll the counter). + let mut waited_ms = 0u64; + while counters.tx_count() < 2 && waited_ms < 2000 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + waited_ms += 10; + } + assert_eq!( + counters.tx_count(), + 2, + "both VPN- and DIRECT-routed packets must bump tx" + ); + assert_eq!( + counters.rx_count(), + 1, + "one packet was written to the TUN, so rx must be 1" + ); + + drop(tun_in_tx); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; +} + +/// `AuraRouter::new` (no counters) must not panic and must not blow up on packets — verifies the +/// `None` branch of `with_stats` short-circuits safely. +#[tokio::test] +async fn test_router_no_counters_still_routes() { + let (tun_in_tx, tun_in_rx) = mpsc::channel::>(8); + let (tun_out_tx, _tun_out_rx) = mpsc::channel::>(8); + let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::>(8); + let (_conn_recv_tx, conn_recv_rx) = mpsc::channel::>(8); + + let tun = MockTun { + inbound: tun_in_rx, + written: tun_out_tx, + }; + let conn: Arc = Arc::new(MockConn { + sent: conn_sent_tx, + to_recv: tokio::sync::Mutex::new(conn_recv_rx), + }); + let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))); + + let router = AuraRouter::new(tun, routes, conn); + let handle = tokio::spawn(router.run()); + + let pkt = ipv4_packet_to(Ipv4Addr::new(1, 1, 1, 1)); + tun_in_tx.send(pkt.clone()).await.unwrap(); + let got = tokio::time::timeout(std::time::Duration::from_secs(2), conn_sent_rx.recv()) + .await + .expect("router did not forward without counters") + .expect("conn sent closed"); + assert_eq!(got, pkt); + + drop(tun_in_tx); + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; +}