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:
@@ -40,7 +40,7 @@ use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use aura_tunnel::{RouteAction, RouteTable};
|
||||
use aura_tunnel::{PacketCounters, RouteAction, RouteTable};
|
||||
use ipnetwork::IpNetwork;
|
||||
use serde::{Deserialize, Serialize};
|
||||
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";
|
||||
|
||||
/// 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)]
|
||||
pub struct Stats {
|
||||
/// 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).
|
||||
pub tx_packets: AtomicU64,
|
||||
pub tx_packets: Arc<AtomicU64>,
|
||||
/// Verified peer identity, set once a connection is established.
|
||||
pub peer_id: StdMutex<Option<String>>,
|
||||
}
|
||||
@@ -79,6 +84,17 @@ impl Stats {
|
||||
*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
|
||||
|
||||
@@ -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
|
||||
/// 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)]
|
||||
pub struct BridgeManifest {
|
||||
/// 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
|
||||
/// not impose a hard limit.
|
||||
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 {
|
||||
@@ -90,12 +144,13 @@ impl BridgeManifest {
|
||||
generated_at,
|
||||
expires_at,
|
||||
bridges,
|
||||
endpoints: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `aura sign-bridges` CLI command.
|
||||
/// `aura sign-bridges` CLI command (v3.3 path; no per-transport endpoints).
|
||||
#[must_use]
|
||||
pub fn with_ttl(bridges: Vec<String>, ttl: Duration) -> Self {
|
||||
let now = unix_now();
|
||||
@@ -104,9 +159,42 @@ impl BridgeManifest {
|
||||
generated_at: now,
|
||||
expires_at: now.saturating_add(ttl.as_secs()),
|
||||
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
|
||||
/// disk in the signed-manifest format documented at the module level.
|
||||
pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||
@@ -514,6 +602,7 @@ mod tests {
|
||||
generated_at: now,
|
||||
expires_at: now + 3600,
|
||||
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)
|
||||
// by serialising the body manually with version=99.
|
||||
@@ -638,4 +727,43 @@ mod tests {
|
||||
let snap = watcher.current().await;
|
||||
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()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +294,20 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
||||
)
|
||||
.await
|
||||
.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
|
||||
// TUN. The guard is bound to this `run()` scope; its Drop rolls every installed route back
|
||||
@@ -303,10 +316,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
|
||||
// explicitly, set `enabled = false`.
|
||||
//
|
||||
// We pass `cfg.tunnel.tun_name` rather than the kernel-assigned name because `AuraTun` does
|
||||
// not (yet) surface the latter; on macOS the operator can pin the resulting `utunN` in the
|
||||
// config (or set `[tunnel.os_routes] dry_run = true` to validate the plan). Linux assigns the
|
||||
// requested name verbatim.
|
||||
// We pass `actual_tun_name` (the kernel-assigned name from `AuraTun::name()`), not
|
||||
// `cfg.tunnel.tun_name`. On macOS those differ whenever the config does not pre-pin a valid
|
||||
// `utunN`, so passing the config string would make every `route add -interface ...` silently
|
||||
// miss the real interface.
|
||||
let os_routes_cfg = cfg
|
||||
.tunnel
|
||||
.os_routes
|
||||
@@ -315,7 +328,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 split = SplitRoutes::from_config(&cfg.tunnel.split, &resolved_domains);
|
||||
let guard = OsRouteGuard::install(
|
||||
&cfg.tunnel.tun_name,
|
||||
&actual_tun_name,
|
||||
&split,
|
||||
os_routes_cfg.gateway.as_deref(),
|
||||
os_routes_cfg.egress_iface.as_deref(),
|
||||
@@ -323,7 +336,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
||||
)
|
||||
.context("installing OS-level split-tunnel routes")?;
|
||||
tracing::info!(
|
||||
tun = %cfg.tunnel.tun_name,
|
||||
tun = %actual_tun_name,
|
||||
dry_run = os_routes_cfg.dry_run,
|
||||
"OS-level split-tunnel routes installed (DIRECT traffic now bypasses the TUN)"
|
||||
);
|
||||
@@ -346,7 +359,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")?;
|
||||
}
|
||||
|
||||
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");
|
||||
// _os_routes_guard drops here, rolling back any installed system routes.
|
||||
run_result
|
||||
|
||||
@@ -695,9 +695,14 @@ impl Default for TransportSection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
order: default_transport_order(),
|
||||
udp_port: 443,
|
||||
tcp_port: 443,
|
||||
quic_port: 444,
|
||||
// v3.4: defaults moved off 443/444 because in practice 443 is heavily contested
|
||||
// (sing-box, Hysteria2, Cloudflare tunnels, ...). Picking 8443/8444 gives us a free
|
||||
// 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,
|
||||
masquerade: true,
|
||||
masks: MasksSection::default(),
|
||||
@@ -1547,16 +1552,17 @@ pool_cidr = "10.7.0.0/24"
|
||||
assert_eq!(cfg.tunnel.mtu, 1420);
|
||||
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.udp_port, 443);
|
||||
assert_eq!(cfg.transport.tcp_port, 443);
|
||||
assert_eq!(cfg.transport.quic_port, 444);
|
||||
assert_eq!(cfg.transport.udp_port, 8443);
|
||||
assert_eq!(cfg.transport.tcp_port, 8443);
|
||||
assert_eq!(cfg.transport.quic_port, 8444);
|
||||
assert!(cfg.transport.obfuscate);
|
||||
assert!(cfg.transport.masquerade);
|
||||
let eps = cfg.transport_endpoints().expect("default endpoints");
|
||||
assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:443");
|
||||
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:444");
|
||||
assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:8443");
|
||||
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:8444");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1709,9 +1715,12 @@ local_ip = "10.7.0.2"
|
||||
dial.order,
|
||||
vec![TransportMode::Udp, TransportMode::Tcp, TransportMode::Quic]
|
||||
);
|
||||
assert_eq!(dial.endpoints.udp.unwrap().to_string(), "1.2.3.4:443");
|
||||
assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "1.2.3.4:443");
|
||||
assert_eq!(dial.endpoints.quic.unwrap().to_string(), "1.2.3.4:444");
|
||||
// v3.4: when [transport] is omitted the defaults are 8443/8443/8444 (was 443/443/444 in
|
||||
// v3.3); the `server_addr` port is informational here — actual transport ports come from
|
||||
// [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
|
||||
|
||||
@@ -40,11 +40,13 @@ pub struct ServerInitOpts {
|
||||
pub pki_dir: PathBuf,
|
||||
/// Listen IP for `[server] listen` and `[transport]` bindings. Default `0.0.0.0`.
|
||||
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,
|
||||
/// TCP fallback port. Default 443.
|
||||
/// TCP fallback port. Default `8443`. May equal `udp_port` (different protocol).
|
||||
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,
|
||||
/// VPN address pool. Default `10.7.0.0/24`.
|
||||
pub pool_cidr: String,
|
||||
@@ -74,9 +76,9 @@ impl ServerInitOpts {
|
||||
domain: domain.into(),
|
||||
pki_dir: pki_dir.into(),
|
||||
listen_ip: "0.0.0.0".to_string(),
|
||||
udp_port: 443,
|
||||
tcp_port: 443,
|
||||
quic_port: 444,
|
||||
udp_port: 8443,
|
||||
tcp_port: 8443,
|
||||
quic_port: 8444,
|
||||
pool_cidr: "10.7.0.0/24".to_string(),
|
||||
egress_iface: None,
|
||||
out_config: PathBuf::from("/etc/aura/server.toml"),
|
||||
@@ -290,6 +292,12 @@ pub struct ProvisionClientOpts {
|
||||
pub enable_cover_traffic: bool,
|
||||
/// Optional bridge addresses (`bridges = [...]`).
|
||||
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**
|
||||
/// (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
|
||||
@@ -318,15 +326,17 @@ impl ProvisionClientOpts {
|
||||
ca_dir: ca_dir.into(),
|
||||
server_addr: server_addr.into(),
|
||||
server_name: server_name.into(),
|
||||
udp_port: 443,
|
||||
tcp_port: 443,
|
||||
quic_port: 444,
|
||||
udp_port: 8443,
|
||||
tcp_port: 8443,
|
||||
quic_port: 8444,
|
||||
tun_ip: tun_ip.into(),
|
||||
tun_prefix: 24,
|
||||
out_dir: out_dir.into(),
|
||||
enable_knock: false,
|
||||
enable_cover_traffic: false,
|
||||
bridges: Vec::new(),
|
||||
vpn_cidrs: Vec::new(),
|
||||
direct_cidrs: Vec::new(),
|
||||
circuit_hops: None,
|
||||
force: false,
|
||||
}
|
||||
@@ -479,8 +489,22 @@ pub fn render_client_toml(
|
||||
s.push_str(&format!("prefix = {}\n", opts.tun_prefix));
|
||||
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("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("padding = true\n\n");
|
||||
|
||||
@@ -30,5 +30,6 @@ pub mod pki;
|
||||
pub mod pool;
|
||||
pub mod privdrop;
|
||||
pub mod relay;
|
||||
pub mod runtime_state;
|
||||
pub mod server;
|
||||
pub mod server_router;
|
||||
|
||||
+134
-38
@@ -165,14 +165,14 @@ struct ServerInitArgs {
|
||||
/// Listen IP for the server (default 0.0.0.0).
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
listen_ip: String,
|
||||
/// UDP transport port (default 443).
|
||||
#[arg(long, default_value_t = 443)]
|
||||
/// UDP transport port (default 8443; v3.4 moved off 443 to dodge sing-box/Hysteria2 conflicts).
|
||||
#[arg(long, default_value_t = 8443)]
|
||||
udp_port: u16,
|
||||
/// TCP fallback port (default 443).
|
||||
#[arg(long, default_value_t = 443)]
|
||||
/// TCP fallback port (default 8443).
|
||||
#[arg(long, default_value_t = 8443)]
|
||||
tcp_port: u16,
|
||||
/// QUIC fallback port (default 444). Must differ from --udp-port.
|
||||
#[arg(long, default_value_t = 444)]
|
||||
/// QUIC fallback port (default 8444). Must differ from --udp-port.
|
||||
#[arg(long, default_value_t = 8444)]
|
||||
quic_port: u16,
|
||||
/// VPN address pool (default 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).
|
||||
#[arg(long)]
|
||||
server_name: String,
|
||||
/// UDP transport port (default 443).
|
||||
#[arg(long, default_value_t = 443)]
|
||||
/// UDP transport port (default 8443).
|
||||
#[arg(long, default_value_t = 8443)]
|
||||
udp_port: u16,
|
||||
/// TCP fallback port (default 443).
|
||||
#[arg(long, default_value_t = 443)]
|
||||
/// TCP fallback port (default 8443).
|
||||
#[arg(long, default_value_t = 8443)]
|
||||
tcp_port: u16,
|
||||
/// QUIC fallback port (default 444).
|
||||
#[arg(long, default_value_t = 444)]
|
||||
/// QUIC fallback port (default 8444).
|
||||
#[arg(long, default_value_t = 8444)]
|
||||
quic_port: u16,
|
||||
/// TUN local IP (placed in [tunnel] local_ip). Must fall inside the server's pool.
|
||||
#[arg(long)]
|
||||
@@ -242,6 +242,14 @@ struct ProvisionClientArgs {
|
||||
/// Comma-separated list of fallback server addresses (IP or IP:port).
|
||||
#[arg(long)]
|
||||
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
|
||||
/// 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
|
||||
@@ -260,9 +268,17 @@ struct SignBridgesArgs {
|
||||
/// Directory holding the CA (`ca.crt` + `ca.key`).
|
||||
#[arg(long)]
|
||||
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)]
|
||||
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`
|
||||
/// — clients reject manifests past their expiry.
|
||||
#[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
|
||||
/// 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<()> {
|
||||
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)
|
||||
.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 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)?;
|
||||
|
||||
println!("Signed bridges manifest written:");
|
||||
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!(" expires_at: {}", manifest.expires_at);
|
||||
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
|
||||
/// 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
|
||||
@@ -581,15 +672,18 @@ fn opts_domain_for_hint(server_toml: &std::path::Path) -> String {
|
||||
|
||||
/// Dispatch `aura provision-client`.
|
||||
fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> {
|
||||
let bridges = args
|
||||
.bridges
|
||||
.map(|s| {
|
||||
fn split_csv(s: Option<String>) -> Vec<String> {
|
||||
s.map(|s| {
|
||||
s.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.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 {
|
||||
id: args.id,
|
||||
ca_dir: args.ca,
|
||||
@@ -604,6 +698,8 @@ fn run_provision_client(args: ProvisionClientArgs) -> anyhow::Result<()> {
|
||||
enable_knock: args.enable_knock,
|
||||
enable_cover_traffic: args.enable_cover_traffic,
|
||||
bridges,
|
||||
vpn_cidrs,
|
||||
direct_cidrs,
|
||||
circuit_hops: args.circuit_hops,
|
||||
force: args.force,
|
||||
};
|
||||
|
||||
@@ -168,8 +168,12 @@ pub struct OsRouteGuard {
|
||||
impl OsRouteGuard {
|
||||
/// 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,
|
||||
/// `"utun4"` on macOS — see [`aura_tunnel::AuraTun::name`]).
|
||||
/// * `tun_name`: the **kernel-assigned** name of the freshly created TUN device — read it
|
||||
/// 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.
|
||||
/// * `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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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`.
|
||||
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,
|
||||
proto_cfg.clone(),
|
||||
udp_opts,
|
||||
tcp_opts.clone(),
|
||||
outer_pems.as_ref().map(|(c, _)| c.as_str()),
|
||||
outer_pems.as_ref().map(|(_, k)| k.as_str()),
|
||||
aura_transport::DEFAULT_PORT_SCAN_MAX,
|
||||
)
|
||||
.await
|
||||
.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
|
||||
// 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
|
||||
// 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 tun = AuraTun::create("aura-srv0", server_tun_ip, prefix, mtu)
|
||||
.await
|
||||
.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)
|
||||
// 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")?;
|
||||
}
|
||||
|
||||
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 inbound_tx = router.inbound_sender();
|
||||
let router_task = tokio::spawn(async move {
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::sync::Arc;
|
||||
|
||||
use aura_proto::PacketConnection;
|
||||
use aura_tunnel::router::dst_ip;
|
||||
use aura_tunnel::PacketIo;
|
||||
use aura_tunnel::{PacketCounters, PacketIo};
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
|
||||
use crate::pool::IpPool;
|
||||
@@ -119,22 +119,44 @@ pub struct ServerRouter<P: PacketIo> {
|
||||
/// drains the receiver.
|
||||
inbound_tx: mpsc::Sender<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> {
|
||||
/// 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 {
|
||||
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).
|
||||
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);
|
||||
Self {
|
||||
tun,
|
||||
routes,
|
||||
inbound_tx,
|
||||
inbound_rx,
|
||||
counters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +237,10 @@ impl<P: PacketIo + 'static> ServerRouter<P> {
|
||||
if let Err(e) = self.tun.write_packet(&pkt).await {
|
||||
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 => {
|
||||
// All inbound senders dropped (the accept-loop and all per-conn
|
||||
@@ -234,7 +260,13 @@ impl<P: PacketIo + 'static> ServerRouter<P> {
|
||||
return Ok(());
|
||||
};
|
||||
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 => {
|
||||
tracing::trace!(%dst, len = pkt.len(), "no client registered for destination; dropping");
|
||||
Ok(())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user