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:
+164
-19
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user