feat(transport): real TLS-443 on the TCP backend (replaces HTTP/1.1 masquerade)

The TCP fallback now does a full outer TLS handshake (tokio-rustls 0.26 over
rustls 0.23, ring provider) before the Aura proto handshake, exactly like the
QUIC backend: on the wire it is indistinguishable from genuine HTTPS until the
inner Aura mutual-auth handshake starts. Removes v1's "light HTTP masquerade"
limitation; the real security boundary remains the inner PQ handshake.

- aura-transport::tcp: dropped the HTTP/1.1 preamble helpers and TcpOpts
  fields (masquerade, host, user_agent, server_header). New flow:
  TlsAcceptor::accept (server) / TlsConnector::connect (client) →
  tokio::io::split(TlsStream) → server_handshake / client_handshake → Session.
  Client reuses crate::quic::AcceptAnyServerCert (outer SNI not authenticated;
  inner handshake is the security boundary). Outer server cert auto-sourced
  from proto_cfg.server_cert_pem (no API change for the CLI's bind).
- ALPN default: ["h2", "http/1.1"] (DEFAULT_TCP_ALPN, exported).
- TcpOpts: now just { alpn: Option<Vec<Vec<u8>>> }.
- TcpClient::connect gains an outer-SNI &str param; DialConfig.sni passes it
  through (separate from the inner proto_cfg.server_name).
- tokio-rustls 0.26 added as a transport-local dependency (not workspace).

CLI updates: removed dead host/user_agent/server_header wiring; mask rotation
no longer touches TCP outer parameters (TLS doesn't have a Host header on
the wire). [transport] masquerade kept as a no-op for back-compat with old
configs (documented).

3 new tcp_loopback tests (default ALPN end-to-end, custom ALPN, outer SNI
mismatch still connects = proves accept-any is in effect). Workspace: 142
tests passed (+1), clippy -D warnings clean, fmt clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 01:53:02 +03:00
parent 75e350e870
commit 821f7711e7
10 changed files with 348 additions and 225 deletions
+6 -5
View File
@@ -5,8 +5,9 @@
//! 2. Build a shared [`RouteTable`] from `[tunnel.split]` (default action + direct/vpn CIDR rules);
//! record domain rules for resolution.
//! 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`.
//! UDP→TCP→QUIC "handover") until one connects; both QUIC and TCP present `[client] sni` as
//! their outer-TLS SNI (the TCP backend wraps the connection in a real TLS-443 handshake too;
//! see [`aura_transport::TcpClient::connect`]).
//! 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.
@@ -47,10 +48,10 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?);
let initial = rot.current().await;
dial_cfg.sni = initial.sni.clone();
dial_cfg.tcp.host = initial.http_host.clone();
dial_cfg.tcp.user_agent = initial.user_agent.clone();
dial_cfg.tcp.server_header = initial.server_header.clone();
dial_cfg.udp.padding_profile = initial.padding_profile_id;
// The TCP transport now wraps in real outer TLS-443 (no HTTP preamble), so the per-mask
// `Host:` / `User-Agent:` / `Server:` strings no longer feed `dial_cfg.tcp`. The outer TLS
// SNI for both TCP and QUIC is `dial_cfg.sni` (above).
tracing::info!(
sni = %initial.sni,
padding_profile = initial.padding_profile_id,
+21 -23
View File
@@ -265,7 +265,9 @@ pub struct TransportSection {
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.
/// **Deprecated, ignored.** The TCP transport used to optionally prepend a minimal HTTP/1.1
/// preamble (a light disguise); in v2 it always uses a real outer TLS-443 handshake (a much
/// stronger camouflage), so this knob has no effect. Kept for backwards-compat config parsing.
pub masquerade: bool,
/// `[transport.masks]`: daily protocol-mask rotation knobs.
pub masks: MasksSection,
@@ -542,17 +544,14 @@ impl ServerConfigFile {
}
}
/// Build the [`TcpOpts`] for the server's TCP transport from `[transport] masquerade`; the
/// masquerade `Host` reuses the mimicry SNI when one is configured.
/// Build the [`TcpOpts`] for the server's TCP transport.
///
/// In v2 the TCP backend always uses a real outer TLS-443 layer, so there are no per-config
/// knobs here (ALPN keeps its `[h2, http/1.1]` default). The legacy `[transport] masquerade` /
/// `[mimicry] sni` values are still parsed for backwards compatibility but are no longer plumbed
/// into [`TcpOpts`].
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
TcpOpts::default()
}
}
@@ -588,9 +587,10 @@ impl ClientConfigFile {
/// 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`.
/// uses its own port from `[transport]`). `order` becomes the fallback order. Per-transport
/// options: UDP gets `obfuscate` from `[transport]`; TCP/QUIC both use `[client] sni` as their
/// outer-TLS camouflage SNI (TLS is now real on the TCP side too, see
/// [`aura_transport::TcpClient::connect`]).
pub fn dial_config(&self) -> anyhow::Result<DialConfig> {
let ip = self.server_socket_addr()?.ip();
let order = self.transport.modes()?;
@@ -611,11 +611,7 @@ impl ClientConfigFile {
obfuscate: self.transport.obfuscate,
..UdpOpts::default()
},
tcp: TcpOpts {
masquerade: self.transport.masquerade,
host: self.client.sni.clone(),
..TcpOpts::default()
},
tcp: TcpOpts::default(),
attempt_timeout: Duration::from_secs(8),
})
}
@@ -787,9 +783,10 @@ masquerade = true
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);
// TCP options are now ALPN-only (real outer TLS handles the camouflage); the legacy
// [transport] masquerade / [mimicry] sni values are parsed but no longer plumbed into TcpOpts.
let tcp = cfg.tcp_opts();
assert!(tcp.masquerade);
assert_eq!(tcp.host, "cdn.example.com"); // reuses mimicry SNI
assert!(tcp.alpn.is_none(), "default ALPN is used");
}
#[test]
@@ -848,8 +845,9 @@ pool_cidr = "10.7.0.0/24"
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");
// TCP is wrapped in real outer TLS now; the legacy HTTP `Host` / masquerade fields are gone.
// The outer TLS SNI is `dial.sni`, asserted above.
assert!(dial.tcp.alpn.is_none(), "default ALPN is used");
}
#[test]
+12 -4
View File
@@ -9,10 +9,18 @@
//! 4. logs the rotation and loops.
//!
//! Each new connection (`UdpServer::accept`, `UdpClient::connect`, `TcpClient::connect`, ...)
//! reads the **current** mask once when constructing its [`UdpOpts`] / [`TcpOpts`] / QUIC SNI, so
//! already-established connections keep their original mask and only fresh connections see the
//! rotation. There is no need to coordinate with the peer: each side independently derived the same
//! set from the CA fingerprint it already trusts.
//! reads the **current** mask once when constructing its [`UdpOpts`] padding profile / QUIC SNI /
//! TCP outer-TLS SNI, so already-established connections keep their original mask and only fresh
//! connections see the rotation. There is no need to coordinate with the peer: each side
//! independently derived the same set from the CA fingerprint it already trusts.
//!
//! v2 note: the [`MaskSet::user_agent`] / [`MaskSet::server_header`] fields and the corresponding
//! palettes ([`USER_AGENT_PALETTE`](aura_crypto::USER_AGENT_PALETTE) /
//! [`SERVER_HEADER_PALETTE`](aura_crypto::SERVER_HEADER_PALETTE)) survive but are no longer plumbed
//! into the TCP transport — the v1 HTTP/1.1 masquerade preamble has been replaced by a real outer
//! TLS-443 handshake (see [`aura_transport::TcpClient::connect`]) which makes those header strings
//! irrelevant. They are kept for a possible future evolution (e.g. mimicking specific origin
//! fingerprints via ALPN rotation or in an in-stream HTTP request inside the TLS tunnel).
//!
//! ## Date arithmetic (no external date crate)
//!
+10 -11
View File
@@ -72,7 +72,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
// Per-transport endpoints (UDP/TCP/QUIC) derived from the listen IP + `[transport]` ports.
let endpoints = cfg.transport_endpoints()?;
let mut udp_opts = cfg.udp_opts();
let mut tcp_opts = cfg.tcp_opts();
let tcp_opts = cfg.tcp_opts();
// Build the daily mask rotator (HKDF over the CA fingerprint + MSK date). When enabled in the
// config, the *initial* mask overrides the static SNI / padding-profile / header values from
@@ -83,9 +83,9 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?);
let initial = rot.current().await;
udp_opts.padding_profile = initial.padding_profile_id;
tcp_opts.host = initial.http_host.clone();
tcp_opts.user_agent = initial.user_agent.clone();
tcp_opts.server_header = initial.server_header.clone();
// The TCP transport now uses a real outer TLS-443 layer, which subsumes the old HTTP
// masquerade preamble — there is no longer a per-mask `Host:` / `User-Agent:` / `Server:`
// header to inject. Mask rotation still drives UDP padding (above) and the QUIC SNI.
tracing::info!(
sni = %initial.sni,
padding_profile = initial.padding_profile_id,
@@ -110,7 +110,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
tcp = ?endpoints.tcp,
quic = ?endpoints.quic,
obfuscate = udp_opts.obfuscate,
masquerade = tcp_opts.masquerade,
tcp_tls = "real outer TLS-443 (h2/http1.1 ALPN)",
"starting Aura server"
);
@@ -129,11 +129,15 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
let server_for_apply = Arc::clone(&server);
let rot_for_apply = Arc::clone(rot);
let base_udp = udp_opts;
let base_tcp = tcp_opts.clone();
tokio::spawn(async move {
// Poll the rotator's handle for a change once a minute, and push it into the live
// MultiServer when it changes. The actual rotation timer lives inside the rotator's
// spawn; this loop is just the "apply to bound sockets" bridge.
//
// Only UDP's padding profile gets pushed: the TCP transport now uses real outer TLS,
// not an HTTP preamble, so the per-mask `Host:` / `User-Agent:` / `Server:` headers no
// longer apply. The QUIC outer SNI is also derived from the mask but is per-connect on
// the client side (the server does not advertise an SNI).
let handle = rot_for_apply.handle();
let mut last = rot_for_apply.current().await;
loop {
@@ -142,13 +146,8 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
if current != last {
let mut new_udp = base_udp;
new_udp.padding_profile = current.padding_profile_id;
let mut new_tcp = base_tcp.clone();
new_tcp.host = current.http_host.clone();
new_tcp.user_agent = current.user_agent.clone();
new_tcp.server_header = current.server_header.clone();
let srv = server_for_apply.lock().await;
srv.set_udp_opts(new_udp).await;
srv.set_tcp_opts(new_tcp).await;
tracing::info!(
sni = %current.sni,
padding_profile = current.padding_profile_id,