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

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

## v3.4 server port-discovery

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

## Bug fixes from the live test

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

## Test coverage

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-29 17:14:45 +03:00
parent a173ced9b2
commit ba8d6b796f
20 changed files with 1267 additions and 110 deletions
+108 -1
View File
@@ -9,7 +9,7 @@ use async_trait::async_trait;
use aura_proto::PacketConnection;
use aura_tunnel::router::dst_ip;
use aura_tunnel::tun::PacketIo;
use aura_tunnel::{AuraDns, AuraRouter, RouteAction, RouteTable};
use aura_tunnel::{AuraDns, AuraRouter, PacketCounters, RouteAction, RouteTable};
use tokio::sync::{mpsc, RwLock};
// ---- §8.4 RouteTable classification --------------------------------------------------------------
@@ -286,3 +286,110 @@ async fn test_router_direct_not_sent_to_vpn() {
drop(tun_in_tx);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
}
// ---- PacketCounters wiring through AuraRouter ----------------------------------------------------
/// A VPN-routed outbound packet bumps `tx`; a DIRECT-routed outbound packet *also* bumps `tx`
/// (the v1 stub still counts as "tx from the TUN"); a packet pumped through the connection and
/// successfully written to the TUN bumps `rx`.
#[tokio::test]
async fn test_router_packet_counters_increment_for_tx_and_rx() {
let (tun_in_tx, tun_in_rx) = mpsc::channel::<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;
}