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
+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.