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:
@@ -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
|
||||
|
||||
@@ -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,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,9 +155,40 @@ 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())
|
||||
// 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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user