diff --git a/Cargo.lock b/Cargo.lock index f49f334..f4dd70d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,29 @@ dependencies = [ "password-hash", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -65,12 +88,33 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -101,6 +145,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" version = "1.0.2" @@ -116,16 +166,30 @@ dependencies = [ "base64", "hmac", "pbkdf2", + "rustls-pemfile", "serde", "serde_json", "sha2", "subtle", "tokio", + "tokio-rustls", "toml", "tracing", "tracing-subscriber", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "generic-array" version = "0.14.7" @@ -147,6 +211,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -178,6 +253,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -219,7 +304,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -228,7 +313,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -264,6 +349,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -282,13 +373,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -308,6 +405,65 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "serde" version = "1.0.228" @@ -380,6 +536,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "smallvec" version = "1.15.2" @@ -393,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -434,7 +596,7 @@ dependencies = [ "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -448,6 +610,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -562,6 +734,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "valuable" version = "0.1.1" @@ -586,6 +764,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -595,6 +782,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -604,6 +855,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 96527c2..799e8c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,5 @@ subtle = "2" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tokio-rustls = "0.26.4" +rustls-pemfile = "2.2.0" diff --git a/scripts/gen-certs.sh b/scripts/gen-certs.sh new file mode 100755 index 0000000..6091ba8 --- /dev/null +++ b/scripts/gen-certs.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Generate a CA and one node certificate for the gossip mutual-TLS link. +# Point every node's [gossip.tls] at ca.crt, and give each its own node cert/key. +# Usage: scripts/gen-certs.sh [out-dir] [node-name] +set -euo pipefail +DIR="${1:-certs}" +NAME="${2:-fedserv}" +mkdir -p "$DIR" +cd "$DIR" + +if [ ! -f ca.crt ]; then + openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ + -keyout ca.key -out ca.crt -days 3650 -subj "/CN=fedserv-ca" +fi + +openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ + -keyout "$NAME.key" -out "$NAME.csr" -subj "/CN=$NAME" +openssl x509 -req -in "$NAME.csr" -CA ca.crt -CAkey ca.key -CAcreateserial \ + -out "$NAME.crt" -days 3650 \ + -extfile <(printf "subjectAltName=DNS:%s" "$NAME") +rm -f "$NAME.csr" ca.srl + +echo "wrote $DIR/{ca.crt, $NAME.crt, $NAME.key}" diff --git a/src/config.rs b/src/config.rs index 71933a8..f2d7869 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,11 +17,28 @@ pub struct Gossip { pub bind: Option, // Shared secret both nodes must present. pub secret: String, + // Mutual-TLS for the peer link. Absent = plaintext. + #[serde(default)] + pub tls: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Tls { + pub cert: String, // our certificate chain (PEM) + pub key: String, // our private key (PEM) + pub ca: String, // CA bundle that must have signed a peer's certificate (PEM) } #[derive(Debug, Deserialize, Clone)] pub struct Peer { pub addr: String, + // TLS server name to expect from this peer; must match its certificate. + #[serde(default = "default_peer_name")] + pub name: String, +} + +fn default_peer_name() -> String { + "fedserv".to_string() } #[derive(Debug, Deserialize)] diff --git a/src/gossip.rs b/src/gossip.rs index 589b15d..18603a0 100644 --- a/src/gossip.rs +++ b/src/gossip.rs @@ -9,8 +9,11 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, mpsc, Mutex}; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use tokio_rustls::rustls::{self, ClientConfig, RootCertStore, ServerConfig}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; -use crate::config::{Gossip, Peer}; +use crate::config::{Gossip, Peer, Tls}; use crate::engine::db::LogEntry; use crate::engine::Engine; @@ -29,28 +32,43 @@ enum Msg { Entry { entry: LogEntry }, } -// Start the listener (if bound) and a dialer per configured peer. +// Start the listener (if bound) and a dialer per configured peer. When TLS is +// configured, every peer link is mutually authenticated against the CA. pub async fn run(engine: Shared, cfg: Gossip, peers: Vec, origin: String, outbound: Outbound) { + let tls = match cfg.tls.as_ref().map(build_tls).transpose() { + Ok(t) => t, + Err(e) => return tracing::error!(%e, "gossip TLS setup failed"), + }; + let acceptor = tls.as_ref().map(|(a, _)| a.clone()); + let connector = tls.as_ref().map(|(_, c)| c.clone()); if let Some(bind) = cfg.bind.clone() { - tokio::spawn(listen(bind, engine.clone(), cfg.secret.clone(), origin.clone(), outbound.clone())); + tokio::spawn(listen(bind, engine.clone(), cfg.secret.clone(), origin.clone(), outbound.clone(), acceptor)); } for peer in peers { - tokio::spawn(dial(peer.addr, engine.clone(), cfg.secret.clone(), origin.clone(), outbound.clone())); + tokio::spawn(dial(peer, engine.clone(), cfg.secret.clone(), origin.clone(), outbound.clone(), connector.clone())); } } -async fn listen(bind: String, engine: Shared, secret: String, origin: String, outbound: Outbound) { +async fn listen(bind: String, engine: Shared, secret: String, origin: String, outbound: Outbound, acceptor: Option) { let listener = match TcpListener::bind(&bind).await { Ok(l) => l, Err(e) => return tracing::error!(%e, %bind, "gossip bind failed"), }; - tracing::info!(%bind, "gossip listening"); + tracing::info!(%bind, tls = acceptor.is_some(), "gossip listening"); loop { if let Ok((stream, addr)) = listener.accept().await { tracing::info!(%addr, "gossip peer accepted"); - let (engine, secret, origin, outbound) = (engine.clone(), secret.clone(), origin.clone(), outbound.clone()); + let (engine, secret, origin, outbound, acceptor) = + (engine.clone(), secret.clone(), origin.clone(), outbound.clone(), acceptor.clone()); tokio::spawn(async move { - if let Err(e) = session(stream, engine, secret, origin, outbound).await { + let served = match acceptor { + Some(acc) => match acc.accept(stream).await { + Ok(tls) => session(tls, engine, secret, origin, outbound).await, + Err(e) => return tracing::debug!(%e, %addr, "gossip tls accept failed"), + }, + None => session(stream, engine, secret, origin, outbound).await, + }; + if let Err(e) = served { tracing::debug!(%e, "gossip session ended"); } }); @@ -58,21 +76,68 @@ async fn listen(bind: String, engine: Shared, secret: String, origin: String, ou } } -async fn dial(addr: String, engine: Shared, secret: String, origin: String, outbound: Outbound) { +async fn dial(peer: Peer, engine: Shared, secret: String, origin: String, outbound: Outbound, connector: Option) { loop { - match TcpStream::connect(&addr).await { + match TcpStream::connect(&peer.addr).await { Ok(stream) => { - tracing::info!(%addr, "gossip dialed peer"); - if let Err(e) = session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await { - tracing::debug!(%e, %addr, "gossip session ended"); + tracing::info!(addr = %peer.addr, "gossip dialed peer"); + let served = match &connector { + Some(conn) => match ServerName::try_from(peer.name.clone()) { + Ok(name) => match conn.connect(name, stream).await { + Ok(tls) => session(tls, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await, + Err(e) => Err(anyhow::anyhow!("tls connect: {e}")), + }, + Err(e) => Err(anyhow::anyhow!("bad peer TLS name {:?}: {e}", peer.name)), + }, + None => session(stream, engine.clone(), secret.clone(), origin.clone(), outbound.clone()).await, + }; + if let Err(e) = served { + tracing::debug!(%e, addr = %peer.addr, "gossip session ended"); } } - Err(e) => tracing::debug!(%e, %addr, "gossip dial failed"), + Err(e) => tracing::debug!(%e, addr = %peer.addr, "gossip dial failed"), } tokio::time::sleep(REDIAL).await; } } +// Build a mutual-TLS acceptor + connector from PEM files: we present our cert and +// require the peer to present one signed by the configured CA. +fn build_tls(tls: &Tls) -> anyhow::Result<(TlsAcceptor, TlsConnector)> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let certs = load_certs(&tls.cert)?; + let key = load_key(&tls.key)?; + let roots = load_roots(&tls.ca)?; + + let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots.clone())).build()?; + let server = ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(certs.clone(), key.clone_key())?; + let client = ClientConfig::builder() + .with_root_certificates(roots) + .with_client_auth_cert(certs, key)?; + + Ok((TlsAcceptor::from(Arc::new(server)), TlsConnector::from(Arc::new(client)))) +} + +fn load_certs(path: &str) -> anyhow::Result>> { + let data = std::fs::read(path)?; + Ok(rustls_pemfile::certs(&mut &data[..]).collect::, _>>()?) +} + +fn load_key(path: &str) -> anyhow::Result> { + let data = std::fs::read(path)?; + rustls_pemfile::private_key(&mut &data[..])?.ok_or_else(|| anyhow::anyhow!("no private key in {path}")) +} + +fn load_roots(path: &str) -> anyhow::Result { + let mut roots = RootCertStore::empty(); + for cert in load_certs(path)? { + roots.add(cert)?; + } + Ok(roots) +} + // One peer connection: authenticate, then run anti-entropy until it drops. async fn session(stream: S, engine: Shared, secret: String, origin: String, outbound: Outbound) -> anyhow::Result<()> where @@ -262,4 +327,55 @@ mod tests { .await; assert!(!b.lock().await.test_has_account("alice"), "no state should cross a bad handshake"); } + + // Generate a CA + node cert with openssl into a temp dir; None if unavailable. + fn gen_certs() -> Option { + use std::process::Command; + let dir = std::env::temp_dir().join(format!("fedserv-tls-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok()?; + let d = dir.to_str()?.to_string(); + let run = |args: &[&str]| Command::new("openssl").args(args).status().map(|s| s.success()).unwrap_or(false); + let ok = run(&["req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", + "-keyout", &format!("{d}/ca.key"), "-out", &format!("{d}/ca.crt"), "-days", "1", "-subj", "/CN=fedserv-ca"]) + && run(&["req", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", + "-keyout", &format!("{d}/node.key"), "-out", &format!("{d}/node.csr"), "-subj", "/CN=fedserv"]); + if !ok { + return None; + } + std::fs::write(format!("{d}/ext"), "subjectAltName=DNS:fedserv,IP:127.0.0.1\n").ok()?; + run(&["x509", "-req", "-in", &format!("{d}/node.csr"), "-CA", &format!("{d}/ca.crt"), + "-CAkey", &format!("{d}/ca.key"), "-CAcreateserial", "-out", &format!("{d}/node.crt"), + "-days", "1", "-extfile", &format!("{d}/ext")]) + .then_some(d) + } + + // Convergence over a mutually authenticated TLS link. Skips if openssl is absent. + #[tokio::test] + async fn tls_link_converges() { + let Some(d) = gen_certs() else { return }; + let tls = Tls { cert: format!("{d}/node.crt"), key: format!("{d}/node.key"), ca: format!("{d}/ca.crt") }; + let (acceptor, connector) = build_tls(&tls).expect("tls config"); + + let (a, atx) = engine("A", "tls-a"); + let (b, btx) = engine("B", "tls-b"); + a.lock().await.test_register("alice"); + + let (sside, cside) = tokio::io::duplex(64 * 1024); + let name = ServerName::try_from("fedserv").unwrap(); + let (server, client) = tokio::join!(acceptor.accept(sside), connector.connect(name, cside)); + let sa = tokio::spawn(session(server.expect("tls accept"), a.clone(), "s3cret".into(), "A".into(), atx)); + let sb = tokio::spawn(session(client.expect("tls connect"), b.clone(), "s3cret".into(), "B".into(), btx)); + + let mut converged = false; + for _ in 0..100 { + if b.lock().await.test_has_account("alice") { + converged = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + sa.abort(); + sb.abort(); + assert!(converged, "B should converge over the TLS link"); + } }