Compare commits

..

2 Commits

Author SHA1 Message Date
xah30 7c2080321b feat(cli,tunnel): v3.4 client consumes manifest endpoints + fix #45 silent client exit
Two follow-ups to the previous v3.4 commit (ba8d6b7):

## #49 — client uses BridgeEndpoint ports as authoritative

BridgesDiscoveryWatcher now keeps a second snapshot
(`Arc<RwLock<Vec<BridgeEndpoint>>>`) for the per-transport endpoints carried by
v3.4 manifests, alongside the existing flat-bridges snapshot for v3.3
compatibility. `endpoints_snapshot()` and `primary_endpoint()` expose it to the
client.

In `client::run`, immediately after the watcher loads, the primary endpoint's
per-transport ports override the dial-time `dial_cfg.endpoints.{tcp,quic,udp}`
*ports*. The IP stays whatever the dialer already resolved (server_addr /
bridge list). This is what closes the loop on the user's friend's setup: the
server picks 8444 because sing-box has 443/8443, signs a manifest with
`endpoints = [{tcp: 8444, ...}]`, the client loads it on next refresh and
starts dialing the right port without an operator-side `client.toml` edit.

When the manifest has no `endpoints` field (old v3.3 format, or operator
chose not to publish per-transport ports), no override is applied and the
client.toml `[transport] *_port` values are used as before.

## #45 — silent client exit on broken connection

Root cause confirmed in `AuraRouter::run`:
- the inbound task did `let pkt = inbound_conn.recv_packet().await?;`, so any
  recv error returned silently via `?`
- the `to_tun_tx` channel sender dropped, `to_tun_rx.recv()` returned `None`
- the outbound `select!` arm matched `None => break Ok(())`
- the router returned `Ok(())`, the client's `run()` returned `Ok(())`, the
  process exited 0 with no log, no error message

We saw this empirically when the user disabled a co-resident VPN that had been
routing AuraVPN's UDP/444 traffic — the underlying QUIC socket broke, the
inbound task hit recv error, and the whole client vanished.

Fix:
- Inbound task now logs the error at `error` level with the underlying
  `recv_packet` cause before exiting.
- The outbound `select!`'s `None` arm now returns an Err (not Ok(())) so the
  caller knows the tunnel died and `aura client` exits non-zero — which is
  what a supervisor (systemd, launchd, or a future auto-redial loop) wants to
  see.
- The router waits up to 200ms for the inbound task to land cleanly before
  returning, so its error / panic is logged instead of being swallowed by
  `abort()`.

Existing tests still pass (12/12 in aura-tunnel router tests). Tested
manually: with the fix, killing the underlying transport now produces a
"peer connection broke (recv_packet failed): …" error line and a non-zero
exit, instead of silent process disappearance.

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:22:10 +03:00
xah30 ba8d6b796f 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>
2026-05-29 17:14:45 +03:00
20 changed files with 1357 additions and 114 deletions
+19 -3
View File
@@ -40,7 +40,7 @@ use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex}; use std::sync::{Arc, Mutex as StdMutex};
use aura_tunnel::{RouteAction, RouteTable}; use aura_tunnel::{PacketCounters, RouteAction, RouteTable};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 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"; pub const DEFAULT_SOCKET: &str = r"\\.\pipe\aura-admin";
/// Live tunnel statistics shared between the data path and the admin listener. /// Live tunnel statistics shared between the data path and the admin listener.
///
/// The two packet counters are `Arc<AtomicU64>` 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)] #[derive(Debug, Default)]
pub struct Stats { pub struct Stats {
/// Packets received from the peer (inbound, toward the TUN). /// Packets received from the peer (inbound, toward the TUN).
pub rx_packets: AtomicU64, pub rx_packets: Arc<AtomicU64>,
/// Packets sent to the peer (outbound, from the TUN). /// Packets sent to the peer (outbound, from the TUN).
pub tx_packets: AtomicU64, pub tx_packets: Arc<AtomicU64>,
/// Verified peer identity, set once a connection is established. /// Verified peer identity, set once a connection is established.
pub peer_id: StdMutex<Option<String>>, pub peer_id: StdMutex<Option<String>>,
} }
@@ -79,6 +84,17 @@ impl Stats {
*g = id; *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 /// A parallel record of admin-configured rules, so `route_list` can enumerate them (the library
+155 -1
View File
@@ -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 /// 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). /// 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BridgeManifest { pub struct BridgeManifest {
/// Wire-format version. Currently `1`. A manifest with an unknown version is rejected. /// 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 /// are expected to keep this list small (single digits or low tens of entries); the format does
/// not impose a hard limit. /// not impose a hard limit.
pub bridges: Vec<String>, pub bridges: Vec<String>,
/// 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<BridgeEndpoint>,
}
/// 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<u16>,
/// 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<u16>,
/// 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<u16>,
}
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<String>,
tcp: Option<u16>,
quic: Option<u16>,
udp: Option<u16>,
) -> Self {
Self {
host: host.into(),
tcp,
quic,
udp,
}
}
} }
impl BridgeManifest { impl BridgeManifest {
@@ -90,12 +144,13 @@ impl BridgeManifest {
generated_at, generated_at,
expires_at, expires_at,
bridges, bridges,
endpoints: Vec::new(),
} }
} }
/// Build a manifest from a slice of bridge strings with `expires_at = now + ttl`. The /// 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 /// `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] #[must_use]
pub fn with_ttl(bridges: Vec<String>, ttl: Duration) -> Self { pub fn with_ttl(bridges: Vec<String>, ttl: Duration) -> Self {
let now = unix_now(); let now = unix_now();
@@ -104,9 +159,42 @@ impl BridgeManifest {
generated_at: now, generated_at: now,
expires_at: now.saturating_add(ttl.as_secs()), expires_at: now.saturating_add(ttl.as_secs()),
bridges, 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<BridgeEndpoint>, 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 /// 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. /// disk in the signed-manifest format documented at the module level.
pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result<Vec<u8>> { pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result<Vec<u8>> {
@@ -245,6 +333,10 @@ impl BridgeManifest {
pub struct BridgesDiscoveryWatcher { pub struct BridgesDiscoveryWatcher {
/// The current effective merged list (static + manifest, de-duplicated by `SocketAddr`). /// The current effective merged list (static + manifest, de-duplicated by `SocketAddr`).
snapshot: Arc<RwLock<Vec<SocketAddr>>>, snapshot: Arc<RwLock<Vec<SocketAddr>>>,
/// v3.4: the per-transport endpoints carried by the most-recently-loaded manifest. Empty
/// when the manifest has no `endpoints` field (v3.3-format manifest, or v3.4 manifest where
/// the operator opted not to publish per-transport ports).
endpoints_snapshot: Arc<RwLock<Vec<BridgeEndpoint>>>,
/// The static list from `[client] bridges` (used as a fallback when the manifest is missing). /// The static list from `[client] bridges` (used as a fallback when the manifest is missing).
static_bridges: Vec<SocketAddr>, static_bridges: Vec<SocketAddr>,
/// File path of the signed manifest. /// File path of the signed manifest.
@@ -267,8 +359,10 @@ impl BridgesDiscoveryWatcher {
static_bridges: Vec<SocketAddr>, static_bridges: Vec<SocketAddr>,
) -> Self { ) -> Self {
let snapshot = Arc::new(RwLock::new(static_bridges.clone())); let snapshot = Arc::new(RwLock::new(static_bridges.clone()));
let endpoints_snapshot = Arc::new(RwLock::new(Vec::new()));
let watcher = Self { let watcher = Self {
snapshot, snapshot,
endpoints_snapshot,
static_bridges, static_bridges,
manifest_path, manifest_path,
ca_cert_pem, ca_cert_pem,
@@ -278,6 +372,20 @@ impl BridgesDiscoveryWatcher {
watcher watcher
} }
/// v3.4: clone of the per-transport endpoint snapshot. Empty when the manifest has no
/// `endpoints` field. The dialer's [`Endpoints`](aura_transport::Endpoints) port overrides
/// should be derived from this — see [`Self::primary_endpoint`].
pub async fn endpoints_snapshot(&self) -> Vec<BridgeEndpoint> {
self.endpoints_snapshot.read().await.clone()
}
/// v3.4: first endpoint from the snapshot, when present. Useful for the common case of a
/// single-server deployment where the watcher mainly mirrors the primary server's chosen
/// ports.
pub async fn primary_endpoint(&self) -> Option<BridgeEndpoint> {
self.endpoints_snapshot.read().await.first().cloned()
}
/// Snapshot handle: clones of this `Arc<RwLock<...>>` can be read concurrently by the dial loop. /// Snapshot handle: clones of this `Arc<RwLock<...>>` can be read concurrently by the dial loop.
pub fn handle(&self) -> Arc<RwLock<Vec<SocketAddr>>> { pub fn handle(&self) -> Arc<RwLock<Vec<SocketAddr>>> {
Arc::clone(&self.snapshot) Arc::clone(&self.snapshot)
@@ -298,11 +406,17 @@ impl BridgesDiscoveryWatcher {
let merged = merged_snapshot(&self.static_bridges, &manifest.parsed_bridges()); let merged = merged_snapshot(&self.static_bridges, &manifest.parsed_bridges());
let merged_len = merged.len(); let merged_len = merged.len();
*self.snapshot.write().await = merged; *self.snapshot.write().await = merged;
// v3.4: copy the per-transport endpoints over too. They drive dial-time port
// overrides on the client (see [`crate::client::run`]). Old v3.3 manifests have
// an empty `endpoints` field and the snapshot just clears.
let endpoints_len = manifest.endpoints.len();
*self.endpoints_snapshot.write().await = manifest.endpoints.clone();
tracing::info!( tracing::info!(
path = %self.manifest_path.display(), path = %self.manifest_path.display(),
generated_at = manifest.generated_at, generated_at = manifest.generated_at,
expires_at = manifest.expires_at, expires_at = manifest.expires_at,
manifest_bridges = manifest.bridges.len(), manifest_bridges = manifest.bridges.len(),
manifest_endpoints = endpoints_len,
merged_total = merged_len, merged_total = merged_len,
"loaded signed bridges manifest" "loaded signed bridges manifest"
); );
@@ -514,6 +628,7 @@ mod tests {
generated_at: now, generated_at: now,
expires_at: now + 3600, expires_at: now + 3600,
bridges: vec!["203.0.113.10:443".to_string()], 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) // 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. // by serialising the body manually with version=99.
@@ -638,4 +753,43 @@ mod tests {
let snap = watcher.current().await; let snap = watcher.current().await;
assert_eq!(snap.len(), 2, "snapshot kept across missing-file refresh"); 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()]);
}
} }
+53 -8
View File
@@ -131,6 +131,36 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
// returned JoinHandle. Dropping the watcher returned by `new` would also be fine — // returned JoinHandle. Dropping the watcher returned by `new` would also be fine —
// the handle keeps a clone of the Arc and outlives the local binding. // the handle keeps a clone of the Arc and outlives the local binding.
let _bg = watcher.spawn_refresh(); let _bg = watcher.spawn_refresh();
// v3.4: when the manifest carries per-transport endpoints, override the dial-time
// *_port for each transport with the operator's published value. This is what lets a
// server that had to port-scan past a busy 8443 (sing-box / Hysteria2 on the same host)
// tell its clients to use 8444 instead — the client.toml's static [transport] ports
// become only the bootstrap fallback. We deliberately override only the *port*: the IP
// stays whatever the dialer already resolved (server_addr / bridge list), because the
// bridges manifest is authoritative for ports but not for which host the client is
// currently talking to.
if let Some(ep) = watcher.primary_endpoint().await {
let mut applied = Vec::new();
if let (Some(port), Some(addr)) = (ep.tcp, dial_cfg.endpoints.tcp) {
dial_cfg.endpoints.tcp = Some(std::net::SocketAddr::new(addr.ip(), port));
applied.push(format!("tcp={}", port));
}
if let (Some(port), Some(addr)) = (ep.quic, dial_cfg.endpoints.quic) {
dial_cfg.endpoints.quic = Some(std::net::SocketAddr::new(addr.ip(), port));
applied.push(format!("quic={}", port));
}
if let (Some(port), Some(addr)) = (ep.udp, dial_cfg.endpoints.udp) {
dial_cfg.endpoints.udp = Some(std::net::SocketAddr::new(addr.ip(), port));
applied.push(format!("udp={}", port));
}
if !applied.is_empty() {
tracing::info!(
endpoint_host = %ep.host,
overrides = %applied.join(","),
"v3.4 manifest endpoints override dial-time transport ports"
);
}
}
tracing::info!( tracing::info!(
path = %manifest_path.display(), path = %manifest_path.display(),
refresh_interval_secs = refresh_secs, refresh_interval_secs = refresh_secs,
@@ -294,7 +324,20 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
) )
.await .await
.context("creating TUN device (needs root)")?; .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 // 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 // TUN. The guard is bound to this `run()` scope; its Drop rolls every installed route back
@@ -303,10 +346,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 // change (eliminating the user-space `send_direct` stub). To restore the v1 behaviour
// explicitly, set `enabled = false`. // explicitly, set `enabled = false`.
// //
// We pass `cfg.tunnel.tun_name` rather than the kernel-assigned name because `AuraTun` does // We pass `actual_tun_name` (the kernel-assigned name from `AuraTun::name()`), not
// not (yet) surface the latter; on macOS the operator can pin the resulting `utunN` in the // `cfg.tunnel.tun_name`. On macOS those differ whenever the config does not pre-pin a valid
// config (or set `[tunnel.os_routes] dry_run = true` to validate the plan). Linux assigns the // `utunN`, so passing the config string would make every `route add -interface ...` silently
// requested name verbatim. // miss the real interface.
let os_routes_cfg = cfg let os_routes_cfg = cfg
.tunnel .tunnel
.os_routes .os_routes
@@ -315,7 +358,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
let _os_routes_guard: Option<OsRouteGuard> = if os_routes_cfg.enabled { let _os_routes_guard: Option<OsRouteGuard> = if os_routes_cfg.enabled {
let split = SplitRoutes::from_config(&cfg.tunnel.split, &resolved_domains); let split = SplitRoutes::from_config(&cfg.tunnel.split, &resolved_domains);
let guard = OsRouteGuard::install( let guard = OsRouteGuard::install(
&cfg.tunnel.tun_name, &actual_tun_name,
&split, &split,
os_routes_cfg.gateway.as_deref(), os_routes_cfg.gateway.as_deref(),
os_routes_cfg.egress_iface.as_deref(), os_routes_cfg.egress_iface.as_deref(),
@@ -323,7 +366,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
) )
.context("installing OS-level split-tunnel routes")?; .context("installing OS-level split-tunnel routes")?;
tracing::info!( tracing::info!(
tun = %cfg.tunnel.tun_name, tun = %actual_tun_name,
dry_run = os_routes_cfg.dry_run, dry_run = os_routes_cfg.dry_run,
"OS-level split-tunnel routes installed (DIRECT traffic now bypasses the TUN)" "OS-level split-tunnel routes installed (DIRECT traffic now bypasses the TUN)"
); );
@@ -346,7 +389,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")?; 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"); let run_result = router.run().await.context("router run loop");
// _os_routes_guard drops here, rolling back any installed system routes. // _os_routes_guard drops here, rolling back any installed system routes.
run_result run_result
+21 -12
View File
@@ -695,9 +695,14 @@ impl Default for TransportSection {
fn default() -> Self { fn default() -> Self {
Self { Self {
order: default_transport_order(), order: default_transport_order(),
udp_port: 443, // v3.4: defaults moved off 443/444 because in practice 443 is heavily contested
tcp_port: 443, // (sing-box, Hysteria2, Cloudflare tunnels, ...). Picking 8443/8444 gives us a free
quic_port: 444, // 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, obfuscate: true,
masquerade: true, masquerade: true,
masks: MasksSection::default(), masks: MasksSection::default(),
@@ -1547,16 +1552,17 @@ pool_cidr = "10.7.0.0/24"
assert_eq!(cfg.tunnel.mtu, 1420); assert_eq!(cfg.tunnel.mtu, 1420);
assert!(!cfg.mimicry.padding); 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.order, vec!["udp", "tcp", "quic"]);
assert_eq!(cfg.transport.udp_port, 443); assert_eq!(cfg.transport.udp_port, 8443);
assert_eq!(cfg.transport.tcp_port, 443); assert_eq!(cfg.transport.tcp_port, 8443);
assert_eq!(cfg.transport.quic_port, 444); assert_eq!(cfg.transport.quic_port, 8444);
assert!(cfg.transport.obfuscate); assert!(cfg.transport.obfuscate);
assert!(cfg.transport.masquerade); assert!(cfg.transport.masquerade);
let eps = cfg.transport_endpoints().expect("default endpoints"); let eps = cfg.transport_endpoints().expect("default endpoints");
assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:443"); assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:8443");
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:444"); assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:8444");
} }
#[test] #[test]
@@ -1709,9 +1715,12 @@ local_ip = "10.7.0.2"
dial.order, dial.order,
vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic] vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic]
); );
assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:443"); // v3.4: when [transport] is omitted the defaults are 8443/8443/8444 (was 443/443/444 in
assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:443"); // v3.3); the `server_addr` port is informational here — actual transport ports come from
assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:444"); // [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 /// `[server.pool]` is parsed in full (cidr + strategy + static reservations) and
+33 -9
View File
@@ -40,11 +40,13 @@ pub struct ServerInitOpts {
pub pki_dir: PathBuf, pub pki_dir: PathBuf,
/// Listen IP for `[server] listen` and `[transport]` bindings. Default `0.0.0.0`. /// Listen IP for `[server] listen` and `[transport]` bindings. Default `0.0.0.0`.
pub listen_ip: String, 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, pub udp_port: u16,
/// TCP fallback port. Default 443. /// TCP fallback port. Default `8443`. May equal `udp_port` (different protocol).
pub tcp_port: u16, 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, pub quic_port: u16,
/// VPN address pool. Default `10.7.0.0/24`. /// VPN address pool. Default `10.7.0.0/24`.
pub pool_cidr: String, pub pool_cidr: String,
@@ -74,9 +76,9 @@ impl ServerInitOpts {
domain: domain.into(), domain: domain.into(),
pki_dir: pki_dir.into(), pki_dir: pki_dir.into(),
listen_ip: "0.0.0.0".to_string(), listen_ip: "0.0.0.0".to_string(),
udp_port: 443, udp_port: 8443,
tcp_port: 443, tcp_port: 8443,
quic_port: 444, quic_port: 8444,
pool_cidr: "10.7.0.0/24".to_string(), pool_cidr: "10.7.0.0/24".to_string(),
egress_iface: None, egress_iface: None,
out_config: PathBuf::from("/etc/aura/server.toml"), out_config: PathBuf::from("/etc/aura/server.toml"),
@@ -290,6 +292,12 @@ pub struct ProvisionClientOpts {
pub enable_cover_traffic: bool, pub enable_cover_traffic: bool,
/// Optional bridge addresses (`bridges = [...]`). /// Optional bridge addresses (`bridges = [...]`).
pub bridges: Vec<String>, pub bridges: Vec<String>,
/// 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<String>,
/// v3.4: CIDRs whose traffic should **bypass** the VPN (rendered as `[[tunnel.split.direct]]`
/// blocks). Empty = no per-CIDR bypass.
pub direct_cidrs: Vec<String>,
/// v3.2: when set to `Some(N)` with `N >= 2`, generate **N independent client certificates** /// 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`, /// (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 /// ..., `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(), ca_dir: ca_dir.into(),
server_addr: server_addr.into(), server_addr: server_addr.into(),
server_name: server_name.into(), server_name: server_name.into(),
udp_port: 443, udp_port: 8443,
tcp_port: 443, tcp_port: 8443,
quic_port: 444, quic_port: 8444,
tun_ip: tun_ip.into(), tun_ip: tun_ip.into(),
tun_prefix: 24, tun_prefix: 24,
out_dir: out_dir.into(), out_dir: out_dir.into(),
enable_knock: false, enable_knock: false,
enable_cover_traffic: false, enable_cover_traffic: false,
bridges: Vec::new(), bridges: Vec::new(),
vpn_cidrs: Vec::new(),
direct_cidrs: Vec::new(),
circuit_hops: None, circuit_hops: None,
force: false, force: false,
} }
@@ -479,8 +489,22 @@ pub fn render_client_toml(
s.push_str(&format!("prefix = {}\n", opts.tun_prefix)); s.push_str(&format!("prefix = {}\n", opts.tun_prefix));
s.push_str("mtu = 1420\n\n"); 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("[tunnel.split]\n");
s.push_str("default = \"VPN\"\n\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("[mimicry]\n");
s.push_str("padding = true\n\n"); s.push_str("padding = true\n\n");
+1
View File
@@ -30,5 +30,6 @@ pub mod pki;
pub mod pool; pub mod pool;
pub mod privdrop; pub mod privdrop;
pub mod relay; pub mod relay;
pub mod runtime_state;
pub mod server; pub mod server;
pub mod server_router; pub mod server_router;
+134 -38
View File
@@ -165,14 +165,14 @@ struct ServerInitArgs {
/// Listen IP for the server (default 0.0.0.0). /// Listen IP for the server (default 0.0.0.0).
#[arg(long, default_value = "0.0.0.0")] #[arg(long, default_value = "0.0.0.0")]
listen_ip: String, listen_ip: String,
/// UDP transport port (default 443). /// UDP transport port (default 8443; v3.4 moved off 443 to dodge sing-box/Hysteria2 conflicts).
#[arg(long, default_value_t = 443)] #[arg(long, default_value_t = 8443)]
udp_port: u16, udp_port: u16,
/// TCP fallback port (default 443). /// TCP fallback port (default 8443).
#[arg(long, default_value_t = 443)] #[arg(long, default_value_t = 8443)]
tcp_port: u16, tcp_port: u16,
/// QUIC fallback port (default 444). Must differ from --udp-port. /// QUIC fallback port (default 8444). Must differ from --udp-port.
#[arg(long, default_value_t = 444)] #[arg(long, default_value_t = 8444)]
quic_port: u16, quic_port: u16,
/// VPN address pool (default 10.7.0.0/24). /// VPN address pool (default 10.7.0.0/24).
#[arg(long, default_value = "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). /// Server SAN / SNI (placed in [client] sni).
#[arg(long)] #[arg(long)]
server_name: String, server_name: String,
/// UDP transport port (default 443). /// UDP transport port (default 8443).
#[arg(long, default_value_t = 443)] #[arg(long, default_value_t = 8443)]
udp_port: u16, udp_port: u16,
/// TCP fallback port (default 443). /// TCP fallback port (default 8443).
#[arg(long, default_value_t = 443)] #[arg(long, default_value_t = 8443)]
tcp_port: u16, tcp_port: u16,
/// QUIC fallback port (default 444). /// QUIC fallback port (default 8444).
#[arg(long, default_value_t = 444)] #[arg(long, default_value_t = 8444)]
quic_port: u16, quic_port: u16,
/// TUN local IP (placed in [tunnel] local_ip). Must fall inside the server's pool. /// TUN local IP (placed in [tunnel] local_ip). Must fall inside the server's pool.
#[arg(long)] #[arg(long)]
@@ -242,6 +242,14 @@ struct ProvisionClientArgs {
/// Comma-separated list of fallback server addresses (IP or IP:port). /// Comma-separated list of fallback server addresses (IP or IP:port).
#[arg(long)] #[arg(long)]
bridges: Option<String>, bridges: Option<String>,
/// 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<String>,
/// 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<String>,
/// v3.2: generate N independent client certificates (one UUID-v4 CN each) for an N-hop /// 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 /// 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 /// 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`). /// Directory holding the CA (`ca.crt` + `ca.key`).
#[arg(long)] #[arg(long)]
ca: PathBuf, 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)] #[arg(long)]
bridges: String, bridges: Option<String>,
/// 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<String>,
/// Manifest validity in days. The signed manifest carries `expires_at = now + ttl_days*86400` /// Manifest validity in days. The signed manifest carries `expires_at = now + ttl_days*86400`
/// — clients reject manifests past their expiry. /// — clients reject manifests past their expiry.
#[arg(long, default_value_t = 7)] #[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 /// 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<()> { fn run_sign_bridges(args: SignBridgesArgs) -> anyhow::Result<()> {
use std::time::Duration; 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) 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()))?; .map_err(|e| anyhow::anyhow!("reading CA key {}: {e}", ca_key_path.display()))?;
let bridges: Vec<String> = 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 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<String> = 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)?; manifest.save_signed(&args.out, &ca_key_pem)?;
println!("Signed bridges manifest written:"); println!("Signed bridges manifest written:");
println!(" out: {}", args.out.display()); 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!(" generated_at: {}", manifest.generated_at);
println!(" expires_at: {}", manifest.expires_at); println!(" expires_at: {}", manifest.expires_at);
Ok(()) 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<Vec<aura_cli::bridges::BridgeEndpoint>> {
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 /// 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 /// 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 /// 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`. /// Dispatch `aura provision-client`.
fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> { fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> {
let bridges = args fn split_csv(s: Option<String>) -> Vec<String> {
.bridges s.map(|s| {
.map(|s| {
s.split(',') s.split(',')
.map(|t| t.trim().to_string()) .map(|t| t.trim().to_string())
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.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 { let opts = init::ProvisionClientOpts {
id: args.id, id: args.id,
ca_dir: args.ca, ca_dir: args.ca,
@@ -604,6 +698,8 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> {
enable_knock: args.enable_knock, enable_knock: args.enable_knock,
enable_cover_traffic: args.enable_cover_traffic, enable_cover_traffic: args.enable_cover_traffic,
bridges, bridges,
vpn_cidrs,
direct_cidrs,
circuit_hops: args.circuit_hops, circuit_hops: args.circuit_hops,
force: args.force, force: args.force,
}; };
+6 -2
View File
@@ -168,8 +168,12 @@ pub struct OsRouteGuard {
impl OsRouteGuard { impl OsRouteGuard {
/// Program the OS routing table from `routes` and return the RAII guard. /// 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, /// * `tun_name`: the **kernel-assigned** name of the freshly created TUN device — read it
/// `"utun4"` on macOS — see [`aura_tunnel::AuraTun::name`]). /// 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. /// * `routes`: the resolved split-tunnel plan.
/// * `explicit_gw`: optional override for the host's default gateway (IPv4 in v2). When /// * `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 /// `None`, the gateway is auto-detected per platform; if auto-detection fails an error is
+193
View File
@@ -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 `<server.toml>.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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tcp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quic: Option<String>,
}
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<Option<RuntimeEndpoints>> {
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<Endpoints> {
fn parse_one(s: &Option<String>, label: &str) -> anyhow::Result<Option<SocketAddr>> {
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());
}
}
+54 -3
View File
@@ -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 // 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` // 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`. // (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, endpoints,
proto_cfg.clone(), proto_cfg.clone(),
udp_opts, udp_opts,
tcp_opts.clone(), tcp_opts.clone(),
outer_pems.as_ref().map(|(c, _)| c.as_str()), outer_pems.as_ref().map(|(c, _)| c.as_str()),
outer_pems.as_ref().map(|(_, k)| k.as_str()), outer_pems.as_ref().map(|(_, k)| k.as_str()),
aura_transport::DEFAULT_PORT_SCAN_MAX,
) )
.await .await
.context("binding Aura multi-transport server")?; .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 (`<server.toml>.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 <server.toml>` 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 // 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. // 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 // 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. // 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 mtu = cfg.tunnel.mtu;
let tun = AuraTun::create("aura-srv0", server_tun_ip, prefix, mtu) let tun = AuraTun::create("aura-srv0", server_tun_ip, prefix, mtu)
.await .await
.context("failed to create server TUN (needs root)")?; .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) // 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 // 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")?; 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 server_routes = router.routes();
let inbound_tx = router.inbound_sender(); let inbound_tx = router.inbound_sender();
let router_task = tokio::spawn(async move { let router_task = tokio::spawn(async move {
+34 -2
View File
@@ -27,7 +27,7 @@ use std::sync::Arc;
use aura_proto::PacketConnection; use aura_proto::PacketConnection;
use aura_tunnel::router::dst_ip; use aura_tunnel::router::dst_ip;
use aura_tunnel::PacketIo; use aura_tunnel::{PacketCounters, PacketIo};
use tokio::sync::{mpsc, RwLock}; use tokio::sync::{mpsc, RwLock};
use crate::pool::IpPool; use crate::pool::IpPool;
@@ -119,22 +119,44 @@ pub struct ServerRouter<P: PacketIo> {
/// drains the receiver. /// drains the receiver.
inbound_tx: mpsc::Sender<Vec<u8>>, inbound_tx: mpsc::Sender<Vec<u8>>,
inbound_rx: mpsc::Receiver<Vec<u8>>, inbound_rx: mpsc::Receiver<Vec<u8>>,
/// 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<PacketCounters>,
} }
impl<P: PacketIo + 'static> ServerRouter<P> { impl<P: PacketIo + 'static> ServerRouter<P> {
/// Build a fresh router with empty routes and the given pool. /// 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<IpPool>) -> Self { pub fn new(tun: P, pool: Arc<IpPool>) -> Self {
Self::from_routes(tun, ServerRoutes::new(pool)) 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<IpPool>, counters: Option<PacketCounters>) -> 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). /// Build a router from an existing [`ServerRoutes`] (mainly for tests that pre-seed routes).
pub fn from_routes(tun: P, routes: ServerRoutes) -> Self { 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<PacketCounters>,
) -> Self {
let (inbound_tx, inbound_rx) = mpsc::channel::<Vec<u8>>(INBOUND_CAPACITY); let (inbound_tx, inbound_rx) = mpsc::channel::<Vec<u8>>(INBOUND_CAPACITY);
Self { Self {
tun, tun,
routes, routes,
inbound_tx, inbound_tx,
inbound_rx, inbound_rx,
counters,
} }
} }
@@ -215,6 +237,10 @@ impl<P: PacketIo + 'static> ServerRouter<P> {
if let Err(e) = self.tun.write_packet(&pkt).await { if let Err(e) = self.tun.write_packet(&pkt).await {
return Err(anyhow::Error::new(e).context("server TUN write failed")); 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 => { None => {
// All inbound senders dropped (the accept-loop and all per-conn // All inbound senders dropped (the accept-loop and all per-conn
@@ -234,7 +260,13 @@ impl<P: PacketIo + 'static> ServerRouter<P> {
return Ok(()); return Ok(());
}; };
match self.routes.dispatch(dst, pkt).await? { 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 => { false => {
tracing::trace!(%dst, len = pkt.len(), "no client registered for destination; dropping"); tracing::trace!(%dst, len = pkt.len(), "no client registered for destination; dropping");
Ok(()) Ok(())
+6 -5
View File
@@ -41,14 +41,15 @@ fn build_dial_targets_from_parsed_client_config() {
let targets = build_dial_targets(&dial.endpoints, &cfg.client.bridges); let targets = build_dial_targets(&dial.endpoints, &cfg.client.bridges);
assert_eq!(targets.len(), 3, "primary + two bridges"); assert_eq!(targets.len(), 3, "primary + two bridges");
// The primary is always first. // 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: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 // 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..] { for t in &targets[1..] {
assert_eq!(t.udp.unwrap().port(), 443); assert_eq!(t.udp.unwrap().port(), 8443);
assert_eq!(t.quic.unwrap().port(), 444); assert_eq!(t.quic.unwrap().port(), 8444);
} }
// Both bridge IPs are represented. // Both bridge IPs are represented.
+53 -1
View File
@@ -86,7 +86,8 @@ fn provision_client_with_explicit_id() {
// The client.toml round-trips through the parser cleanly. // The client.toml round-trips through the parser cleanly.
let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml"); 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.client.sni, "vpn.example.com");
assert_eq!(cfg.tunnel.local_ip, "10.7.0.2"); assert_eq!(cfg.tunnel.local_ip, "10.7.0.2");
assert!(cfg.client.bridges.is_empty(), "no bridges by default"); 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); 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`. /// A non-empty bundle directory triggers an error without `--force`.
#[test] #[test]
fn provision_client_refuses_non_empty_bundle() { fn provision_client_refuses_non_empty_bundle() {
+5 -3
View File
@@ -51,10 +51,12 @@ fn server_init_writes_and_parses() {
assert!(report.server_config.exists(), "server.toml exists"); assert!(report.server_config.exists(), "server.toml exists");
let cfg = ServerConfigFile::load(&report.server_config).expect("server.toml parses"); 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.tunnel.pool_cidr, "10.7.0.0/24");
assert_eq!(cfg.transport.udp_port, 443); assert_eq!(cfg.transport.udp_port, 8443);
assert_eq!(cfg.transport.quic_port, 444); assert_eq!(cfg.transport.quic_port, 8444);
// no-nat was set in the baseline. // no-nat was set in the baseline.
assert!(cfg.server.nat.is_none(), "no [server.nat] section"); assert!(cfg.server.nat.is_none(), "no [server.nat] section");
// knock / cover default to disabled. // knock / cover default to disabled.
+199
View File
@@ -193,8 +193,16 @@ pub struct MultiServer {
/// Live TCP server handle (shared with the accept loop), used by the mask rotator to update /// 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. /// the accept-time options. `None` when the TCP transport was not enabled.
tcp: Option<Arc<TcpServer>>, tcp: Option<Arc<TcpServer>>,
/// 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 { impl MultiServer {
/// Bind and start accept loops for every transport whose address is set in `endpoints`. /// 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`. /// 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::<Accepted>(32); let (txc, rx) = mpsc::channel::<Accepted>(32);
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let mut bound = Endpoints::default();
let udp_handle = if let Some(addr) = endpoints.udp { 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. // 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)?); let server = Arc::new(UdpServer::bind(addr, proto_cfg.clone(), udp)?);
bound.udp = server.local_addr().ok();
tasks.push(tokio::spawn(udp_accept_loop( tasks.push(tokio::spawn(udp_accept_loop(
Arc::clone(&server), Arc::clone(&server),
txc.clone(), txc.clone(),
@@ -271,6 +281,7 @@ impl MultiServer {
} }
None => TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?, None => TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?,
}); });
bound.tcp = server.local_addr().ok();
tasks.push(tokio::spawn(tcp_accept_loop( tasks.push(tokio::spawn(tcp_accept_loop(
Arc::clone(&server), Arc::clone(&server),
txc.clone(), txc.clone(),
@@ -289,6 +300,7 @@ impl MultiServer {
), ),
}; };
let server = AuraServer::bind(addr, oc, ok, proto_cfg.clone())?; 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()))); tasks.push(tokio::spawn(quic_accept_loop(server, txc.clone())));
} }
@@ -300,9 +312,119 @@ impl MultiServer {
tasks, tasks,
udp: udp_handle, udp: udp_handle,
tcp: tcp_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<Self> {
// 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 /// 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. /// 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) { 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<SocketAddr> {
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<SocketAddr> {
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 { impl Drop for MultiServer {
fn drop(&mut self) { fn drop(&mut self) {
for t in &self.tasks { for t in &self.tasks {
@@ -399,3 +557,44 @@ async fn quic_accept_loop(server: AuraServer, tx: mpsc::Sender<Accepted>) {
} }
} }
} }
#[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);
}
}
+3 -1
View File
@@ -72,7 +72,9 @@ pub mod tcp;
pub mod udp; pub mod udp;
pub use conn::AuraConnection; 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 mimicry::{alpn_protocols, chrome_quic_transport_config, ALPN_H3, DEFAULT_SNI};
pub use padding::{ pub use padding::{
inject_padding_frames, next_bucket_for_profile, pad_to_bucket, pad_to_https_size, inject_padding_frames, next_bucket_for_profile, pad_to_bucket, pad_to_https_size,
+1 -1
View File
@@ -50,7 +50,7 @@ pub mod routes;
pub mod tun; pub mod tun;
pub use dns::AuraDns; pub use dns::AuraDns;
pub use router::{dst_ip, AuraRouter}; pub use router::{dst_ip, AuraRouter, PacketCounters};
pub use routes::{RouteAction, RouteTable}; pub use routes::{RouteAction, RouteTable};
pub use tun::{AuraTun, PacketIo}; pub use tun::{AuraTun, PacketIo};
+115 -5
View File
@@ -20,6 +20,7 @@
//! the device in one place while still running both directions concurrently. //! the device in one place while still running both directions concurrently.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use aura_proto::PacketConnection; use aura_proto::PacketConnection;
@@ -28,6 +29,52 @@ use tokio::sync::{mpsc, RwLock};
use crate::routes::{RouteAction, RouteTable}; use crate::routes::{RouteAction, RouteTable};
use crate::tun::PacketIo; 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. /// 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 /// 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, tun: P,
routes: Arc<RwLock<RouteTable>>, routes: Arc<RwLock<RouteTable>>,
conn: Arc<dyn PacketConnection>, 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> { 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 /// `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. /// 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 { 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. /// Run the router until the connection or TUN errors out.
@@ -73,7 +144,22 @@ impl<P: PacketIo + 'static> AuraRouter<P> {
let inbound_conn = Arc::clone(&self.conn); let inbound_conn = Arc::clone(&self.conn);
let inbound = tokio::spawn(async move { let inbound = tokio::spawn(async move {
loop { loop {
let pkt = inbound_conn.recv_packet().await?; let pkt = match inbound_conn.recv_packet().await {
Ok(p) => p,
Err(e) => {
// v3.4 fix for #45 (silent client exit): the inbound task used to swallow
// this error and ride out via `?`, so when the underlying transport broke
// (e.g. a co-resident VPN's UDP socket got remapped) the outbound select!
// saw a clean `None` and returned `Ok(())`. No log, no exit message, no
// reconnect hint. Now we log loudly with the real cause before propagating.
let err_str = e.to_string();
tracing::error!(
error = %err_str,
"peer connection broke (recv_packet failed); client is exiting"
);
return Err(anyhow::anyhow!("recv_packet from peer failed: {err_str}"));
}
};
if to_tun_tx.send(pkt).await.is_err() { if to_tun_tx.send(pkt).await.is_err() {
// TUN owner loop has stopped; nothing more to do. // TUN owner loop has stopped; nothing more to do.
break; break;
@@ -101,15 +187,34 @@ impl<P: PacketIo + 'static> AuraRouter<P> {
if let Err(e) = self.tun.write_packet(&pkt).await { if let Err(e) = self.tun.write_packet(&pkt).await {
break Err(anyhow::Error::new(e).context("TUN write failed")); 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). // Inbound task ended. Either gracefully (we drove `to_tun_tx` drop via the
None => break Ok(()), // outbound side exiting first — unreachable here since we'd still be inside
// the select), or because the peer connection broke. v3.4: surface as an
// error so `aura client` exits non-zero and a supervisor (systemd, launchd,
// a future auto-redial loop) knows the tunnel died. The inbound task itself
// already logged the underlying cause at error level.
None => break Err(anyhow::anyhow!(
"peer connection closed; router shutting down (see preceding error log for cause)"
)),
} }
} }
} }
}; };
inbound.abort(); // Wait for the inbound task to land so we can surface its error rather than just abort()
// it (which would silently drop the underlying cause). Bounded by a short timeout so a
// stuck inbound future cannot wedge shutdown.
match tokio::time::timeout(std::time::Duration::from_millis(200), inbound).await {
Ok(Ok(Ok(()))) => {}
Ok(Ok(Err(e))) => tracing::warn!(error = %e, "inbound task exited with error"),
Ok(Err(join_err)) => tracing::warn!(error = %join_err, "inbound task panicked"),
Err(_) => tracing::warn!("inbound task did not exit within 200ms; abandoning"),
}
result result
} }
@@ -130,6 +235,11 @@ impl<P: PacketIo + 'static> AuraRouter<P> {
self.send_direct(dst, pkt).await?; 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(()) Ok(())
} }
+164 -19
View File
@@ -3,12 +3,16 @@
//! [`AuraTun`] is a thin async wrapper over a layer-3 TUN interface: //! [`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)` //! * **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 //! yields an `AsyncDevice` whose `recv`/`send` move whole IP packets. On macOS the kernel
//! is system-assigned (`utunN`) and the requested name may be ignored — we do not treat a name //! `utun` driver requires interface names to match `^utun[0-9]+$`; any other requested name is
//! mismatch as an error. //! rewritten to an empty string before creation, which makes the kernel auto-assign the next
//! * **Windows** via the [`wintun`] crate (0.5): `Adapter::create(..)` + `start_session(..)`. This //! free `utunN`. The actual assigned name is captured via [`tun::AbstractDevice::tun_name`] and
//! path is `cfg(windows)`-gated and is *not compiled* on the macOS development host; it is //! exposed via [`AuraTun::name`] so callers (e.g. the OS-routes installer) can program the real
//! validated by inspection only. //! 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 //! 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 //! 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>, _adapter: std::sync::Arc<wintun::Adapter>,
#[cfg(windows)] #[cfg(windows)]
mtu: u16, 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 { impl AuraTun {
/// Create and bring up a TUN interface named `name` with address `ip`/`prefix_len` and the /// Create and bring up a TUN interface named `name` with address `ip`/`prefix_len` and the
/// given `mtu`. /// given `mtu`.
/// ///
/// On macOS `name` is advisory (the kernel assigns `utunN`); a different resulting name is not /// On macOS `name` is advisory: the kernel `utun` driver only accepts names matching
/// an error. Requires privileges, so this is never called from unit tests. /// `^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))] #[cfg(not(windows))]
pub async fn create( pub async fn create(
name: &str, name: &str,
@@ -74,9 +94,18 @@ impl AuraTun {
.with_context(|| format!("invalid TUN address {ip}/{prefix_len}"))? .with_context(|| format!("invalid TUN address {ip}/{prefix_len}"))?
.mask(); .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(); let mut config = tun::Configuration::default();
config config
.tun_name(name) .tun_name(requested_name)
.address(ip) .address(ip)
.netmask(netmask) .netmask(netmask)
.mtu(mtu) .mtu(mtu)
@@ -86,18 +115,44 @@ impl AuraTun {
let inner = tun::create_as_async(&config) let inner = tun::create_as_async(&config)
.with_context(|| format!("failed to create TUN device '{name}'"))?; .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. // Capture the kernel-assigned name. On macOS this is the auto-picked `utunN`; on Linux
if let Ok(actual) = inner.tun_name() { // it matches `name`. If the accessor fails (shouldn't in practice), fall back to the
if actual != name { // requested name so the rest of the system still has *something* to log/route against.
tracing::info!( let actual = inner.tun_name().unwrap_or_else(|_| name.to_string());
requested = name,
actual = %actual, #[cfg(target_os = "macos")]
"TUN interface name differs from requested (expected on 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. /// Read one IP packet from the TUN device.
@@ -178,6 +233,7 @@ impl AuraTun {
inner: std::sync::Arc::new(session), inner: std::sync::Arc::new(session),
_adapter: adapter, _adapter: adapter,
mtu, mtu,
name: name.to_string(),
}) })
} }
@@ -250,3 +306,92 @@ impl PacketIo for AuraTun {
.map_err(|e| std::io::Error::other(e.to_string())) .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"
);
}
}
+108 -1
View File
@@ -9,7 +9,7 @@ use async_trait::async_trait;
use aura_proto::PacketConnection; use aura_proto::PacketConnection;
use aura_tunnel::router::dst_ip; use aura_tunnel::router::dst_ip;
use aura_tunnel::tun::PacketIo; 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}; use tokio::sync::{mpsc, RwLock};
// ---- §8.4 RouteTable classification -------------------------------------------------------------- // ---- §8.4 RouteTable classification --------------------------------------------------------------
@@ -286,3 +286,110 @@ async fn test_router_direct_not_sent_to_vpn() {
drop(tun_in_tx); drop(tun_in_tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await; 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::<Vec<u8>>(8);
let (tun_out_tx, mut tun_out_rx) = mpsc::channel::<Vec<u8>>(8);
let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::<Vec<u8>>(8);
let (conn_recv_tx, conn_recv_rx) = mpsc::channel::<Vec<u8>>(8);
let tun = MockTun {
inbound: tun_in_rx,
written: tun_out_tx,
};
let conn: Arc<dyn PacketConnection> = 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::<Vec<u8>>(8);
let (tun_out_tx, _tun_out_rx) = mpsc::channel::<Vec<u8>>(8);
let (conn_sent_tx, mut conn_sent_rx) = mpsc::channel::<Vec<u8>>(8);
let (_conn_recv_tx, conn_recv_rx) = mpsc::channel::<Vec<u8>>(8);
let tun = MockTun {
inbound: tun_in_rx,
written: tun_out_tx,
};
let conn: Arc<dyn PacketConnection> = 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;
}