feat(cli): select transport in config; server MultiServer + client dial handover

- aura-cli config gains [transport] (order + per-transport ports + obfuscate/
  masquerade); server binds all enabled transports via MultiServer, client uses
  dial() with UDP->TCP->QUIC handover. Config examples updated; backward-compatible
  (defaults to udp,tcp,quic). 21 cli tests incl. a real-UDP-transport loopback.
- docs/sing-box.md: integration approach note (process-bridge now; native Go
  outbound for phones, with crypto-library mapping + KAT requirement).
- Normalize rustfmt across the v2 transport files (tcp/dial/udp contract).

Whole workspace: 97 tests pass, clippy -D warnings clean, fmt clean. Deploy flow
(pki init/issue-server/issue-client) validated with the release binary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-25 21:41:59 +03:00
parent d72fbe8d68
commit d5b9a8611d
15 changed files with 682 additions and 94 deletions
+333
View File
@@ -14,8 +14,11 @@
use std::fs;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Context};
use aura_transport::{DialConfig, Endpoints, TcpOpts, TransportMode, UdpOpts};
use aura_tunnel::{RouteAction, RouteTable};
use ipnetwork::IpNetwork;
use serde::Deserialize;
@@ -34,6 +37,9 @@ pub struct ServerConfigFile {
/// `[mimicry]` section: outer-TLS camouflage knobs.
#[serde(default)]
pub mimicry: ServerMimicrySection,
/// `[transport]` section: which transports to enable and their per-transport ports/options.
#[serde(default)]
pub transport: TransportSection,
}
/// `[server]` section.
@@ -87,6 +93,9 @@ pub struct ClientConfigFile {
/// `[mimicry]` section: outer-TLS camouflage knobs.
#[serde(default)]
pub mimicry: ClientMimicrySection,
/// `[transport]` section: fallback order and per-transport ports/options.
#[serde(default)]
pub transport: TransportSection,
}
/// `[client]` section.
@@ -178,6 +187,91 @@ pub struct PkiSection {
pub key: String,
}
/// `[transport]` section shared by both config files: the set/order of transports and their ports.
///
/// Aura's primary transport is its own post-quantum protocol over **plain UDP**, with **TCP/443**
/// and **QUIC** (HTTP/3 mimicry) as fallbacks. This section maps directly onto
/// [`aura_transport::Endpoints`] (server) and [`aura_transport::DialConfig`] (client):
///
/// * On the **client**, `order` is the fallback order tried left-to-right ("handover"); the first
/// transport that connects wins.
/// * On the **server**, `order` selects exactly which transports are bound and accepted at once.
///
/// The UDP transport and QUIC both ride UDP, so they **must** use different ports (`udp_port` vs
/// `quic_port`); TCP may reuse the UDP port number (different protocol). Backwards compatible: when
/// the whole section is omitted, [`TransportSection::default`] enables `udp, tcp, quic` on the
/// standard ports, so pre-transport-v2 configs keep working.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct TransportSection {
/// Client fallback order; server enables exactly these. Strings: `"udp"`, `"tcp"`, `"quic"`.
pub order: Vec<String>,
/// Port for Aura's custom UDP transport. Must differ from `quic_port`.
pub udp_port: u16,
/// Port for the TCP fallback. May equal `udp_port` (different protocol).
pub tcp_port: u16,
/// Port for the QUIC fallback. Must differ from `udp_port`.
pub quic_port: u16,
/// UDP transport: pad datagrams up to HTTPS size buckets to blur on-wire sizes.
pub obfuscate: bool,
/// TCP transport: prepend a minimal HTTP/1.1 preamble so the open resembles plain HTTP.
pub masquerade: bool,
}
impl Default for TransportSection {
fn default() -> Self {
Self {
order: default_transport_order(),
udp_port: 443,
tcp_port: 443,
quic_port: 444,
obfuscate: true,
masquerade: true,
}
}
}
impl TransportSection {
/// Parse `order` into [`TransportMode`]s, rejecting unknown names and duplicates.
pub fn modes(&self) -> anyhow::Result<Vec<TransportMode>> {
let mut modes = Vec::with_capacity(self.order.len());
for raw in &self.order {
let mode = TransportMode::from_str(raw)
.map_err(|e| anyhow!("invalid [transport] order entry: {e}"))?;
if modes.contains(&mode) {
return Err(anyhow!("duplicate transport '{mode}' in [transport] order"));
}
modes.push(mode);
}
if modes.is_empty() {
return Err(anyhow!(
"[transport] order must list at least one transport"
));
}
// UDP and QUIC share the UDP socket layer, so they cannot collide on the same port.
if modes.contains(&TransportMode::Udp)
&& modes.contains(&TransportMode::Quic)
&& self.udp_port == self.quic_port
{
return Err(anyhow!(
"[transport] udp_port and quic_port must differ ({} used for both); \
the UDP transport and QUIC both use UDP",
self.udp_port
));
}
Ok(modes)
}
/// The configured port for a given transport mode.
fn port_for(&self, mode: TransportMode) -> u16 {
match mode {
TransportMode::Udp => self.udp_port,
TransportMode::Tcp => self.tcp_port,
TransportMode::Quic => self.quic_port,
}
}
}
// ---- defaults -------------------------------------------------------------------------------
fn default_listen() -> String {
@@ -198,6 +292,11 @@ fn default_tun_name() -> String {
fn default_split_default() -> String {
"VPN".to_string()
}
/// Default transport set/order when `[transport]` (or its `order`) is omitted: UDP first, then the
/// TCP/443 and QUIC fallbacks. Keeps pre-transport-v2 configs working.
fn default_transport_order() -> Vec<String> {
vec!["udp".to_string(), "tcp".to_string(), "quic".to_string()]
}
// ---- ~ expansion ----------------------------------------------------------------------------
@@ -274,6 +373,48 @@ impl ServerConfigFile {
server_key_pem: read_pem(&self.pki.key)?,
})
}
/// Build the per-transport [`Endpoints`] the [`aura_transport::MultiServer`] should bind.
///
/// The listen **IP** is reused from `[server] listen`; each enabled transport (from
/// `[transport] order`) gets its `udp_port` / `tcp_port` / `quic_port`. Transports not listed in
/// `order` are left `None` (disabled).
pub fn transport_endpoints(&self) -> anyhow::Result<Endpoints> {
let ip = self.listen_addr()?.ip();
let modes = self.transport.modes()?;
let mut endpoints = Endpoints::default();
for mode in modes {
let addr = SocketAddr::new(ip, self.transport.port_for(mode));
match mode {
TransportMode::Udp => endpoints.udp = Some(addr),
TransportMode::Tcp => endpoints.tcp = Some(addr),
TransportMode::Quic => endpoints.quic = Some(addr),
}
}
Ok(endpoints)
}
/// Build the [`UdpOpts`] for the server's UDP transport from `[transport] obfuscate` (timeouts
/// keep their library defaults).
pub fn udp_opts(&self) -> UdpOpts {
UdpOpts {
obfuscate: self.transport.obfuscate,
..UdpOpts::default()
}
}
/// Build the [`TcpOpts`] for the server's TCP transport from `[transport] masquerade`; the
/// masquerade `Host` reuses the mimicry SNI when one is configured.
pub fn tcp_opts(&self) -> TcpOpts {
let mut opts = TcpOpts {
masquerade: self.transport.masquerade,
..TcpOpts::default()
};
if let Some(sni) = &self.mimicry.sni {
opts.host = sni.clone();
}
opts
}
}
impl ClientConfigFile {
@@ -305,6 +446,40 @@ impl ClientConfigFile {
.with_context(|| format!("invalid [tunnel] local_ip '{}'", self.tunnel.local_ip))
}
/// Build the [`DialConfig`] the client passes to [`aura_transport::dial`].
///
/// The server **IP** is taken from `[client] server_addr` (its port is ignored: each transport
/// uses its own port from `[transport]`). `order` becomes the fallback order, and the per-
/// transport options (UDP `obfuscate`, TCP `masquerade`/`host` and the QUIC SNI) come from
/// `[transport]` + `[client] sni`.
pub fn dial_config(&self) -> anyhow::Result<DialConfig> {
let ip = self.server_socket_addr()?.ip();
let order = self.transport.modes()?;
let mut endpoints = Endpoints::default();
for mode in &order {
let addr = SocketAddr::new(ip, self.transport.port_for(*mode));
match mode {
TransportMode::Udp => endpoints.udp = Some(addr),
TransportMode::Tcp => endpoints.tcp = Some(addr),
TransportMode::Quic => endpoints.quic = Some(addr),
}
}
Ok(DialConfig {
endpoints,
sni: self.client.sni.clone(),
order,
udp: UdpOpts {
obfuscate: self.transport.obfuscate,
..UdpOpts::default()
},
tcp: TcpOpts {
masquerade: self.transport.masquerade,
host: self.client.sni.clone(),
},
attempt_timeout: Duration::from_secs(8),
})
}
/// Read the `[pki]` PEM files and build an [`aura_proto::ClientConfig`].
///
/// The inner-handshake `server_name` is taken from `[client] sni` so the SAN verified against
@@ -398,6 +573,14 @@ dns = "10.7.0.1"
[mimicry]
sni = "cdn.example.com"
padding = true
[transport]
order = ["udp", "tcp", "quic"]
udp_port = 4433
tcp_port = 4433
quic_port = 4434
obfuscate = true
masquerade = true
"#;
const CLIENT_TOML: &str = r#"
@@ -434,6 +617,14 @@ cidr = "10.7.0.0/24"
[mimicry]
padding = false
[transport]
order = ["tcp", "udp"]
udp_port = 4433
tcp_port = 4433
quic_port = 4434
obfuscate = false
masquerade = true
"#;
#[test]
@@ -447,6 +638,18 @@ padding = false
assert!(cfg.mimicry.padding);
assert_eq!(cfg.mimicry.sni.as_deref(), Some("cdn.example.com"));
assert_eq!(cfg.pki.ca_cert, "/etc/aura/ca.crt");
// [transport]: order + ports parse and the server endpoints reuse the listen IP.
assert_eq!(cfg.transport.order, vec!["udp", "tcp", "quic"]);
assert_eq!(cfg.transport.quic_port, 4434);
let eps = cfg.transport_endpoints().expect("server endpoints");
assert_eq!(eps.udp.unwrap().to_string(), "0.0.0.0:4433");
assert_eq!(eps.tcp.unwrap().to_string(), "0.0.0.0:4433");
assert_eq!(eps.quic.unwrap().to_string(), "0.0.0.0:4434");
assert!(cfg.udp_opts().obfuscate);
let tcp = cfg.tcp_opts();
assert!(tcp.masquerade);
assert_eq!(tcp.host, "cdn.example.com"); // reuses mimicry SNI
}
#[test]
@@ -466,6 +669,17 @@ pool_cidr = "10.7.0.0/24"
assert_eq!(cfg.server.workers, 1);
assert_eq!(cfg.tunnel.mtu, 1420);
assert!(!cfg.mimicry.padding);
// Omitting [transport] yields the backward-compatible defaults (udp/tcp/quic on 443/443/444).
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!(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");
}
#[test]
@@ -481,6 +695,21 @@ pool_cidr = "10.7.0.0/24"
assert_eq!(cfg.tunnel.prefix, 24);
assert_eq!(cfg.tunnel.split.direct.len(), 3);
assert_eq!(cfg.tunnel.split.vpn.len(), 1);
// [transport]: the client dial config honors `order` and reuses the server IP per transport.
let dial = cfg.dial_config().expect("client dial config");
assert_eq!(
dial.order,
vec![TransportMode::Tcp, TransportMode::Udp] // as written in CLIENT_TOML
);
assert_eq!(dial.endpoints.tcp.unwrap().to_string(), "203.0.113.10:4433");
assert_eq!(dial.endpoints.udp.unwrap().to_string(), "203.0.113.10:4433");
// QUIC was not in `order`, so it is left unconfigured.
assert!(dial.endpoints.quic.is_none());
assert_eq!(dial.sni, "cdn.example.com");
assert!(!dial.udp.obfuscate);
assert!(dial.tcp.masquerade);
assert_eq!(dial.tcp.host, "cdn.example.com");
}
#[test]
@@ -540,10 +769,16 @@ pool_cidr = "10.7.0.0/24"
let s = ServerConfigFile::parse(&server).expect("server.toml.example parses");
assert!(s.listen_addr().is_ok());
assert!(s.pool_network().is_ok());
// The shipped [transport] section builds valid endpoints (udp_port != quic_port).
let eps = s
.transport_endpoints()
.expect("example server endpoints build");
assert!(eps.udp.is_some() && eps.tcp.is_some() && eps.quic.is_some());
let c = ClientConfigFile::parse(&client).expect("client.toml.example parses");
assert!(c.server_socket_addr().is_ok());
assert!(c.local_ip().is_ok());
assert!(c.dial_config().is_ok(), "example client dial config builds");
let (table, domains) = c
.build_route_table()
.expect("example split builds a route table");
@@ -573,4 +808,102 @@ domain = "x.example.com"
let cfg = ClientConfigFile::parse(bad).expect("parse");
assert!(cfg.build_route_table().is_err());
}
/// A client config with no `[transport]` section falls back to the udp→tcp→quic defaults so old
/// configs keep working.
#[test]
fn client_transport_defaults_when_omitted() {
let minimal = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "a"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(minimal).expect("parse");
let dial = cfg.dial_config().expect("dial config");
assert_eq!(
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");
}
/// UDP and QUIC share the UDP socket layer; configuring the same port for both must be rejected.
#[test]
fn rejects_udp_quic_port_collision() {
let bad = r#"
[server]
name = "edge"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
[transport]
order = ["udp", "quic"]
udp_port = 443
quic_port = 443
"#;
let cfg = ServerConfigFile::parse(bad).expect("parse");
let err = cfg.transport_endpoints().unwrap_err().to_string();
assert!(
err.contains("udp_port") && err.contains("quic_port"),
"{err}"
);
}
/// Sharing the UDP/QUIC port number is fine if only one of them is enabled.
#[test]
fn allows_shared_port_when_quic_disabled() {
let ok = r#"
[server]
name = "edge"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
[transport]
order = ["udp", "tcp"]
udp_port = 443
tcp_port = 443
quic_port = 443
"#;
let cfg = ServerConfigFile::parse(ok).expect("parse");
let eps = cfg.transport_endpoints().expect("endpoints");
assert!(eps.udp.is_some());
assert!(eps.tcp.is_some());
assert!(eps.quic.is_none());
}
/// An unknown transport name in `order` is a hard error (not silently dropped).
#[test]
fn rejects_unknown_transport_name() {
let bad = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "a"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
[transport]
order = ["udp", "smoke-signals"]
"#;
let cfg = ClientConfigFile::parse(bad).expect("parse");
assert!(cfg.dial_config().is_err());
}
}