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
+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);
assert_eq!(targets.len(), 3, "primary + two bridges");
// The primary is always first.
assert_eq!(targets[0].udp.unwrap().to_string(), "203.0.113.10:443");
// The primary is always first. v3.4 default udp_port is 8443 (not 443).
assert_eq!(targets[0].udp.unwrap().to_string(), "203.0.113.10:8443");
// Each bridge entry must keep the per-transport ports (the bridge `:9999` in the second
// string is ignored — transports always use [transport] ports).
// string is ignored — transports always use [transport] ports, which default to 8443/8444
// in v3.4).
for t in &targets[1..] {
assert_eq!(t.udp.unwrap().port(), 443);
assert_eq!(t.quic.unwrap().port(), 444);
assert_eq!(t.udp.unwrap().port(), 8443);
assert_eq!(t.quic.unwrap().port(), 8444);
}
// Both bridge IPs are represented.
+53 -1
View File
@@ -86,7 +86,8 @@ fn provision_client_with_explicit_id() {
// The client.toml round-trips through the parser cleanly.
let cfg = ClientConfigFile::load(&report.client_config).expect("parse client.toml");
assert_eq!(cfg.client.server_addr, "203.0.113.10:443");
// v3.4: default udp_port is 8443 (was 443 in v3.3).
assert_eq!(cfg.client.server_addr, "203.0.113.10:8443");
assert_eq!(cfg.client.sni, "vpn.example.com");
assert_eq!(cfg.tunnel.local_ip, "10.7.0.2");
assert!(cfg.client.bridges.is_empty(), "no bridges by default");
@@ -280,6 +281,57 @@ fn provision_client_circuit_hops_too_few_errors() {
let _ = std::fs::remove_dir_all(&root);
}
/// v3.4: `vpn_cidrs` / `direct_cidrs` end up as `[[tunnel.split.vpn]]` / `[[tunnel.split.direct]]`
/// blocks in the rendered client.toml, and the server's parser actually loads them into the
/// `[tunnel.split]` rule table (proves we are not on the silently-ignored `vpn_cidrs = [...]`
/// flat-array footgun any more).
#[test]
fn provision_client_emits_split_cidr_blocks() {
let root = temp_dir("split-cidrs");
let ca_dir = root.join("ca");
bootstrap_ca(&ca_dir);
let bundle = root.join("bundle");
let mut opts = ProvisionClientOpts::new(
&ca_dir,
"203.0.113.10",
"vpn.example.com",
"10.7.0.7",
&bundle,
);
opts.vpn_cidrs = vec!["10.7.0.0/24".to_string(), "1.1.1.1/32".to_string()];
opts.direct_cidrs = vec!["192.168.0.0/16".to_string()];
let report = init::provision_client(&opts).expect("provision");
let toml_text = std::fs::read_to_string(&report.client_config).expect("read client.toml");
// The rendered TOML uses the array-of-tables syntax the server parser actually understands.
assert!(
toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"10.7.0.0/24\""),
"rendered toml missing 10.7.0.0/24 vpn block:\n{toml_text}"
);
assert!(
toml_text.contains("[[tunnel.split.vpn]]\ncidr = \"1.1.1.1/32\""),
"rendered toml missing 1.1.1.1/32 vpn block:\n{toml_text}"
);
assert!(
toml_text.contains("[[tunnel.split.direct]]\ncidr = \"192.168.0.0/16\""),
"rendered toml missing 192.168.0.0/16 direct block:\n{toml_text}"
);
// And the parser loads the rules — this is the bit v3.3 silently failed at.
let cfg = ClientConfigFile::load(&report.client_config).expect("parse");
assert_eq!(cfg.tunnel.split.vpn.len(), 2);
assert_eq!(cfg.tunnel.split.direct.len(), 1);
assert_eq!(cfg.tunnel.split.vpn[0].cidr.as_deref(), Some("10.7.0.0/24"));
assert_eq!(cfg.tunnel.split.vpn[1].cidr.as_deref(), Some("1.1.1.1/32"));
assert_eq!(
cfg.tunnel.split.direct[0].cidr.as_deref(),
Some("192.168.0.0/16")
);
let _ = std::fs::remove_dir_all(&root);
}
/// A non-empty bundle directory triggers an error without `--force`.
#[test]
fn provision_client_refuses_non_empty_bundle() {
+5 -3
View File
@@ -51,10 +51,12 @@ fn server_init_writes_and_parses() {
assert!(report.server_config.exists(), "server.toml exists");
let cfg = ServerConfigFile::load(&report.server_config).expect("server.toml parses");
assert_eq!(cfg.server.listen, "0.0.0.0:443");
// v3.4: server-init defaults moved off 443/444 to 8443/8444 to dodge sing-box / Hysteria2
// collisions; the listen-address derives from udp_port.
assert_eq!(cfg.server.listen, "0.0.0.0:8443");
assert_eq!(cfg.tunnel.pool_cidr, "10.7.0.0/24");
assert_eq!(cfg.transport.udp_port, 443);
assert_eq!(cfg.transport.quic_port, 444);
assert_eq!(cfg.transport.udp_port, 8443);
assert_eq!(cfg.transport.quic_port, 8444);
// no-nat was set in the baseline.
assert!(cfg.server.nat.is_none(), "no [server.nat] section");
// knock / cover default to disabled.