feat(cli,pki): v3.3 bridge discovery via signed CA manifest
Closes the v3.3 "bridges by hand" honest limitation. Admins now publish a
CA-signed manifest with the current bridge list; clients re-read it from
disk on a timer and merge it with the static [client] bridges. Cuts the
"rotate the bridge list" cycle from "edit every client config" to
"distribute one signed file".
- New aura sign-bridges CLI:
aura sign-bridges --ca /etc/aura/pki \
--bridges "ip1:443,ip2:443" \
--ttl-days 7 \
--out /var/aura/bridges.signed
- Manifest format (single file, text + signature block, same shape as the
in-band CRL):
AURA-BRIDGES-v1
{"version":1,"generated_at":...,"expires_at":...,"bridges":[...]}
--SIGNATURE--
<hex ECDSA-P256/SHA-256 over body>
- aura-pki now exports `sign_ecdsa_p256` / `verify_ecdsa_p256` so CRL and
bridges share ONE signing primitive (no copy-paste). CRL keeps working.
- aura-cli::bridges::BridgeManifest + BridgesDiscoveryWatcher: new
module. encode_signed/load_signed_verified verifies signature + rejects
expired manifests. Watcher spawns a tokio interval that re-reads the
file; on load failure (truncated, expired, bad sig) the previous
snapshot is kept — bridges never collapse to empty.
- New [client.bridges_discovery] {enabled, manifest_path,
refresh_interval_secs}; serde(default) so v3.2 configs keep working.
- Merge strategy: manifest EXTENDS static [client] bridges, dedup by
SocketAddr, static-first ordering. Static remains as fallback.
- 13 new tests (8 lib unit + 4 integration + 1 config). Workspace: 310
tests passed (+13), clippy -D warnings clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! Integration tests for the v3.3 signed-bridges manifest:
|
||||
//!
|
||||
//! * Parses a synthetic `client.toml` with `[client.bridges_discovery]` and asserts the section
|
||||
//! round-trips through the config layer.
|
||||
//! * Drives [`BridgesDiscoveryWatcher`] end-to-end against an on-disk manifest, swaps the file,
|
||||
//! asks the watcher to refresh, and verifies the snapshot picks the new list up while keeping
|
||||
//! the static `[client] bridges` baseline.
|
||||
//!
|
||||
//! Lives next to the existing `cli_bridges.rs` test so the v3.3 watcher coverage stays close to
|
||||
//! the v3.2 static-list test.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use aura_cli::bridges::{BridgeManifest, BridgesDiscoveryWatcher};
|
||||
use aura_cli::config::ClientConfigFile;
|
||||
use aura_pki::AuraCa;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Helper: build a fresh CA on disk and return `(cert_pem, key_pem, cert_path, key_path)`. The
|
||||
/// caller is responsible for cleaning up the files on the temp dir.
|
||||
fn fresh_ca() -> (String, String, PathBuf, PathBuf) {
|
||||
let ca = AuraCa::generate("Aura Test").unwrap();
|
||||
let cert_pem = ca.ca_cert_pem();
|
||||
let cert_path = std::env::temp_dir().join(format!("aura-bridges-it-{}-ca.crt", Uuid::new_v4()));
|
||||
let key_path = std::env::temp_dir().join(format!("aura-bridges-it-{}-ca.key", Uuid::new_v4()));
|
||||
ca.save(&cert_path, &key_path).unwrap();
|
||||
let key_pem = std::fs::read_to_string(&key_path).unwrap();
|
||||
(cert_pem, key_pem, cert_path, key_path)
|
||||
}
|
||||
|
||||
const CLIENT_TOML_WITH_DISCOVERY: &str = r#"
|
||||
[client]
|
||||
name = "laptop"
|
||||
server_addr = "203.0.113.10:443"
|
||||
sni = "vpn.example.com"
|
||||
bridges = ["203.0.113.11:443"]
|
||||
|
||||
[client.bridges_discovery]
|
||||
enabled = true
|
||||
manifest_path = "/tmp/aura-bridges-it.signed"
|
||||
refresh_interval_secs = 200
|
||||
|
||||
[pki]
|
||||
ca_cert = "ca.crt"
|
||||
cert = "client.crt"
|
||||
key = "client.key"
|
||||
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_bridges_discovery_section() {
|
||||
let cfg = ClientConfigFile::parse(CLIENT_TOML_WITH_DISCOVERY).expect("parse");
|
||||
assert!(cfg.client.bridges_discovery.enabled);
|
||||
assert_eq!(
|
||||
cfg.client.bridges_discovery.manifest_path.to_string_lossy(),
|
||||
"/tmp/aura-bridges-it.signed"
|
||||
);
|
||||
assert_eq!(cfg.client.bridges_discovery.refresh_interval_secs, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridges_discovery_section_optional() {
|
||||
let minimal = r#"
|
||||
[client]
|
||||
name = "x"
|
||||
server_addr = "1.2.3.4:443"
|
||||
sni = "vpn.example.com"
|
||||
|
||||
[pki]
|
||||
ca_cert = "a"
|
||||
cert = "b"
|
||||
key = "c"
|
||||
|
||||
[tunnel]
|
||||
local_ip = "10.7.0.2"
|
||||
"#;
|
||||
let cfg = ClientConfigFile::parse(minimal).expect("parse minimal");
|
||||
assert!(
|
||||
!cfg.client.bridges_discovery.enabled,
|
||||
"default is enabled = false (back-compat)"
|
||||
);
|
||||
assert_eq!(cfg.client.bridges_discovery.refresh_interval_secs, 3600);
|
||||
}
|
||||
|
||||
/// End-to-end watcher path: sign a manifest with one CA, hand it to the watcher with a static
|
||||
/// bridges baseline, then rewrite the file with a different list and ensure `refresh_once` picks
|
||||
/// it up. The merged snapshot must always contain the static baseline.
|
||||
#[tokio::test]
|
||||
async fn watcher_picks_up_file_replacement() {
|
||||
let (cert_pem, key_pem, cert_path, key_path) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-it-{}.signed", Uuid::new_v4()));
|
||||
let statics: Vec<std::net::SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
// Generation 1: one extra bridge in the manifest.
|
||||
let gen1 = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen1.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
let watcher = BridgesDiscoveryWatcher::new(
|
||||
manifest_path.clone(),
|
||||
cert_pem.clone(),
|
||||
// No background timer — drive refresh manually so the test is deterministic.
|
||||
0,
|
||||
statics.clone(),
|
||||
)
|
||||
.await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "static + one from manifest");
|
||||
assert!(
|
||||
snap.iter().any(|sa| sa.to_string() == "198.51.100.20:443"),
|
||||
"manifest bridge present"
|
||||
);
|
||||
|
||||
// Generation 2: two bridges, one of them duplicating the static baseline.
|
||||
let gen2 = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"203.0.113.10:443".to_string(), // dup of static
|
||||
"192.0.2.5:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen2.save_signed(&manifest_path, &key_pem).expect("save2");
|
||||
watcher.refresh_once().await;
|
||||
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 2, "dedup: static + one new");
|
||||
assert_eq!(snap[0].to_string(), "203.0.113.10:443");
|
||||
assert_eq!(snap[1].to_string(), "192.0.2.5:443");
|
||||
|
||||
// Clean up.
|
||||
let _ = std::fs::remove_file(&manifest_path);
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
}
|
||||
|
||||
/// Sanity check: a `spawn_refresh` with a non-zero interval picks up a file replacement
|
||||
/// asynchronously. The interval here is 200 ms so the test is fast.
|
||||
#[tokio::test]
|
||||
async fn watcher_background_refresh_picks_up_change() {
|
||||
let (cert_pem, key_pem, cert_path, key_path) = fresh_ca();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("aura-bridges-it-bg-{}.signed", Uuid::new_v4()));
|
||||
let statics: Vec<std::net::SocketAddr> = vec!["203.0.113.10:443".parse().unwrap()];
|
||||
|
||||
let gen1 = BridgeManifest::with_ttl(
|
||||
vec!["198.51.100.20:443".to_string()],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen1.save_signed(&manifest_path, &key_pem).expect("save");
|
||||
|
||||
// Use a background refresher with a 1 s tick. The initial load (in `new`) already pulled
|
||||
// generation 1 in synchronously, so we only need to wait for the *next* tick after we drop a
|
||||
// new manifest into place.
|
||||
let watcher =
|
||||
BridgesDiscoveryWatcher::new(manifest_path.clone(), cert_pem.clone(), 1, statics.clone())
|
||||
.await;
|
||||
let _bg = watcher.spawn_refresh().expect("background task");
|
||||
assert_eq!(watcher.current().await.len(), 2, "static + gen1");
|
||||
|
||||
// Swap to a manifest with three new bridges. The first tick the background loop runs (after
|
||||
// the discard-first-tick) must observe the new file.
|
||||
let gen2 = BridgeManifest::with_ttl(
|
||||
vec![
|
||||
"192.0.2.5:443".to_string(),
|
||||
"192.0.2.6:443".to_string(),
|
||||
"192.0.2.7:443".to_string(),
|
||||
],
|
||||
Duration::from_secs(3600),
|
||||
);
|
||||
gen2.save_signed(&manifest_path, &key_pem).expect("save2");
|
||||
|
||||
// The background task ticks once per second; allow some slack on slow CI.
|
||||
tokio::time::sleep(Duration::from_millis(2500)).await;
|
||||
let snap = watcher.current().await;
|
||||
assert_eq!(snap.len(), 4, "static + three new");
|
||||
assert_eq!(snap[0].to_string(), "203.0.113.10:443");
|
||||
assert!(snap.iter().any(|sa| sa.to_string() == "192.0.2.5:443"));
|
||||
assert!(snap.iter().any(|sa| sa.to_string() == "192.0.2.7:443"));
|
||||
|
||||
let _ = std::fs::remove_file(&manifest_path);
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
}
|
||||
Reference in New Issue
Block a user