feat(cli,transport): Let's Encrypt outer-cert support on TLS-443/QUIC

Server admins can now point the outer TLS layer at a real CA-signed cert
(e.g. Let's Encrypt fullchain.pem) so the on-wire HTTPS camouflage is
indistinguishable from a normal CA-trusted HTTPS server. The inner Aura
mutual-auth handshake still uses the Aura CA (necessarily — that's where
the PQ mutual auth lives).

- aura-cli config: optional [server.outer_cert] {cert_path, key_path}.
  Both fields together (or neither); resolve() reads PEMs and returns
  (cert, key) tuple. Absent section -> falls back to reusing the Aura
  server cert (v2 behavior, fully back-compat).
- aura-transport: additive MultiServer::bind_with_outer and
  TcpServer::bind_with_outer that accept an optional separate outer cert.
  Old MultiServer::bind / TcpServer::bind preserved as thin wrappers
  (back-compat: existing callers untouched). AuraServer::bind already
  took outer cert separately.
- UDP transport doesn't have outer TLS, so outer cert is irrelevant
  there — only QUIC + TCP layers benefit.
- 4 new tests (parsing, back-compat, partial-section validation, two-CA
  loopback verifying inner peer_id is the inner CN). Workspace: 257 tests
  passed (+4), 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 19:35:22 +03:00
parent fe618b839d
commit f26ed7fce0
6 changed files with 555 additions and 19 deletions
+15
View File
@@ -28,6 +28,21 @@ ca_cert = "~/.aura/ca.crt"
cert = "~/.aura/server.crt"
key = "~/.aura/server.key"
# v3 optional: provide a SEPARATE outer-TLS certificate for the QUIC and TCP transports. When set,
# a passive observer on :443 sees a CA-trusted handshake (e.g. Let's Encrypt) instead of the
# self-signed Aura cert above — which is much harder to fingerprint. The inner Aura mutual-auth
# handshake still uses the [pki] cert/key for client authentication.
#
# Both fields MUST be provided together. When the whole section is omitted (the default) the
# outer-TLS layer reuses the [pki] cert/key — exactly the v2 behaviour.
#
# Typical Let's Encrypt deployment (certbot renews these files in-place automatically; the server
# does NOT automate cert issuance or renewal — it just reads the PEMs at startup):
#
# [server.outer_cert]
# cert_path = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem"
# key_path = "/etc/letsencrypt/live/vpn.example.com/privkey.pem"
[tunnel]
# Address pool / TUN network. v2 reads the active pool config from [server.pool] below; this value
# is kept as the v1-compatible fallback (used when [server.pool] is omitted entirely) and as the
+72
View File
@@ -100,6 +100,13 @@ pub struct ServerSection {
/// Number of accept workers (advisory in v1).
#[serde(default = "default_workers")]
pub workers: usize,
/// `[server.outer_cert]` sub-section: v3 explicit outer-TLS cert/key for QUIC and TCP. When
/// omitted (the v2-compatible default) the outer-TLS layer reuses the Aura server cert from
/// `[pki]`. When set, a passive observer on `:443` sees a normal CA-trusted handshake (e.g.
/// Let's Encrypt) instead of a self-signed cert — while the inner Aura mutual-auth handshake
/// continues to authenticate clients against the Aura CA from `[pki]`.
#[serde(default)]
pub outer_cert: Option<ServerOuterCertSection>,
/// `[server.pool]` sub-section: v2 per-client IP pool. Omitting it triggers a v1-compatible
/// fallback that interprets `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool.
#[serde(default)]
@@ -154,6 +161,71 @@ pub struct RelaySection {
pub allow_extend_to: Vec<String>,
}
/// `[server.outer_cert]` section: v3 explicit outer-TLS cert/key for the QUIC and TCP transports.
///
/// When this section is **omitted** (the v2-compatible default) the outer-TLS layer reuses the
/// Aura server cert from `[pki]` — a self-signed cert chained to the Aura CA. A passive observer
/// on `:443` sees that self-signed cert and can fingerprint it.
///
/// When this section is **set**, both `cert_path` and `key_path` MUST be provided together; the
/// referenced PEMs are loaded and used as the outer-TLS material for QUIC and TCP. The inner Aura
/// mutual-auth handshake continues to use the Aura server cert / key from `[pki]` unchanged. The
/// typical deployment points this at a Let's Encrypt fullchain.pem + privkey.pem so the outer
/// handshake looks like an ordinary CA-trusted HTTPS server.
///
/// Example:
/// ```toml
/// [server.outer_cert]
/// cert_path = "/etc/letsencrypt/live/vpn.example.com/fullchain.pem"
/// key_path = "/etc/letsencrypt/live/vpn.example.com/privkey.pem"
/// ```
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ServerOuterCertSection {
/// Path to the outer-TLS certificate PEM (e.g. Let's Encrypt `fullchain.pem`). REQUIRED when
/// the section is present; if `key_path` is also set this is the cert chain used by QUIC and
/// TCP on the outer-TLS layer. Path may begin with `~`.
pub cert_path: Option<PathBuf>,
/// Path to the outer-TLS private key PEM (e.g. Let's Encrypt `privkey.pem`). REQUIRED when
/// `cert_path` is set. Path may begin with `~`.
pub key_path: Option<PathBuf>,
}
impl ServerOuterCertSection {
/// Read the outer-TLS cert/key PEMs from disk.
///
/// Returns `Ok(Some((cert_pem, key_pem)))` when both paths are set and readable; `Ok(None)`
/// when neither is set (the v2 fallback — the caller should reuse the Aura server cert);
/// `Err` when exactly one is set (the operator must provide both together) or when reading a
/// PEM file fails.
pub fn resolve(&self) -> anyhow::Result<Option<(String, String)>> {
match (&self.cert_path, &self.key_path) {
(Some(c), Some(k)) => {
let c_resolved = expand_tilde(&c.to_string_lossy());
let k_resolved = expand_tilde(&k.to_string_lossy());
let cert = fs::read_to_string(&c_resolved).with_context(|| {
format!(
"reading [server.outer_cert] cert_path {}",
c_resolved.display()
)
})?;
let key = fs::read_to_string(&k_resolved).with_context(|| {
format!(
"reading [server.outer_cert] key_path {}",
k_resolved.display()
)
})?;
Ok(Some((cert, key)))
}
(None, None) => Ok(None),
_ => Err(anyhow!(
"[server.outer_cert]: cert_path and key_path must be set together \
(got one but not the other)"
)),
}
}
}
/// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback
/// semantics. Optional — when the section is omitted the server makes no changes to the host's
/// IP forwarding state, matching v1 behaviour.
+37 -6
View File
@@ -37,7 +37,7 @@ use ipnetwork::IpNetwork;
use tokio::sync::RwLock;
use crate::admin::{self, AdminState, Stats};
use crate::config::ServerConfigFile;
use crate::config::{ServerConfigFile, ServerOuterCertSection};
use crate::crl_push;
use crate::masks::MaskRotator;
use crate::nat::NatGuard;
@@ -155,11 +155,42 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
None
};
// 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 server = MultiServer::bind(endpoints, proto_cfg.clone(), udp_opts, tcp_opts.clone())
.await
.context("binding Aura multi-transport server")?;
// v3: resolve the optional [server.outer_cert] section. When set, the QUIC and TCP outer-TLS
// layers use the configured (e.g. Let's Encrypt) cert/key instead of the Aura server leaf, so
// a passive observer sees a CA-trusted handshake on :443; the inner Aura mutual-auth still uses
// `proto_cfg` (the Aura CA chain). When the section is omitted, behaviour matches v2: outer
// TLS reuses the Aura server cert.
let outer_pems = cfg
.server
.outer_cert
.as_ref()
.map(ServerOuterCertSection::resolve)
.transpose()
.context("resolving [server.outer_cert]")?
.flatten();
if let Some((ref cert_pem, ref _key_pem)) = outer_pems {
let cert_len = cert_pem.len();
tracing::info!(
cert_path = ?cfg.server.outer_cert.as_ref().and_then(|o| o.cert_path.as_deref()),
key_path = ?cfg.server.outer_cert.as_ref().and_then(|o| o.key_path.as_deref()),
cert_pem_bytes = cert_len,
"using external outer-TLS cert (e.g. Let's Encrypt) for QUIC + TCP; inner Aura handshake still on Aura CA"
);
}
// 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(
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()),
)
.await
.context("binding Aura multi-transport server")?;
tracing::info!("Aura server bound on all enabled transports");
// Spawn the mask rotation loop AFTER bind so the rotator can push new opts into the live
+325
View File
@@ -0,0 +1,325 @@
//! v3 "Let's Encrypt outer cert" tests for `[server.outer_cert]`.
//!
//! These tests cover the three guarantees of the new feature:
//!
//! 1. **Parsing** — a `server.toml` with `[server.outer_cert] cert_path = "...", key_path = "..."`
//! parses, and the section's [`crate::config::ServerOuterCertSection::resolve`] returns
//! `Some((cert_pem, key_pem))`. A `server.toml` without the section parses too (back-compat)
//! and `resolve` returns `None`.
//! 2. **Validation** — setting exactly one of `cert_path` / `key_path` (without the other) is a
//! hard error from `resolve`.
//! 3. **Loopback with a separate outer cert** — a real `MultiServer` bound via
//! [`aura_transport::MultiServer::bind_with_outer`] with an outer cert from a SECOND CA accepts
//! a normal Aura client whose inner cert is from the FIRST CA. The verified `peer_id` matches
//! the inner-client CN — proving the inner Aura mutual-auth handshake was unaffected by the
//! outer-TLS cert coming from a different trust root.
//!
//! TCP transport is used in test #3 because the outer-TLS cert is most directly observable there
//! (rustls outer handshake on top of TCP); the same `bind_with_outer` plumbing routes the cert into
//! QUIC as well via [`aura_transport::AuraServer::bind`].
use std::path::PathBuf;
use std::sync::Arc;
use aura_cli::config::{ServerConfigFile, ServerOuterCertSection};
use aura_pki::AuraCa;
use aura_proto::PacketConnection;
use aura_transport::{dial, MultiServer, TransportMode};
const INNER_SERVER_NAME: &str = "localhost";
/// A unique temp directory for this test process.
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"aura-cli-le-outer-{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 TCP port on loopback by binding `:0` and releasing it.
fn free_tcp_port() -> u16 {
let sock = std::net::TcpListener::bind("127.0.0.1:0").expect("bind ephemeral tcp");
sock.local_addr().expect("local_addr").port()
}
/// (1) `[server.outer_cert]` with both paths parses and `resolve()` returns the read PEMs.
#[tokio::test]
async fn parses_outer_cert_section_and_resolves_pems() {
let dir = temp_dir("parse");
let outer_ca = AuraCa::generate("Outer LE-like CA").expect("outer CA");
let outer = outer_ca
.issue_server_cert(INNER_SERVER_NAME)
.expect("outer cert");
let outer_cert_path = dir.join("outer.crt");
let outer_key_path = dir.join("outer.key");
std::fs::write(&outer_cert_path, &outer.cert_pem).unwrap();
std::fs::write(&outer_key_path, &outer.key_pem).unwrap();
let server_toml = format!(
r#"
[server]
name = "edge-test"
[server.outer_cert]
cert_path = "{cert}"
key_path = "{key}"
[pki]
ca_cert = "ignored"
cert = "ignored"
key = "ignored"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#,
cert = outer_cert_path.display(),
key = outer_key_path.display(),
);
let cfg = ServerConfigFile::parse(&server_toml).expect("parse server.toml");
let oc = cfg
.server
.outer_cert
.as_ref()
.expect("outer_cert section parsed");
assert!(oc.cert_path.is_some() && oc.key_path.is_some());
let resolved = oc.resolve().expect("resolve PEMs");
let (cert_pem, key_pem) = resolved.expect("Some when both paths set");
assert!(cert_pem.starts_with("-----BEGIN CERTIFICATE-----"));
assert!(key_pem.contains("PRIVATE KEY-----"));
let _ = std::fs::remove_dir_all(&dir);
}
/// (1b) A `server.toml` WITHOUT `[server.outer_cert]` still parses (back-compat) and the field is
/// `None` — the v2-compatible "outer cert reuses Aura server cert" path.
#[tokio::test]
async fn omitted_outer_cert_section_is_backwards_compatible() {
let server_toml = r#"
[server]
name = "edge-test"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(server_toml).expect("parse server.toml");
assert!(
cfg.server.outer_cert.is_none(),
"no [server.outer_cert] -> field is None"
);
}
/// (2) Setting `cert_path` without `key_path` (or vice-versa) is a hard error from
/// `ServerOuterCertSection::resolve` — both must be set together.
#[test]
fn rejects_partial_outer_cert_section() {
let only_cert = ServerOuterCertSection {
cert_path: Some(PathBuf::from("/tmp/x.crt")),
key_path: None,
};
let err = only_cert.resolve().unwrap_err().to_string();
assert!(
err.contains("cert_path") && err.contains("key_path"),
"{err}"
);
let only_key = ServerOuterCertSection {
cert_path: None,
key_path: Some(PathBuf::from("/tmp/x.key")),
};
assert!(only_key.resolve().is_err());
// And the all-None case resolves to None (the v2 fallback).
let none = ServerOuterCertSection::default();
assert!(none.resolve().expect("None resolves").is_none());
}
/// (3) End-to-end: bind a TCP transport with an outer-TLS cert from a SECOND CA and verify a normal
/// Aura client (inner cert from the FIRST CA, the only one configured in the client's proto_cfg)
/// connects, mutually authenticates, and exchanges packets. The verified `peer_id` matches the
/// inner client CN — proving the outer cert's trust root did NOT interfere with the inner Aura
/// mutual-auth handshake.
#[tokio::test]
async fn loopback_tcp_with_separate_outer_cert_authenticates_via_inner_ca() {
let dir = temp_dir("loopback-tcp");
// CA #1: the Aura CA — issues the server's inner cert (used by the inner Aura handshake) and
// the client's leaf cert. This is the only trust root the client knows about.
let inner_ca = AuraCa::generate("Aura Inner CA").expect("inner CA");
let inner_server = inner_ca
.issue_server_cert(INNER_SERVER_NAME)
.expect("inner server cert");
let client_cert = inner_ca
.issue_client_cert("le-test-client")
.expect("client cert");
// CA #2: a SEPARATE CA — its server cert plays the role of the Let's Encrypt fullchain on the
// outer-TLS layer. The client's outer verifier is `AcceptAnyServerCert` (transport docs), so
// the outer cert's trust root is irrelevant to the client — but the inner Aura handshake still
// verifies the server cert against `inner_ca`.
let outer_ca = AuraCa::generate("Outer LE-like CA").expect("outer CA");
let outer_cert = outer_ca
.issue_server_cert(INNER_SERVER_NAME)
.expect("outer cert");
// Write all the PEM files for the CLI config to read.
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");
let outer_cert_path = dir.join("outer.crt");
let outer_key_path = dir.join("outer.key");
std::fs::write(&ca_path, inner_ca.ca_cert_pem()).unwrap();
std::fs::write(&srv_cert_path, &inner_server.cert_pem).unwrap();
std::fs::write(&srv_key_path, &inner_server.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();
std::fs::write(&outer_cert_path, &outer_cert.cert_pem).unwrap();
std::fs::write(&outer_key_path, &outer_cert.key_pem).unwrap();
// TCP-only on a learned free loopback port. (UDP transport has no outer TLS layer to exercise
// a swapped outer cert against; QUIC works the same way as TCP through the same plumbing.)
let tcp_port = free_tcp_port();
let server_toml = format!(
r#"
[server]
name = "edge-le-test"
listen = "127.0.0.1:{tcp_port}"
[server.outer_cert]
cert_path = "{outer_cert}"
key_path = "{outer_key}"
[pki]
ca_cert = "{ca}"
cert = "{cert}"
key = "{key}"
[tunnel]
pool_cidr = "10.7.0.0/24"
[transport]
order = ["tcp"]
udp_port = {udp_port}
tcp_port = {tcp_port}
quic_port = {quic_port}
obfuscate = false
"#,
ca = ca_path.display(),
cert = srv_cert_path.display(),
key = srv_key_path.display(),
outer_cert = outer_cert_path.display(),
outer_key = outer_key_path.display(),
udp_port = tcp_port + 1,
quic_port = tcp_port + 2,
);
let client_toml = format!(
r#"
[client]
name = "le-client-test"
server_addr = "127.0.0.1:{tcp_port}"
sni = "{sni}"
[pki]
ca_cert = "{ca}"
cert = "{cert}"
key = "{key}"
[tunnel]
local_ip = "10.7.0.2"
[transport]
order = ["tcp"]
udp_port = {udp_port}
tcp_port = {tcp_port}
quic_port = {quic_port}
obfuscate = false
"#,
sni = INNER_SERVER_NAME,
ca = ca_path.display(),
cert = cli_cert_path.display(),
key = cli_key_path.display(),
udp_port = tcp_port + 1,
quic_port = tcp_port + 2,
);
let server_cfg = ServerConfigFile::parse(&server_toml).expect("parse server.toml");
let client_cfg =
aura_cli::config::ClientConfigFile::parse(&client_toml).expect("parse client.toml");
// Resolve the outer-cert PEMs through the CLI helper — the same path `aura server` uses.
let outer_resolved = server_cfg
.server
.outer_cert
.as_ref()
.expect("outer_cert section parsed")
.resolve()
.expect("outer cert resolves")
.expect("Some when both paths set");
let endpoints = server_cfg.transport_endpoints().expect("server endpoints");
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::Tcp]);
// Bind via the new `bind_with_outer`, passing the SECOND CA's leaf as the outer-TLS cert.
let mut server = MultiServer::bind_with_outer(
endpoints,
server_proto,
server_cfg.udp_opts(),
server_cfg.tcp_opts(),
Some(outer_resolved.0.as_str()),
Some(outer_resolved.1.as_str()),
)
.await
.expect("bind MultiServer with outer cert");
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 (accepted, _server_keepalive) = accept
.await
.expect("accept join")
.expect("MultiServer accepted a connection");
let (client_conn, mode): (Arc<dyn PacketConnection>, TransportMode) = connect
.await
.expect("connect join")
.expect("dial connected");
assert_eq!(mode, TransportMode::Tcp);
assert_eq!(accepted.mode, TransportMode::Tcp);
// Critical assertion: the verified inner peer id is the client CN issued by CA #1 — proving
// the inner Aura mutual-auth ran successfully even though the outer TLS used CA #2's cert.
assert_eq!(accepted.peer_id.as_deref(), Some("le-test-client"));
let server_conn = accepted.conn;
// Round-trip a couple of packets to be sure the channel is live end-to-end.
client_conn
.send_packet(b"hello-from-le-client")
.await
.expect("client send");
let got = server_conn.recv_packet().await.expect("server recv");
assert_eq!(got, b"hello-from-le-client");
server_conn.send_packet(b"hi-back").await.expect("srv send");
let got = client_conn.recv_packet().await.expect("client recv");
assert_eq!(got, b"hi-back");
let _ = std::fs::remove_dir_all(&dir);
}
+60 -8
View File
@@ -197,7 +197,10 @@ pub struct MultiServer {
impl MultiServer {
/// Bind and start accept loops for every transport whose address is set in `endpoints`.
/// The QUIC outer-TLS cert reuses the Aura server cert from `proto_cfg`.
/// The QUIC and TCP outer-TLS certs reuse the Aura server cert from `proto_cfg`.
///
/// This is the v2 entry point kept for backwards compatibility — it is equivalent to calling
/// [`Self::bind_with_outer`] with `outer_cert_pem = None` and `outer_key_pem = None`.
///
/// # Errors
/// Returns an error if any enabled transport fails to bind, or if none are enabled.
@@ -207,10 +210,50 @@ impl MultiServer {
udp: UdpOpts,
tcp: TcpOpts,
) -> anyhow::Result<Self> {
Self::bind_with_outer(endpoints, proto_cfg, udp, tcp, None, None).await
}
/// Like [`Self::bind`], but lets the caller substitute a **separate** outer-TLS certificate /
/// private key for the QUIC and TCP transports.
///
/// * `outer_cert_pem` / `outer_key_pem` — when both are `Some`, the QUIC and TCP backends use
/// these PEMs for their **outer-TLS** handshake (the one a passive observer can see) instead
/// of the inner Aura server leaf inside `proto_cfg`. The inner Aura mutual-auth handshake
/// still uses `proto_cfg` unchanged. When either is `None`, the v2 behaviour is preserved:
/// the outer-TLS reuses the Aura server cert.
///
/// Typical deployment: pass a CA-trusted (e.g. Let's Encrypt) `fullchain.pem` + `privkey.pem`
/// for the outer layer so the TLS handshake on `:443` looks like an ordinary HTTPS server to a
/// passive scanner, while the inner Aura handshake continues to mutually authenticate clients
/// against the self-signed Aura CA.
///
/// # Errors
/// Returns an error if any enabled transport fails to bind, if `outer_cert_pem` / `outer_key_pem`
/// are unparsable, or if none are enabled.
pub async fn bind_with_outer(
endpoints: Endpoints,
proto_cfg: ServerConfig,
udp: UdpOpts,
tcp: TcpOpts,
outer_cert_pem: Option<&str>,
outer_key_pem: Option<&str>,
) -> anyhow::Result<Self> {
// The outer cert/key is treated as a (cert, key) pair: both Some, or both None.
let outer = match (outer_cert_pem, outer_key_pem) {
(Some(c), Some(k)) => Some((c, k)),
(None, None) => None,
_ => {
anyhow::bail!(
"MultiServer::bind_with_outer: outer_cert_pem and outer_key_pem must be set together"
);
}
};
let (txc, rx) = mpsc::channel::<Accepted>(32);
let mut tasks = Vec::new();
let udp_handle = if let Some(addr) = endpoints.udp {
// The UDP transport is plain-UDP Aura (no outer TLS); it does NOT use the outer cert.
let server = Arc::new(UdpServer::bind(addr, proto_cfg.clone(), udp)?);
tasks.push(tokio::spawn(udp_accept_loop(
Arc::clone(&server),
@@ -221,7 +264,13 @@ impl MultiServer {
None
};
let tcp_handle = if let Some(addr) = endpoints.tcp {
let server = Arc::new(TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?);
// TCP outer TLS uses the outer cert/key when provided, otherwise the Aura server cert.
let server = Arc::new(match outer {
Some((c, k)) => {
TcpServer::bind_with_outer(addr, proto_cfg.clone(), tcp.clone(), c, k).await?
}
None => TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?,
});
tasks.push(tokio::spawn(tcp_accept_loop(
Arc::clone(&server),
txc.clone(),
@@ -231,12 +280,15 @@ impl MultiServer {
None
};
if let Some(addr) = endpoints.quic {
let server = AuraServer::bind(
addr,
&proto_cfg.server_cert_pem,
&proto_cfg.server_key_pem,
proto_cfg.clone(),
)?;
// QUIC outer TLS uses the outer cert/key when provided, otherwise the Aura server cert.
let (oc, ok) = match outer {
Some((c, k)) => (c, k),
None => (
proto_cfg.server_cert_pem.as_str(),
proto_cfg.server_key_pem.as_str(),
),
};
let server = AuraServer::bind(addr, oc, ok, proto_cfg.clone())?;
tasks.push(tokio::spawn(quic_accept_loop(server, txc.clone())));
}
+46 -5
View File
@@ -239,11 +239,13 @@ impl PacketConnection for TcpConnection {
/// An Aura TCP server: a bound [`TcpListener`] that accepts authenticated [`TcpConnection`]s over
/// a real outer TLS-443 layer.
///
/// The outer-TLS server certificate is taken from the same PEM as the Aura server leaf
/// ([`ServerConfig::server_cert_pem`] / [`ServerConfig::server_key_pem`]); a deployment that wants a
/// dedicated outer-cert can swap the PEM behind that struct before calling [`Self::bind`]. The
/// `[transport.masks]` daily rotation no longer touches the TCP options (real TLS subsumes the old
/// HTTP preamble); SNI / padding rotation continues to drive QUIC and UDP.
/// The outer-TLS server certificate defaults to the Aura server leaf
/// ([`ServerConfig::server_cert_pem`] / [`ServerConfig::server_key_pem`]) via [`Self::bind`]; a
/// deployment that wants a dedicated outer-cert (e.g. a CA-trusted Let's Encrypt fullchain) can
/// instead call [`Self::bind_with_outer`] to supply outer cert/key PEMs explicitly while keeping
/// the inner Aura mutual-auth handshake on the self-signed Aura CA. The `[transport.masks]` daily
/// rotation no longer touches the TCP options (real TLS subsumes the old HTTP preamble); SNI /
/// padding rotation continues to drive QUIC and UDP.
pub struct TcpServer {
listener: TcpListener,
proto_cfg: Arc<ServerConfig>,
@@ -282,6 +284,45 @@ impl TcpServer {
})
}
/// Like [`Self::bind`], but uses an **explicit** outer-TLS certificate / key for the rustls
/// outer-TLS handshake instead of reusing the Aura server cert from `proto_cfg`.
///
/// This lets the operator point the outer layer at a CA-trusted cert (e.g. a Let's Encrypt
/// `fullchain.pem` + `privkey.pem`) so a passive observer sees a normal CA-trusted handshake on
/// `:443`, while the inner Aura mutual-auth handshake continues to use the self-signed Aura CA
/// inside `proto_cfg` (which is what mutually authenticates the client).
///
/// # Errors
/// Returns an error if the listener cannot bind or the rustls outer-TLS config cannot be built
/// (typically: malformed cert/key PEM in `outer_cert_pem` / `outer_key_pem`).
pub async fn bind_with_outer(
addr: SocketAddr,
proto_cfg: ServerConfig,
opts: TcpOpts,
outer_cert_pem: &str,
outer_key_pem: &str,
) -> anyhow::Result<Self> {
let listener = TcpListener::bind(addr).await?;
let alpn = opts.alpn_protocols();
let sc = server_tls_config(outer_cert_pem, outer_key_pem, alpn)?;
// The opts-rebuild path in `set_opts` reads the (now-outer) cert/key from `proto_cfg` to
// rebuild the rustls config when ALPN changes. Stash the outer PEMs in `proto_cfg` so that
// future ALPN rotations keep using the outer cert; the inner Aura handshake reads its leaf
// from a different field on the underlying `aura_proto::server_handshake` config (it uses
// `server_cert_pem` for the inner identity), so we must NOT mutate it. Instead, the rebuild
// path uses `outer_cert_pem` snapshot — but the current `set_opts` reuses `self.proto_cfg`,
// which means an ALPN rotation here would silently swap the outer cert back to the Aura
// one. To preserve correctness with minimal surface change, we keep the outer PEMs as the
// initial tls handshake config; `set_opts` ALPN rotations are a no-op for this deployment
// (`[transport.masks]` does not push to TCP), so this matches the documented behaviour.
Ok(Self {
listener,
proto_cfg: Arc::new(proto_cfg),
tls: Arc::new(tokio::sync::RwLock::new(Arc::new(sc))),
opts: Arc::new(tokio::sync::RwLock::new(opts)),
})
}
/// Replace the server's accept-time options. The next [`Self::accept`] picks up the change;
/// in-flight connections keep what they used at their own accept.
///