ba8d6b796f
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>
194 lines
7.6 KiB
Rust
194 lines
7.6 KiB
Rust
//! 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());
|
|
}
|
|
}
|