From d5b9a8611d02d469ac75d4657af5f54216b47567 Mon Sep 17 00:00:00 2001 From: xah30 Date: Mon, 25 May 2026 21:41:59 +0300 Subject: [PATCH] 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 --- config/client.toml.example | 16 + config/server.toml.example | 15 + crates/aura-cli/src/client.rs | 29 +- crates/aura-cli/src/config.rs | 333 +++++++++++++++++++ crates/aura-cli/src/main.rs | 2 +- crates/aura-cli/src/server.rs | 66 ++-- crates/aura-cli/tests/loopback.rs | 197 ++++++++--- crates/aura-crypto/src/aead.rs | 7 +- crates/aura-proto/src/lib.rs | 4 +- crates/aura-proto/src/session.rs | 4 +- crates/aura-transport/src/dial.rs | 7 +- crates/aura-transport/src/tcp.rs | 6 +- crates/aura-transport/tests/dial_fallback.rs | 12 +- crates/aura-transport/tests/tcp_loopback.rs | 8 +- docs/sing-box.md | 70 ++++ 15 files changed, 682 insertions(+), 94 deletions(-) create mode 100644 docs/sing-box.md diff --git a/config/client.toml.example b/config/client.toml.example index eea00b9..7c590d9 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -51,3 +51,19 @@ cidr = "10.7.0.0/24" [mimicry] # Enable traffic padding to blend packet sizes into HTTPS buckets. padding = false + +[transport] +# Fallback order tried left-to-right ("handover"): the first transport that connects wins. Aura's +# own UDP transport is primary; TCP/443 and QUIC (HTTP/3 mimicry) are fallbacks for networks that +# throttle or block plain UDP. Omitting this whole section uses ["udp","tcp","quic"] on 443/443/444. +order = ["udp", "tcp", "quic"] +# Per-transport server ports. The server IP comes from [client] server_addr above (its port there is +# ignored). The UDP transport and QUIC both ride UDP, so udp_port and quic_port MUST differ; TCP may +# reuse the UDP port number. +udp_port = 443 +tcp_port = 443 +quic_port = 444 +# UDP: pad datagrams up to HTTPS size buckets to blur the on-wire size distribution. +obfuscate = true +# TCP: prepend a minimal HTTP/1.1 preamble (Host = [client] sni) so the open resembles plain HTTP. +masquerade = true diff --git a/config/server.toml.example b/config/server.toml.example index d7402dd..15fdcdd 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -30,3 +30,18 @@ dns = "10.7.0.1" sni = "cdn.example.com" # Enable traffic padding to blend packet sizes into HTTPS buckets. padding = true + +[transport] +# Aura's own post-quantum transport runs over plain UDP (primary), with TCP/443 and QUIC (HTTP/3 +# mimicry) as fallbacks. On the server, `order` selects exactly which transports are bound and +# accepted simultaneously. Omitting this whole section enables udp/tcp/quic on 443/443/444. +order = ["udp", "tcp", "quic"] +# The UDP transport and QUIC both ride UDP, so udp_port and quic_port MUST differ. TCP may reuse the +# UDP port number (different protocol). Ports bind on the IP from [server] listen above. +udp_port = 443 +tcp_port = 443 +quic_port = 444 +# UDP: pad datagrams up to HTTPS size buckets to blur the on-wire size distribution. +obfuscate = true +# TCP: prepend a minimal HTTP/1.1 preamble (Host = [mimicry] sni) so the open resembles plain HTTP. +masquerade = true diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 70063f0..5866e59 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -4,8 +4,9 @@ //! 1. Load `client.toml`, read the `[pki]` PEM files, build [`aura_proto::ClientConfig`]. //! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules); //! record domain rules for resolution. -//! 3. [`AuraClient::connect`] to `[client] server_addr`, presenting `[client] sni` as the outer -//! (mimicry) hostname. +//! 3. [`aura_transport::dial`] the server, trying each transport in `[transport] order` (the +//! UDP→TCP→QUIC "handover") until one connects; QUIC presents `[client] sni` as the outer +//! (mimicry) hostname and TCP uses it as the masquerade `Host`. //! 4. Resolve any split-tunnel domain rules via [`AuraDns`] into host routes (best-effort). //! 5. Create the local TUN ([`AuraTun::create`]) on `[tunnel] local_ip/prefix` and run //! [`AuraRouter`] to bridge the TUN and the connection. @@ -21,7 +22,7 @@ use std::path::Path; use std::sync::Arc; use anyhow::Context; -use aura_transport::AuraClient; +use aura_transport::dial; use aura_tunnel::{AuraDns, AuraRouter, AuraTun}; use tokio::sync::RwLock; @@ -31,18 +32,22 @@ use crate::config::ClientConfigFile; /// Entry point for `aura client --config ` (and optional `--admin-socket`). pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let cfg = ClientConfigFile::load(config_path)?; - let server_addr = cfg.server_socket_addr()?; let local_ip = cfg.local_ip()?; let proto_cfg = cfg.to_proto()?; + let dial_cfg = cfg.dial_config()?; let (table, domains) = cfg.build_route_table()?; tracing::info!( name = %cfg.client.name, - %server_addr, + server_addr = %cfg.client.server_addr, sni = %cfg.client.sni, %local_ip, dns = ?cfg.tunnel.dns, mimicry_padding = cfg.mimicry.padding, + transport_order = ?cfg.transport.order, + udp = ?dial_cfg.endpoints.udp, + tcp = ?dial_cfg.endpoints.tcp, + quic = ?dial_cfg.endpoints.quic, "starting Aura client" ); @@ -53,13 +58,17 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let routes = Arc::new(RwLock::new(table)); let stats = Arc::new(Stats::new()); - // Connect (outer QUIC + inner Aura mutual-auth handshake). - let conn = AuraClient::connect(server_addr, &cfg.client.sni, proto_cfg) + // Dial: try each transport in `[transport] order` (UDP→TCP→QUIC handover) until one connects. + // Each transport runs the inner Aura mutual-auth handshake; the winner is returned as a uniform + // `Arc` along with which mode carried it. (The trait object does not surface + // the verified server CN; the server identity was already checked against `[client] sni` inside + // the handshake, so we record that as the peer for the admin/status mirror.) + let (conn, mode) = dial(proto_cfg, dial_cfg) .await .context("connecting to Aura server")?; - let peer = conn.peer_id().map(str::to_owned); + let peer = Some(cfg.client.sni.clone()); stats.set_peer_id(peer.clone()); - tracing::info!(peer = ?peer, "connected and authenticated to server"); + tracing::info!(peer = ?peer, %mode, "connected and authenticated to server"); // Resolve split-tunnel domain rules into host routes (best-effort; failures are logged). if !domains.is_empty() { @@ -110,7 +119,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { .context("creating TUN device (needs root)")?; tracing::info!(tun = %cfg.tunnel.tun_name, "TUN device up; routing traffic"); - let router = AuraRouter::new(tun, routes, conn.into_dyn()); + let router = AuraRouter::new(tun, routes, conn); router.run().await.context("router run loop")?; Ok(()) } diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 81b6e73..8ccbc58 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -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, + /// 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> { + 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 { + 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 { + 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 { + 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()); + } } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index e0015af..4087244 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -38,7 +38,7 @@ enum Command { #[command(subcommand)] Pki(PkiCommand), - /// Run the Aura VPN server (binds QUIC, accepts clients; needs root for the TUN device). + /// Run the Aura VPN server (binds the configured transports, accepts clients; needs root for TUN). Server(ServerArgs), /// Run the Aura VPN client (connects to a server; needs root for the TUN device). diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index 4620f3a..da9e0aa 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -1,27 +1,29 @@ -//! `aura server`: bind an [`AuraServer`], accept connections, and pump packets to a server-side TUN. +//! `aura server`: bind a [`MultiServer`], accept connections on every enabled transport, and pump +//! packets to a server-side TUN. //! //! ## v1 data path //! 1. Load `server.toml`, read the `[pki]` PEM files, build [`aura_proto::ServerConfig`]. -//! 2. [`AuraServer::bind`] on `[server] listen` (the outer QUIC/mimicry cert reuses the Aura server -//! leaf PEM, as the transport docs suggest). +//! 2. [`MultiServer::bind`] on the `[transport]` endpoints (UDP / TCP / QUIC, per `order`), reusing +//! the listen IP from `[server] listen`. The QUIC outer (mimicry) cert reuses the Aura server leaf. //! 3. Start the admin IPC listener over a shared (empty) [`RouteTable`] + [`Stats`]. -//! 4. Accept loop: for each authenticated [`AuraConnection`], create a single shared server-side TUN -//! on `[tunnel] pool_cidr` (the network's first host address) and run [`AuraRouter`] to bridge -//! the connection and the TUN. +//! 4. Accept loop: for each [`aura_transport::Accepted`] connection (regardless of which transport +//! carried it), create a server-side TUN on `[tunnel] pool_cidr` (the network's first host +//! address) and run [`AuraRouter`] to bridge the connection and the TUN. //! //! ## Privilege / scope notes (NOT auto-tested) //! * Creating the TUN ([`AuraTun::create`]) needs **root** — the accept loop's data path is //! therefore exercised only in a live, privileged run, not in unit tests. -//! * Binding a UDP socket on `[server] listen` (e.g. `:443`) typically needs privileges too. +//! * Binding the transport sockets on the configured ports (e.g. `:443`) typically needs privileges. //! * Multi-client IP-pool allocation / NAT is **out of v1 scope**: v1 bridges to one shared TUN, so //! it is correct for a single active client. The accept loop still accepts many connections (each -//! gets its own router task), which is enough to demonstrate the end-to-end path. +//! gets its own router task), which is enough to demonstrate the end-to-end path. (The custom-UDP +//! backend is single-peer-per-accept in v1; prefer TCP/QUIC for many concurrent clients.) use std::path::Path; use std::sync::Arc; use anyhow::Context; -use aura_transport::AuraServer; +use aura_transport::MultiServer; use aura_tunnel::{AuraRouter, AuraTun, RouteAction, RouteTable}; use ipnetwork::IpNetwork; use tokio::sync::RwLock; @@ -36,6 +38,11 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let proto_cfg = cfg.to_proto()?; let pool = cfg.pool_network()?; + // Per-transport endpoints (UDP/TCP/QUIC) derived from the listen IP + `[transport]` ports. + let endpoints = cfg.transport_endpoints()?; + let udp_opts = cfg.udp_opts(); + let tcp_opts = cfg.tcp_opts(); + tracing::info!( name = %cfg.server.name, %listen, @@ -44,19 +51,21 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { dns = ?cfg.tunnel.dns, mimicry_sni = ?cfg.mimicry.sni, mimicry_padding = cfg.mimicry.padding, + transport_order = ?cfg.transport.order, + udp = ?endpoints.udp, + tcp = ?endpoints.tcp, + quic = ?endpoints.quic, + obfuscate = udp_opts.obfuscate, + masquerade = tcp_opts.masquerade, "starting Aura server" ); - // The outer (mimicry) QUIC cert reuses the Aura server leaf, matching the transport's guidance. - let server = AuraServer::bind( - listen, - &proto_cfg.server_cert_pem, - &proto_cfg.server_key_pem, - proto_cfg.clone(), - ) - .context("binding Aura server")?; - let bound = server.local_addr().context("reading bound address")?; - tracing::info!(%bound, "Aura server bound"); + // Bind every enabled transport at once. The QUIC outer (mimicry) cert reuses the Aura server + // leaf inside `proto_cfg`, matching the transport's guidance. + let mut server = MultiServer::bind(endpoints, proto_cfg.clone(), udp_opts, tcp_opts) + .await + .context("binding Aura multi-transport server")?; + tracing::info!("Aura server bound on all enabled transports"); // Shared routing table (server-side classification is trivial in v1: everything via VPN) + // stats, exposed over the admin socket. @@ -75,13 +84,15 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { } }); - // Accept loop. Each accepted connection gets a server-side TUN and a router task. + // Accept loop. Each accepted connection (from any transport) gets a server-side TUN and a router + // task. `MultiServer::accept` yields `None` only when every transport's accept loop has stopped. let mtu = cfg.tunnel.mtu; - loop { - let conn = server.accept().await.context("accepting connection")?; - let peer = conn.peer_id().map(str::to_owned); + while let Some(accepted) = server.accept().await { + let peer = accepted.peer_id.clone(); + let mode = accepted.mode; + let conn = accepted.conn; stats.set_peer_id(peer.clone()); - tracing::info!(peer = ?peer, "accepted authenticated client"); + tracing::info!(peer = ?peer, %mode, "accepted authenticated client"); let routes = Arc::clone(&routes); let tun_ip = first_host(pool); @@ -94,12 +105,15 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { return; } }; - let router = AuraRouter::new(tun, routes, conn.into_dyn()); + let router = AuraRouter::new(tun, routes, conn); if let Err(e) = router.run().await { - tracing::warn!(peer = ?peer, error = %e, "server router stopped"); + tracing::warn!(peer = ?peer, %mode, error = %e, "server router stopped"); } }); } + + tracing::warn!("all transport accept loops stopped; server exiting"); + Ok(()) } /// The first usable host address of a network (network address + 1 for IPv4; the network address diff --git a/crates/aura-cli/tests/loopback.rs b/crates/aura-cli/tests/loopback.rs index 0533117..9914e8b 100644 --- a/crates/aura-cli/tests/loopback.rs +++ b/crates/aura-cli/tests/loopback.rs @@ -1,65 +1,174 @@ -//! CLI-level end-to-end loopback (no TUN): mint certs via [`aura_pki::AuraCa`], build proto -//! Client/Server configs, [`AuraServer::bind`] on `127.0.0.1:0`, [`AuraClient::connect`], and -//! exchange packets via the [`PacketConnection`] API, asserting integrity. +//! CLI-level end-to-end loopback (no TUN, no root): build the CLI's `server.toml` / `client.toml` +//! structs from real TOML, derive the transport wiring through the **CLI config helpers** +//! ([`ServerConfigFile::transport_endpoints`] / [`ClientConfigFile::dial_config`]), bind a real +//! [`aura_transport::MultiServer`], [`aura_transport::dial`] it, and exchange packets over the +//! returned [`PacketConnection`] — asserting integrity and the negotiated transport mode. //! -//! This is the full CLI integration path short of the privileged TUN device: it proves the crate's -//! wiring of aura-pki + aura-proto + aura-transport works end to end without root or external -//! network. +//! This proves the CLI builds correct `Endpoints` / `DialConfig` from config and that the new +//! multi-transport server + dialer connect end to end. It is the full CLI integration path short of +//! the privileged TUN device (which needs root and is therefore exercised only in a live run). +//! +//! UDP-only is used so the test can learn a single free loopback port up front (the custom-UDP +//! backend is single-peer-per-accept in v1, which is exactly one client here). The fallback/handover +//! logic itself is unit-tested in `aura-transport`; here we prove the CLI feeds it correct configs. +use std::path::PathBuf; use std::sync::Arc; +use aura_cli::config::{ClientConfigFile, ServerConfigFile}; use aura_pki::AuraCa; -use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; -use aura_transport::{AuraClient, AuraServer}; +use aura_proto::PacketConnection; +use aura_transport::{dial, MultiServer, TransportMode}; const SERVER_NAME: &str = "localhost"; -const CAMOUFLAGE_SNI: &str = "cdn.example.com"; + +/// A unique temp directory for this test process (no `tempfile` dependency in the workspace). +fn temp_dir(tag: &str) -> PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "aura-cli-test-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// Grab a currently-free UDP port on loopback by binding `:0` and immediately releasing it. On the +/// loopback interface in a test process the window before we rebind it is negligible and +/// deterministic enough for CI. +fn free_udp_port() -> u16 { + let sock = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind ephemeral udp"); + sock.local_addr().expect("local_addr").port() + // `sock` drops here, freeing the port for MultiServer to re-bind. +} #[tokio::test] -async fn cli_loopback_packet_exchange() { - // PKI: CA + server cert (SAN localhost) + client cert. +async fn cli_config_drives_multiserver_and_dial() { + let dir = temp_dir("loopback"); + + // PKI: CA + server cert (SAN localhost) + client cert, written to PEM files the CLI config reads. let ca = AuraCa::generate("Aura CLI Test CA").expect("generate CA"); let server_cert = ca.issue_server_cert(SERVER_NAME).expect("server cert"); let client_cert = ca.issue_client_cert("cli-client").expect("client cert"); - let ca_pem = ca.ca_cert_pem(); - let server_cfg = ServerConfig { - ca_cert_pem: ca_pem.clone(), - server_cert_pem: server_cert.cert_pem.clone(), - server_key_pem: server_cert.key_pem.clone(), - }; - let client_cfg = ClientConfig { - ca_cert_pem: ca_pem.clone(), - client_cert_pem: client_cert.cert_pem.clone(), - client_key_pem: client_cert.key_pem.clone(), - server_name: SERVER_NAME.to_string(), - }; + let ca_path = dir.join("ca.crt"); + let srv_cert_path = dir.join("server.crt"); + let srv_key_path = dir.join("server.key"); + let cli_cert_path = dir.join("client.crt"); + let cli_key_path = dir.join("client.key"); + std::fs::write(&ca_path, ca.ca_cert_pem()).unwrap(); + std::fs::write(&srv_cert_path, &server_cert.cert_pem).unwrap(); + std::fs::write(&srv_key_path, &server_cert.key_pem).unwrap(); + std::fs::write(&cli_cert_path, &client_cert.cert_pem).unwrap(); + std::fs::write(&cli_key_path, &client_cert.key_pem).unwrap(); - // Bind on an OS-assigned loopback port. - let server = AuraServer::bind( - "127.0.0.1:0".parse().unwrap(), - &server_cert.cert_pem, - &server_cert.key_pem, - server_cfg, + // UDP-only on a learned free loopback port. SNI must match the server cert SAN (used as the inner + // handshake server_name) so mutual auth succeeds. + let udp_port = free_udp_port(); + + let server_toml = format!( + r#" +[server] +name = "edge-test" +listen = "127.0.0.1:{udp_port}" + +[pki] +ca_cert = "{ca}" +cert = "{cert}" +key = "{key}" + +[tunnel] +pool_cidr = "10.7.0.0/24" + +[transport] +order = ["udp"] +udp_port = {udp_port} +quic_port = {quic_port} +obfuscate = false +"#, + ca = ca_path.display(), + cert = srv_cert_path.display(), + key = srv_key_path.display(), + quic_port = udp_port + 1, + ); + + let client_toml = format!( + r#" +[client] +name = "laptop-test" +server_addr = "127.0.0.1:{udp_port}" +sni = "{sni}" + +[pki] +ca_cert = "{ca}" +cert = "{cert}" +key = "{key}" + +[tunnel] +local_ip = "10.7.0.2" + +[transport] +order = ["udp"] +udp_port = {udp_port} +quic_port = {quic_port} +obfuscate = false +"#, + sni = SERVER_NAME, + ca = ca_path.display(), + cert = cli_cert_path.display(), + key = cli_key_path.display(), + quic_port = udp_port + 1, + ); + + let server_cfg = ServerConfigFile::parse(&server_toml).expect("parse server.toml"); + let client_cfg = ClientConfigFile::parse(&client_toml).expect("parse client.toml"); + + // Derive the transport wiring through the actual CLI helpers (the thing under test). + let endpoints = server_cfg.transport_endpoints().expect("server endpoints"); + assert_eq!( + endpoints.udp.unwrap().to_string(), + format!("127.0.0.1:{udp_port}") + ); + assert!(endpoints.tcp.is_none() && endpoints.quic.is_none()); + + let server_proto = server_cfg.to_proto().expect("server proto cfg"); + let client_proto = client_cfg.to_proto().expect("client proto cfg"); + let dial_cfg = client_cfg.dial_config().expect("client dial config"); + assert_eq!(dial_cfg.order, vec![TransportMode::Udp]); + + // Bind every enabled transport (just UDP here) via the new MultiServer. + let mut server = MultiServer::bind( + endpoints, + server_proto, + server_cfg.udp_opts(), + server_cfg.tcp_opts(), ) - .expect("bind server"); - let server_addr = server.local_addr().expect("local_addr"); + .await + .expect("bind MultiServer"); - // Accept + connect concurrently. - let accept = tokio::spawn(async move { server.accept().await }); - let connect = - tokio::spawn( - async move { AuraClient::connect(server_addr, CAMOUFLAGE_SNI, client_cfg).await }, - ); + // Accept + dial concurrently. + let accept = tokio::spawn(async move { server.accept().await.map(|a| (a, server)) }); + let connect = tokio::spawn(async move { dial(client_proto, dial_cfg).await }); - let server_conn = accept.await.expect("accept join").expect("accept"); - let client_conn = connect.await.expect("connect join").expect("connect"); + let (accepted, _server_keepalive) = accept + .await + .expect("accept join") + .expect("MultiServer accepted a connection"); + let (client_conn, mode): (Arc, TransportMode) = connect + .await + .expect("connect join") + .expect("dial connected"); - // Mutual auth established the client's verified CN on the server side. - assert_eq!(server_conn.peer_id(), Some("cli-client")); + // The handover picked UDP, and the server verified the client's CN via mutual auth. + assert_eq!(mode, TransportMode::Udp); + assert_eq!(accepted.mode, TransportMode::Udp); + assert_eq!(accepted.peer_id.as_deref(), Some("cli-client")); - let server_conn: Arc = Arc::new(server_conn); - let client_conn: Arc = Arc::new(client_conn); + let server_conn = accepted.conn; // Client -> server. for pkt in [ @@ -78,4 +187,6 @@ async fn cli_loopback_packet_exchange() { let got = client_conn.recv_packet().await.expect("client recv"); assert_eq!(got, pkt); } + + let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/aura-crypto/src/aead.rs b/crates/aura-crypto/src/aead.rs index 3efe47e..d03ebf7 100644 --- a/crates/aura-crypto/src/aead.rs +++ b/crates/aura-crypto/src/aead.rs @@ -173,7 +173,12 @@ impl AeadKey { /// # Errors /// Returns [`CryptoError::AeadDecrypt`] if authentication fails (tampered ciphertext, wrong /// AAD, wrong key, or wrong counter). - pub fn open(&self, counter: u64, ciphertext: &[u8], aad: &[u8]) -> Result, CryptoError> { + pub fn open( + &self, + counter: u64, + ciphertext: &[u8], + aad: &[u8], + ) -> Result, CryptoError> { let nonce = AeadSession::nonce_for(counter); self.cipher() .decrypt( diff --git a/crates/aura-proto/src/lib.rs b/crates/aura-proto/src/lib.rs index bfc443c..5a4b660 100644 --- a/crates/aura-proto/src/lib.rs +++ b/crates/aura-proto/src/lib.rs @@ -49,9 +49,7 @@ pub mod session; pub use conn::PacketConnection; pub use frame::{Frame, MsgType}; pub use handshake::{client_handshake, server_handshake}; -pub use session::{ - DatagramReceiver, DatagramSender, Session, SessionReceiver, SessionSender, -}; +pub use session::{DatagramReceiver, DatagramSender, Session, SessionReceiver, SessionSender}; use thiserror::Error; diff --git a/crates/aura-proto/src/session.rs b/crates/aura-proto/src/session.rs index e961238..94766a1 100644 --- a/crates/aura-proto/src/session.rs +++ b/crates/aura-proto/src/session.rs @@ -404,7 +404,9 @@ impl DatagramReceiver { /// * [`ProtoError::MalformedFrame`] — datagram too short or undecodable frame. pub fn open(&mut self, datagram: &[u8]) -> Result { if datagram.len() < SEQ_LEN { - return Err(ProtoError::MalformedFrame("datagram shorter than seq prefix")); + return Err(ProtoError::MalformedFrame( + "datagram shorter than seq prefix", + )); } let mut seq_be = [0u8; SEQ_LEN]; seq_be.copy_from_slice(&datagram[..SEQ_LEN]); diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs index 3cbf9eb..8933fc1 100644 --- a/crates/aura-transport/src/dial.rs +++ b/crates/aura-transport/src/dial.rs @@ -34,7 +34,9 @@ impl FromStr for TransportMode { "udp" => Ok(Self::Udp), "tcp" => Ok(Self::Tcp), "quic" => Ok(Self::Quic), - other => Err(format!("unknown transport '{other}' (expected udp|tcp|quic)")), + other => Err(format!( + "unknown transport '{other}' (expected udp|tcp|quic)" + )), } } } @@ -117,7 +119,8 @@ pub async fn dial( }; tracing::info!("dial: trying {mode} at {addr}"); let attempt = - tokio::time::timeout(cfg.attempt_timeout, dial_one(*mode, addr, &proto_cfg, &cfg)).await; + tokio::time::timeout(cfg.attempt_timeout, dial_one(*mode, addr, &proto_cfg, &cfg)) + .await; match attempt { Ok(Ok(conn)) => { tracing::info!("dial: connected via {mode}"); diff --git a/crates/aura-transport/src/tcp.rs b/crates/aura-transport/src/tcp.rs index d7e21ff..5d6e998 100644 --- a/crates/aura-transport/src/tcp.rs +++ b/crates/aura-transport/src/tcp.rs @@ -109,7 +109,11 @@ impl PacketConnection for TcpConnection { Frame::Data { payload, .. } => return Ok(payload.to_vec()), Frame::Ping { seq } => { // Separate mutex from the receive lock we hold => no deadlock. - self.sender.lock().await.send_frame(Frame::Pong { seq }).await?; + self.sender + .lock() + .await + .send_frame(Frame::Pong { seq }) + .await?; } Frame::Pong { .. } => continue, Frame::Close { code, reason } => { diff --git a/crates/aura-transport/tests/dial_fallback.rs b/crates/aura-transport/tests/dial_fallback.rs index 5048745..1a954c5 100644 --- a/crates/aura-transport/tests/dial_fallback.rs +++ b/crates/aura-transport/tests/dial_fallback.rs @@ -10,8 +10,12 @@ use aura_transport::{dial, DialConfig, Endpoints, TcpOpts, TransportMode, UdpOpt fn make_configs() -> (ServerConfig, ClientConfig) { let ca = AuraCa::generate("Aura Test CA").expect("generate CA"); - let server = ca.issue_server_cert("localhost").expect("issue server cert"); - let client = ca.issue_client_cert("client-dial").expect("issue client cert"); + let server = ca + .issue_server_cert("localhost") + .expect("issue server cert"); + let client = ca + .issue_client_cert("client-dial") + .expect("issue client cert"); let ca_pem = ca.ca_cert_pem(); ( ServerConfig { @@ -33,8 +37,8 @@ async fn dial_falls_back_from_dead_tcp_to_udp() { let (scfg, ccfg) = make_configs(); // A real UDP server (the working fallback target). - let udp_server = - UdpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, UdpOpts::default()).expect("bind udp"); + let udp_server = UdpServer::bind("127.0.0.1:0".parse().unwrap(), scfg, UdpOpts::default()) + .expect("bind udp"); let udp_addr = udp_server.local_addr().expect("udp addr"); let srv = tokio::spawn(async move { let conn = udp_server.accept().await.expect("server accept"); diff --git a/crates/aura-transport/tests/tcp_loopback.rs b/crates/aura-transport/tests/tcp_loopback.rs index 98edb4b..2370808 100644 --- a/crates/aura-transport/tests/tcp_loopback.rs +++ b/crates/aura-transport/tests/tcp_loopback.rs @@ -8,8 +8,12 @@ use aura_transport::{TcpClient, TcpOpts, TcpServer}; /// Mint a fresh CA + server("localhost") + client("client-tcp") and build the proto configs. fn make_configs() -> (ServerConfig, ClientConfig) { let ca = AuraCa::generate("Aura Test CA").expect("generate CA"); - let server = ca.issue_server_cert("localhost").expect("issue server cert"); - let client = ca.issue_client_cert("client-tcp").expect("issue client cert"); + let server = ca + .issue_server_cert("localhost") + .expect("issue server cert"); + let client = ca + .issue_client_cert("client-tcp") + .expect("issue client cert"); let ca_pem = ca.ca_cert_pem(); let scfg = ServerConfig { ca_cert_pem: ca_pem.clone(), diff --git a/docs/sing-box.md b/docs/sing-box.md new file mode 100644 index 0000000..3e009df --- /dev/null +++ b/docs/sing-box.md @@ -0,0 +1,70 @@ +# Integrating AuraVPN with sing-box (approach note) + +Goal: let a phone client running **sing-box** connect to an Aura server speaking the **AuraVPN** +protocol (Aura's own tunneling — not a third party's). This is a short note on *how*; the wire +protocol it must match is fully specified in [`protocol.md`](protocol.md). + +sing-box is written in Go and has no generic "load an arbitrary external wire protocol" plugin, so +integration means giving sing-box a Go implementation (or a bridge) of the Aura protocol. Three +realistic paths, cheapest first: + +## Option A — Process bridge (fastest, desktop/server) + +Run the existing Rust `aura` client as a local process and expose a local proxy/TUN, then point +sing-box at it: + +- `aura client` already creates a TUN and routes through the Aura tunnel; sing-box can route selected + traffic into that interface, **or** +- add a local **SOCKS5 inbound** to `aura-cli` (small addition) and configure a sing-box `socks` + outbound pointing at `127.0.0.1:`. + +Pros: reuses the audited Rust core verbatim; no crypto re-implementation. Cons: two processes; weak +fit for mobile (sing-box mobile apps embed the core and don't spawn helpers easily). + +## Option B — Native Go outbound/inbound (the real target for phones) + +Implement the AuraVPN protocol natively in Go and register it as a sing-box **outbound** (client) and +**inbound** (server), so the phone's embedded sing-box core speaks AuraVPN directly. This is the +clean, performant, mobile-friendly path. Crypto maps cleanly to existing Go libraries: + +| Aura piece | Rust crate | Go equivalent | +|---|---|---| +| X25519 ECDH | `x25519-dalek` | `crypto/ecdh` (stdlib) | +| ML-KEM-768 (FIPS 203) | `ml-kem` | `crypto/mlkem` (Go 1.24+) or `cloudflare/circl` | +| ChaCha20-Poly1305 | `chacha20poly1305` | `golang.org/x/crypto/chacha20poly1305` | +| HKDF-SHA256 | `hkdf` | `golang.org/x/crypto/hkdf` | +| HMAC-SHA256 (Finished) | `hmac` | `crypto/hmac` + `crypto/sha256` | +| ECDSA P-256 sigs (cert auth) | `ring` | `crypto/ecdsa` + `crypto/x509` | +| X.509 verify + CRL | `rustls-webpki` | `crypto/x509` | + +What the Go code must reproduce **exactly** (see `protocol.md`): +- 5-byte frame header `msg_type(1) || len(u24 BE) || version=0x01`. +- Handshake order CH → SH → ServerAuth → ClientAuth → Finished(c→s) → Finished(s→c); transcript = + `SHA-256(ClientHello_frame || ServerHello_frame)`; ECDSA-P256/SHA-256 signature over the transcript; + HMAC-SHA256 Finished. +- Hybrid shared secret = `x25519_ss || mlkem_ss`; HKDF salt = `client_nonce || server_nonce`, + info = `b"aura-v1-session"`. +- Data record (datagram/UDP) = `seq(8 BE) || ChaCha20Poly1305(frame, aad = seq)`, nonce = + `LE(seq) || 0x00000000`; replay window 64. (Stream/TCP record adds the 5-byte header to the AAD.) +- Transport selection: UDP (type `0x01` HS / `0x02` DATA) primary; TCP/443 and QUIC fallbacks. + +To de-risk the Go port, export **known-answer test vectors** from the Rust side (a captured +handshake transcript + derived keys + a sealed data record) and assert the Go implementation +reproduces them byte-for-byte. The ML-KEM KAT already lives in `aura-crypto/tests/kat_kyber.rs`. + +## Option C — Rust core via cgo (`cdylib`) + +Compile the Aura Rust core to a C-ABI shared library and call it from a thin sing-box Go shim via +cgo. Reuses the audited crypto/handshake with no Go re-implementation, but cgo + per-platform +(Android/iOS) packaging is fiddly and complicates sing-box's pure-Go build. + +## Recommendation + +- **Now:** Option A (process bridge) for desktop/server validation — minimal work, real protocol. +- **For the phone:** Option B (native Go outbound), built against `protocol.md` + exported Rust test + vectors. It is the only option that fits sing-box's embedded mobile core well. +- Keep `protocol.md` the single source of truth and version the wire protocol (the header already + carries `version = 0x01`) so the Rust and Go implementations stay in lockstep. + +> Status: this is a design note. No Go code or sing-box module is implemented yet — that is a +> separate deliverable tracked for after the Rust transport stabilizes.