feat(transport,cli,tunnel): v3.4 port auto-detect + bug fixes from live test

Live macOS test against the production server uncovered six bugs (one of which
turned out to be a port collision with sing-box, not a real bug); this commit
addresses all of them and adds v3.4 port discovery so the same collision is
handled transparently next time.

## v3.4 server port-discovery

- Defaults moved off 443/444 to 8443/8443/8444 (TransportSection::default,
  ServerInitOpts, ProvisionClientOpts, CLI flags). 443 is heavily contested in
  practice (sing-box, Hysteria2, reverse proxies) and the previous default
  silently lost the bind when a co-tenant was already there.
- MultiServer::bind_with_outer_or_scan: scans forward up to
  DEFAULT_PORT_SCAN_MAX (20) candidates per transport when the requested port
  is occupied; QUIC keeps walking if it lands on the custom-UDP port.
- MultiServer::bound_addrs(): the actual addresses each transport bound to.
- Server logs the bound addresses and writes a runtime snapshot
  (server.toml.runtime.json) when they differ from the requested ones, so
  `aura sign-bridges` can re-sign the bridges manifest later.
- BridgeManifest gains an optional `endpoints: Vec<BridgeEndpoint>` field
  with per-transport ports. Backward-compatible: old v3.3 clients ignore the
  field and continue to use the v1 `bridges` line.
- `aura sign-bridges --endpoints HOST:tcp=N:quic=N:udp=N` to mint v3.4
  manifests; bridges line is auto-synthesised for v3.3 clients.

## Bug fixes from the live test

- macOS TUN naming (#41): the tun crate rejects names that don't match
  ^utun[0-9]+$. On macOS we now substitute `""` (kernel auto-assigns utunN),
  capture the assigned name via inner.tun_name(), and propagate it through to
  os_routes::OsRouteGuard::install — so `route add -interface utunN` uses
  the real interface, not "aura0".
- Packet counters (#42): Stats { tx_packets, rx_packets } are now actually
  bumped by the data path. `aura status` shows live numbers instead of
  permanent zeros.
- render_client_toml schema (#44): provisioner emits proper
  `[[tunnel.split.vpn]] cidr = "..."` / `[[tunnel.split.direct]]` blocks from
  new --vpn-cidrs / --direct-cidrs flags. The v3.3 `vpn_cidrs = [...]` flat
  array was silently ignored by serde, leaving users with `rules: 0` even
  when their CIDRs looked right.
- #43 / #46 (TCP/443 dial early-eof / no payload back): diagnosed as the
  sing-box port collision, not an Aura bug. The v3.4 port-scan path makes it
  go away — the server picks a free port and clients learn it from the
  manifest.

## Test coverage

Three new unit tests for the port-scanner (UDP busy, TCP busy, zero budget);
two new tests for v3.4 BridgeManifest round-trip with endpoints; one
integration test for the new `[[tunnel.split.vpn]]` rendering; tests for the
runtime-state file write/read round-trip; agent-added router-counter tests
in aura-tunnel/tests/routes.rs.

cargo test --workspace, cargo clippy --workspace -- -D warnings, and
cargo fmt --check all pass.

#45 (silent client exit when underlying QUIC transport breaks) is still
outstanding — needs deeper investigation; deferred to a follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-29 17:14:45 +03:00
parent a173ced9b2
commit ba8d6b796f
20 changed files with 1267 additions and 110 deletions
+1 -1
View File
@@ -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};
+81 -1
View File
@@ -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<AtomicU64>`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<AtomicU64>,
/// Inbound (peer → TUN) packet count.
pub rx: Arc<AtomicU64>,
}
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<P: PacketIo> {
tun: P,
routes: Arc<RwLock<RouteTable>>,
conn: Arc<dyn PacketConnection>,
/// 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<PacketCounters>,
}
impl<P: PacketIo + 'static> AuraRouter<P> {
@@ -56,8 +107,28 @@ impl<P: PacketIo + 'static> AuraRouter<P> {
///
/// `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<RwLock<RouteTable>>, conn: Arc<dyn PacketConnection>) -> 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<RwLock<RouteTable>>,
conn: Arc<dyn PacketConnection>,
counters: Option<PacketCounters>,
) -> Self {
Self {
tun,
routes,
conn,
counters,
}
}
/// Run the router until the connection or TUN errors out.
@@ -101,6 +172,10 @@ impl<P: PacketIo + 'static> AuraRouter<P> {
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<P: PacketIo + 'static> AuraRouter<P> {
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(())
}
+164 -19
View File
@@ -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<wintun::Adapter>,
#[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"
);
}
}