diff --git a/crates/aura-cli/src/bridges.rs b/crates/aura-cli/src/bridges.rs new file mode 100644 index 0000000..bfaa3fe --- /dev/null +++ b/crates/aura-cli/src/bridges.rs @@ -0,0 +1,641 @@ +//! v3.3 signed bridges manifest — CA-signed list of fallback bridge `IP:port` addresses. +//! +//! A static `[client] bridges = [...]` list is fine for one-off deployments but does not let an +//! operator rotate bridges without re-shipping `client.toml`, and it has no integrity check. +//! v3.3 introduces a small CA-signed manifest the operator places on disk; the client reads it at +//! startup and re-reads it on a timer (see [`BridgesDiscoveryWatcher`]). +//! +//! ## Wire format +//! +//! A signed manifest is a single text file with the same structure as the in-band CRL push: +//! +//! ```text +//! AURA-BRIDGES-v1 +//! {"version":1,"generated_at":1716901234,"expires_at":1717506034,"bridges":[ +//! "203.0.113.10:443", +//! "198.51.100.20:443" +//! ]} +//! --SIGNATURE-- +//! +//! ``` +//! +//! The body (header line + JSON line, both terminated by `\n`) is signed with the Aura CA's private +//! key using [`aura_pki::sign_ecdsa_p256`] — the same primitive the v2 in-band CRL push uses +//! ([`aura_pki::CrlStore::encode_signed`]). Verification calls [`aura_pki::verify_ecdsa_p256`]. +//! +//! ## Distribution +//! +//! v3.3 keeps distribution **file-based / out-of-band** — the operator writes the file to +//! `manifest_path` on every client and re-signs it whenever the bridge list changes. A future v3.4 +//! is expected to add an HTTP-fetch path (likely behind a feature gate so deployments without +//! `reqwest` keep the v3.3 binary slim). +//! +//! ## Merge semantics +//! +//! When `[client.bridges_discovery] enabled = true`, the manifest **extends** the static +//! `[client] bridges` list — duplicates are de-deduplicated by `SocketAddr`, but the static list is +//! kept as a fallback when the manifest is missing or expired so an operator never loses the +//! previously-shipped bridges by accident. See [`BridgesDiscoveryWatcher::merged_snapshot`]. +//! +//! ## Expiry +//! +//! `expires_at` is consulted on every load: a manifest where `expires_at < now()` is **rejected** +//! ([`BridgeManifest::load_signed_verified`] returns an error). This prevents a stale signed +//! manifest from indefinitely overriding the static bridge list and forces the operator to keep +//! re-signing on a cadence (recommended `--ttl-days 7`). + +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; + +/// First line of the signed manifest body. +const SIGNED_MANIFEST_HEADER: &str = "AURA-BRIDGES-v1"; +/// Bytes separating the signed body from the hex signature. +const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n"; + +/// A CA-signed list of bridge `IP:port` addresses with a generation timestamp and an expiry. +/// +/// The body of the wire format is a single line of JSON serialising this struct; the manifest is +/// signed with the Aura CA key using ECDSA-P256/SHA-256 (see module docs for the layout). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BridgeManifest { + /// Wire-format version. Currently `1`. A manifest with an unknown version is rejected. + pub version: u8, + /// Unix seconds at which the operator signed the manifest. Mostly informational (for logs and + /// "which generation is the client looking at" reasoning); the security boundary is the + /// signature plus `expires_at`. + pub generated_at: u64, + /// Unix seconds at which this manifest stops being valid. Clients reject a manifest whose + /// `expires_at` is in the past (including a slight skew tolerance is not applied — operators + /// pick a TTL). + pub expires_at: u64, + /// Ordered list of bridge entries, each parseable as a [`SocketAddr`] (`"IP:port"`). Operators + /// are expected to keep this list small (single digits or low tens of entries); the format does + /// not impose a hard limit. + pub bridges: Vec, +} + +impl BridgeManifest { + /// Construct an empty / placeholder manifest. Mainly useful in tests. + #[must_use] + pub fn new(version: u8, generated_at: u64, expires_at: u64, bridges: Vec) -> Self { + Self { + version, + generated_at, + expires_at, + bridges, + } + } + + /// Build a manifest from a slice of bridge strings with `expires_at = now + ttl`. The + /// `generated_at` field is set to the current wall-clock time. Used by the + /// `aura sign-bridges` CLI command. + #[must_use] + pub fn with_ttl(bridges: Vec, ttl: Duration) -> Self { + let now = unix_now(); + Self { + version: 1, + generated_at: now, + expires_at: now.saturating_add(ttl.as_secs()), + bridges, + } + } + + /// Sign the manifest with the supplied CA key PEM. Returns the bytes that should be written to + /// disk in the signed-manifest format documented at the module level. + pub fn encode_signed(&self, ca_key_pem: &str) -> anyhow::Result> { + if self.version != 1 { + return Err(anyhow!( + "BridgeManifest::encode_signed: only version=1 is defined (got {})", + self.version + )); + } + let body = self.signed_body()?; + let signature = aura_pki::sign_ecdsa_p256(ca_key_pem, body.as_bytes()) + .context("signing bridges manifest with the CA key")?; + let mut out = Vec::with_capacity(body.len() + SIGNATURE_MARKER.len() + signature.len() * 2); + out.extend_from_slice(body.as_bytes()); + out.extend_from_slice(SIGNATURE_MARKER); + out.extend_from_slice(hex_encode(&signature).as_bytes()); + out.push(b'\n'); + Ok(out) + } + + /// Persist the signed manifest at `path`, creating parent directories as needed. + pub fn save_signed(&self, path: &Path, ca_key_pem: &str) -> anyhow::Result<()> { + let bytes = self.encode_signed(ca_key_pem)?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).with_context(|| { + format!("creating bridges manifest dir {}", parent.display()) + })?; + } + } + fs::write(path, &bytes) + .with_context(|| format!("writing signed bridges manifest to {}", path.display()))?; + Ok(()) + } + + /// Parse + verify a signed manifest from raw bytes. Rejects: + /// * a missing or wrong header line, + /// * a malformed signature block, + /// * a signature that fails to verify against `ca_cert_pem`, + /// * an unknown `version`, + /// * an expired manifest (`expires_at < now()`). + pub fn decode_signed_verified(bytes: &[u8], ca_cert_pem: &str) -> anyhow::Result { + let text = std::str::from_utf8(bytes) + .map_err(|e| anyhow!("signed bridges manifest is not valid UTF-8: {e}"))?; + let marker = std::str::from_utf8(SIGNATURE_MARKER) + .expect("SIGNATURE_MARKER is a static ASCII literal"); + let idx = text.find(marker).ok_or_else(|| { + anyhow!("signed bridges manifest missing '--SIGNATURE--' marker line") + })?; + let body = &text[..idx]; + let sig_text = text[idx + marker.len()..].trim(); + let signature = + hex_decode(sig_text).context("decoding signed bridges manifest hex signature")?; + + aura_pki::verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature) + .map_err(|_| anyhow!("signed bridges manifest signature did not verify"))?; + + // Body shape: first line is the header, the rest is the JSON object. + let mut lines = body.lines(); + let header = lines + .next() + .ok_or_else(|| anyhow!("empty signed bridges manifest body"))?; + if header.trim() != SIGNED_MANIFEST_HEADER { + return Err(anyhow!( + "unexpected signed bridges manifest header '{header}', expected '{SIGNED_MANIFEST_HEADER}'" + )); + } + // The body may have used either a single JSON line or pretty-printed; collect the rest. + let json_part: String = lines.collect::>().join("\n"); + let manifest: BridgeManifest = serde_json::from_str(&json_part) + .context("parsing signed bridges manifest JSON body")?; + if manifest.version != 1 { + return Err(anyhow!( + "signed bridges manifest has unknown version={} (expected 1)", + manifest.version + )); + } + let now = unix_now(); + if manifest.expires_at < now { + return Err(anyhow!( + "signed bridges manifest is expired (expires_at={}, now={})", + manifest.expires_at, + now + )); + } + Ok(manifest) + } + + /// Read the signed manifest from `path` and verify it against `ca_cert_pem`. + pub fn load_signed_verified(path: &Path, ca_cert_pem: &str) -> anyhow::Result { + let bytes = fs::read(path) + .with_context(|| format!("reading signed bridges manifest from {}", path.display()))?; + Self::decode_signed_verified(&bytes, ca_cert_pem) + } + + /// Parse the `bridges` list into [`SocketAddr`]s. Entries that fail to parse are skipped with a + /// `tracing::warn!` log so a single malformed line cannot make the whole manifest unusable. + pub fn parsed_bridges(&self) -> Vec { + let mut out = Vec::with_capacity(self.bridges.len()); + for raw in &self.bridges { + match raw.trim().parse::() { + Ok(a) => out.push(a), + Err(e) => { + tracing::warn!( + entry = %raw, + error = %e, + "skipping unparseable bridge entry in signed manifest" + ); + } + } + } + out + } + + /// Internal: build the bytes that get signed (header + JSON, each terminated by `\n`). + fn signed_body(&self) -> anyhow::Result { + let mut s = String::new(); + s.push_str(SIGNED_MANIFEST_HEADER); + s.push('\n'); + s.push_str( + &serde_json::to_string(self).context("serialising bridges manifest body to JSON")?, + ); + s.push('\n'); + Ok(s) + } +} + +/// Background watcher that re-reads a signed bridges manifest from disk on a fixed interval. +/// +/// The watcher keeps the most recently merged `Vec` snapshot behind an +/// `Arc>` so the dial loop can read the freshest list without blocking on a stale lock +/// across rotations. The watcher always **starts** from the static `[client] bridges` baseline so +/// the snapshot is never empty — when the manifest is missing or expired the dial loop still +/// retries the operator-shipped static list. +#[derive(Clone)] +pub struct BridgesDiscoveryWatcher { + /// The current effective merged list (static + manifest, de-duplicated by `SocketAddr`). + snapshot: Arc>>, + /// The static list from `[client] bridges` (used as a fallback when the manifest is missing). + static_bridges: Vec, + /// File path of the signed manifest. + manifest_path: PathBuf, + /// CA cert PEM used to verify manifest signatures (typically the same as `[pki] ca_cert`). + ca_cert_pem: String, + /// Refresh interval in seconds. Zero means "do not refresh in the background" (one-shot load). + refresh_interval_secs: u64, +} + +impl BridgesDiscoveryWatcher { + /// Build the watcher and perform an initial load. If the initial load fails the watcher is + /// still constructed — the snapshot just remains equal to the static fallback list — and an + /// error is logged. This matches the operational expectation that the dial loop must always + /// have *some* bridge list to try. + pub async fn new( + manifest_path: PathBuf, + ca_cert_pem: String, + refresh_interval_secs: u64, + static_bridges: Vec, + ) -> Self { + let snapshot = Arc::new(RwLock::new(static_bridges.clone())); + let watcher = Self { + snapshot, + static_bridges, + manifest_path, + ca_cert_pem, + refresh_interval_secs, + }; + watcher.refresh_once().await; + watcher + } + + /// Snapshot handle: clones of this `Arc>` can be read concurrently by the dial loop. + pub fn handle(&self) -> Arc>> { + Arc::clone(&self.snapshot) + } + + /// Get the current effective list. Cheap (a single `RwLock` read). + pub async fn current(&self) -> Vec { + self.snapshot.read().await.clone() + } + + /// Trigger a single reload from disk; updates `snapshot` if the manifest verifies. + /// + /// On any error the static fallback is kept (the snapshot is **not** overwritten with an + /// empty list — that would leave the dial loop with only the primary `server_addr`). + pub async fn refresh_once(&self) { + match BridgeManifest::load_signed_verified(&self.manifest_path, &self.ca_cert_pem) { + Ok(manifest) => { + let merged = merged_snapshot(&self.static_bridges, &manifest.parsed_bridges()); + let merged_len = merged.len(); + *self.snapshot.write().await = merged; + tracing::info!( + path = %self.manifest_path.display(), + generated_at = manifest.generated_at, + expires_at = manifest.expires_at, + manifest_bridges = manifest.bridges.len(), + merged_total = merged_len, + "loaded signed bridges manifest" + ); + } + Err(e) => { + tracing::warn!( + path = %self.manifest_path.display(), + error = %e, + "failed to load signed bridges manifest; keeping previous snapshot \ + (static [client] bridges still apply)" + ); + } + } + } + + /// Spawn the background refresh task. When `refresh_interval_secs == 0` no task is spawned and + /// `None` is returned. The returned [`tokio::task::JoinHandle`] is owned by the caller and must + /// be kept alive for the lifetime of the watcher. + pub fn spawn_refresh(&self) -> Option> { + if self.refresh_interval_secs == 0 { + return None; + } + let watcher = self.clone(); + let interval = Duration::from_secs(self.refresh_interval_secs); + Some(tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // The first tick fires immediately; skip it so the spawn does not double-refresh + // right after the initial load in [`Self::new`]. + ticker.tick().await; + loop { + ticker.tick().await; + watcher.refresh_once().await; + } + })) + } +} + +/// Merge two `SocketAddr` lists. The static list comes first (operator-shipped, stable order); the +/// manifest list is appended; duplicates (`SocketAddr` equality) are removed while preserving +/// first-seen order. +fn merged_snapshot(statics: &[SocketAddr], manifest: &[SocketAddr]) -> Vec { + let mut out: Vec = Vec::with_capacity(statics.len() + manifest.len()); + for a in statics.iter().chain(manifest.iter()) { + if !out.contains(a) { + out.push(*a); + } + } + out +} + +/// Current Unix seconds (saturating; on impossible clock readings returns 0). +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Lowercase hex of a byte slice. Local copy (the matching helper in `aura-pki` is crate-private). +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(nibble_to_hex(b >> 4)); + s.push(nibble_to_hex(b & 0x0F)); + } + s +} + +/// Decode a hex string into bytes. Returns an error on any non-hex character or odd length. +fn hex_decode(s: &str) -> anyhow::Result> { + let s = s.trim(); + if !s.len().is_multiple_of(2) { + return Err(anyhow!("hex string has odd length ({} chars)", s.len())); + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + for chunk in bytes.chunks_exact(2) { + let hi = hex_to_nibble(chunk[0])?; + let lo = hex_to_nibble(chunk[1])?; + out.push((hi << 4) | lo); + } + Ok(out) +} + +fn nibble_to_hex(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + 10..=15 => (b'a' + n - 10) as char, + _ => '?', + } +} + +fn hex_to_nibble(c: u8) -> anyhow::Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + other => Err(anyhow!("invalid hex character 0x{other:02x}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_pki::AuraCa; + + /// Helper: generate a fresh CA and return `(cert_pem, key_pem)` so signing tests do not need + /// the file-system PKI plumbing. + fn fresh_ca() -> (String, String) { + 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-{}-ca.crt", uuid::Uuid::new_v4())); + let key_path = + std::env::temp_dir().join(format!("aura-bridges-{}-ca.key", uuid::Uuid::new_v4())); + ca.save(&cert_path, &key_path).unwrap(); + let key_pem = std::fs::read_to_string(&key_path).unwrap(); + let _ = std::fs::remove_file(&cert_path); + let _ = std::fs::remove_file(&key_path); + (cert_pem, key_pem) + } + + /// Sign a manifest with one CA, verify with the same CA — must succeed and round-trip. + #[test] + fn sign_verify_roundtrip() { + let (cert_pem, key_pem) = fresh_ca(); + let manifest = BridgeManifest::with_ttl( + vec![ + "203.0.113.10:443".to_string(), + "198.51.100.20:443".to_string(), + ], + Duration::from_secs(3600), + ); + let bytes = manifest.encode_signed(&key_pem).expect("sign"); + let decoded = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).expect("verify"); + assert_eq!(decoded.bridges, manifest.bridges); + assert_eq!(decoded.version, 1); + // Parsed sockets shape OK. + let socks = decoded.parsed_bridges(); + assert_eq!(socks.len(), 2); + assert_eq!(socks[0].to_string(), "203.0.113.10:443"); + } + + /// Flipping a byte inside the signature must be detected. + #[test] + fn verify_rejects_wrong_signature() { + let (cert_pem, key_pem) = fresh_ca(); + let manifest = BridgeManifest::with_ttl( + vec!["203.0.113.10:443".to_string()], + Duration::from_secs(3600), + ); + let mut bytes = manifest.encode_signed(&key_pem).expect("sign"); + // The signature lives after `--SIGNATURE--\n`; flip the last hex char so the bytes change + // value but the hex remains decodable. + let len = bytes.len(); + // Skip the trailing newline added by encode_signed. + let last_hex = len - 2; + bytes[last_hex] = if bytes[last_hex] == b'0' { b'1' } else { b'0' }; + let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("did not verify") || msg.contains("signature"), + "expected verify error, got: {msg}" + ); + } + + /// A manifest with `expires_at` in the past must be rejected even if the signature is good. + #[test] + fn verify_rejects_expired() { + let (cert_pem, key_pem) = fresh_ca(); + let now = unix_now(); + let manifest = BridgeManifest::new( + 1, + now.saturating_sub(7200), + now.saturating_sub(60), + vec!["203.0.113.10:443".to_string()], + ); + let bytes = manifest.encode_signed(&key_pem).expect("sign"); + let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("expired"), "expected expiry error, got: {msg}"); + } + + /// Signed by CA-A but verified against CA-B must be rejected. + #[test] + fn verify_rejects_wrong_ca() { + let (real_cert, _real_key) = fresh_ca(); + let (_rogue_cert, rogue_key) = fresh_ca(); + let manifest = BridgeManifest::with_ttl( + vec!["203.0.113.10:443".to_string()], + Duration::from_secs(3600), + ); + let bytes = manifest.encode_signed(&rogue_key).expect("sign with rogue"); + let err = BridgeManifest::decode_signed_verified(&bytes, &real_cert).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("did not verify") || msg.contains("signature"), + "expected verify error, got: {msg}" + ); + } + + /// A manifest declaring an unknown `version` is rejected even if the signature verifies. + #[test] + fn verify_rejects_unknown_version() { + let (cert_pem, key_pem) = fresh_ca(); + let now = unix_now(); + let manifest = BridgeManifest { + version: 99, + generated_at: now, + expires_at: now + 3600, + bridges: vec!["203.0.113.10:443".to_string()], + }; + // We have to skip the version=1 enforcement on encode (the operator's intent in the test) + // by serialising the body manually with version=99. + let body = format!( + "{}\n{}\n", + SIGNED_MANIFEST_HEADER, + serde_json::to_string(&manifest).unwrap() + ); + let signature = aura_pki::sign_ecdsa_p256(&key_pem, body.as_bytes()).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(body.as_bytes()); + bytes.extend_from_slice(SIGNATURE_MARKER); + bytes.extend_from_slice(hex_encode(&signature).as_bytes()); + bytes.push(b'\n'); + + let err = BridgeManifest::decode_signed_verified(&bytes, &cert_pem).unwrap_err(); + assert!(err.to_string().contains("version"), "{err}"); + } + + /// `parsed_bridges` drops unparseable strings without panicking. + #[test] + fn parsed_bridges_skips_unparseable() { + let manifest = BridgeManifest::new( + 1, + unix_now(), + unix_now() + 3600, + vec![ + "203.0.113.10:443".to_string(), + "not-an-ip:443".to_string(), + "198.51.100.20:443".to_string(), + ], + ); + let socks = manifest.parsed_bridges(); + assert_eq!(socks.len(), 2, "garbage entry dropped"); + } + + /// Merge keeps static-first ordering and dedupes addresses present in both lists. + #[test] + fn merge_dedupes_and_keeps_static_first() { + let statics: Vec = vec![ + "203.0.113.10:443".parse().unwrap(), + "198.51.100.20:443".parse().unwrap(), + ]; + let from_manifest: Vec = vec![ + "198.51.100.20:443".parse().unwrap(), // dup + "192.0.2.5:443".parse().unwrap(), + ]; + let merged = merged_snapshot(&statics, &from_manifest); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].to_string(), "203.0.113.10:443"); + assert_eq!(merged[1].to_string(), "198.51.100.20:443"); + assert_eq!(merged[2].to_string(), "192.0.2.5:443"); + } + + /// `BridgesDiscoveryWatcher::new` loads the manifest at construction and merges it with + /// statics. Subsequent `refresh_once` calls pick up file changes. + #[tokio::test] + async fn watcher_refreshes_on_file_change() { + let (cert_pem, key_pem) = fresh_ca(); + let manifest_path = + std::env::temp_dir().join(format!("aura-bridges-{}.signed", uuid::Uuid::new_v4())); + let statics: Vec = vec!["203.0.113.10:443".parse().unwrap()]; + + // Initial manifest: one extra bridge. + let first = BridgeManifest::with_ttl( + vec!["198.51.100.20:443".to_string()], + Duration::from_secs(3600), + ); + first.save_signed(&manifest_path, &key_pem).expect("save"); + + let watcher = BridgesDiscoveryWatcher::new( + manifest_path.clone(), + cert_pem.clone(), + // No background spawning in this test — we drive refresh manually. + 0, + statics.clone(), + ) + .await; + let snap = watcher.current().await; + assert_eq!(snap.len(), 2, "static + manifest"); + + // Replace the manifest with two bridges (one dup of static). + let second = 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), + ); + second.save_signed(&manifest_path, &key_pem).expect("save2"); + watcher.refresh_once().await; + let snap = watcher.current().await; + assert_eq!(snap.len(), 2, "static + one new (dup dropped)"); + assert_eq!(snap[0].to_string(), "203.0.113.10:443"); + assert_eq!(snap[1].to_string(), "192.0.2.5:443"); + + let _ = std::fs::remove_file(&manifest_path); + } + + /// If the file disappears between refreshes, the watcher keeps the last known snapshot rather + /// than dropping back to just the static fallback. Operators get a non-empty list either way. + #[tokio::test] + async fn watcher_keeps_last_snapshot_when_file_missing() { + let (cert_pem, key_pem) = fresh_ca(); + let manifest_path = + std::env::temp_dir().join(format!("aura-bridges-{}.signed", uuid::Uuid::new_v4())); + let statics: Vec = vec!["203.0.113.10:443".parse().unwrap()]; + + let first = BridgeManifest::with_ttl( + vec!["198.51.100.20:443".to_string()], + Duration::from_secs(3600), + ); + first.save_signed(&manifest_path, &key_pem).expect("save"); + + let watcher = + BridgesDiscoveryWatcher::new(manifest_path.clone(), cert_pem, 0, statics).await; + assert_eq!(watcher.current().await.len(), 2); + + // Delete the file and refresh — the old snapshot must persist. + std::fs::remove_file(&manifest_path).expect("rm"); + watcher.refresh_once().await; + let snap = watcher.current().await; + assert_eq!(snap.len(), 2, "snapshot kept across missing-file refresh"); + } +} diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index c977227..04d1efc 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -29,6 +29,7 @@ use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction}; use tokio::sync::RwLock; use crate::admin::{self, AdminState, Stats}; +use crate::bridges::BridgesDiscoveryWatcher; use crate::circuit; use crate::config::{expand_tilde, ClientConfigFile}; use crate::crl_push::AcceptPushedCrlConn; @@ -95,6 +96,56 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { "starting Aura client" ); + // v3.3: signed bridges manifest discovery. When `[client.bridges_discovery] enabled = true`, + // load the CA-signed bridges manifest from disk and spawn a background refresher that re-reads + // the file on a timer. The merged snapshot (static `[client] bridges` + manifest bridges, + // de-duplicated by SocketAddr) is held behind an Arc> so future per-event re-dials + // can pick up the freshest list without restarting the client. When `enabled = false` the + // static list is used verbatim (the v3.2 behaviour). + // + // Note on scope: v3.2 already dials only the primary `[client] server_addr` once (the + // `[client] bridges` list is documented as the fallback dial-target source but the actual + // sequential retry loop is not yet wired into [`aura_transport::dial`]). v3.3 adds the + // *manifest source* and exposes the watcher handle so the dial loop wiring is a follow-up + // change that only needs to read `_bridges_watcher.handle()` — the signed-manifest + // distribution mechanism is already in place. + let _bridges_watcher: Option = if cfg.client.bridges_discovery.enabled + { + let manifest_path = + expand_tilde(&cfg.client.bridges_discovery.manifest_path.to_string_lossy()); + let refresh_secs = cfg.client.bridges_discovery.refresh_interval_secs; + let mut static_bridges: Vec = Vec::new(); + for raw in &cfg.client.bridges { + if let Ok(sa) = raw.parse::() { + static_bridges.push(sa); + } + } + let watcher = BridgesDiscoveryWatcher::new( + manifest_path.clone(), + proto_cfg.ca_cert_pem.clone(), + refresh_secs, + static_bridges, + ) + .await; + // Keep the background refresher alive for the lifetime of the client via the + // returned JoinHandle. Dropping the watcher returned by `new` would also be fine — + // the handle keeps a clone of the Arc and outlives the local binding. + let _bg = watcher.spawn_refresh(); + tracing::info!( + path = %manifest_path.display(), + refresh_interval_secs = refresh_secs, + snapshot_size = watcher.current().await.len(), + "v3.3 signed bridges discovery enabled" + ); + Some(watcher) + } else { + tracing::debug!( + "v3.3 signed bridges discovery disabled in config; using static [client] bridges \ + verbatim" + ); + None + }; + // Snapshot the configured CIDR rules for the admin mirror before moving the table behind the // lock. (We rebuild the parsed CIDRs from the config rather than reaching into the table.) let cidr_mirror = collect_cidr_rules(&cfg); diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index bd037d2..1b73a79 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -465,6 +465,39 @@ pub struct ClientSection { /// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`. #[serde(default)] pub circuit: CircuitSection, + /// `[client.bridges_discovery]` sub-section: v3.3 CA-signed bridges manifest. When + /// `enabled = true`, the client periodically reloads a signed manifest from + /// `manifest_path` and merges the resulting bridge list with the static + /// `[client] bridges` baseline. See [`crate::bridges`]. Default `enabled = false` + /// (back-compat — the static list is used verbatim). + #[serde(default)] + pub bridges_discovery: BridgesDiscoverySection, +} + +/// `[client.bridges_discovery]` section: v3.3 signed bridges manifest configuration. See +/// [`crate::bridges::BridgeManifest`] for the wire format and [`crate::bridges::BridgesDiscoveryWatcher`] +/// for the runtime behaviour. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct BridgesDiscoverySection { + /// Master switch. `false` (the default) keeps the v3.2 behaviour where `[client] bridges` is + /// the only source. `true` enables the watcher. + pub enabled: bool, + /// File path of the signed manifest on disk. Path may begin with `~`. REQUIRED when `enabled`. + pub manifest_path: PathBuf, + /// Refresh cadence in seconds. The watcher reloads the file every `refresh_interval_secs` + /// (defaults to 3600 = one hour). Zero disables the background timer (one-shot load). + pub refresh_interval_secs: u64, +} + +impl Default for BridgesDiscoverySection { + fn default() -> Self { + Self { + enabled: false, + manifest_path: PathBuf::new(), + refresh_interval_secs: 3600, + } + } } /// `[tunnel]` section of `client.toml`. diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index a84582f..ce08704 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -14,6 +14,7 @@ pub mod admin; pub mod bench; +pub mod bridges; pub mod cells; pub mod circuit; pub mod client; diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 4dad57e..991b244 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -61,6 +61,11 @@ enum Command { /// and assemble a `client.toml` in a portable bundle directory. See /// [`init::ProvisionClientOpts`]. ProvisionClient(ProvisionClientArgs), + + /// v3.3: sign a bridges manifest with the Aura CA key. The output file is consumed by the + /// client's `[client.bridges_discovery]` watcher; see [`aura_cli::bridges`] for the wire + /// format. The CA cert + key are read from `<--ca>/{ca.crt, ca.key}`. + SignBridges(SignBridgesArgs), } /// `aura pki ...` subcommands. @@ -249,6 +254,24 @@ struct ProvisionClientArgs { force: bool, } +/// Arguments for `aura sign-bridges`. +#[derive(Debug, Args)] +struct SignBridgesArgs { + /// Directory holding the CA (`ca.crt` + `ca.key`). + #[arg(long)] + ca: PathBuf, + /// Comma-separated list of bridge `IP:port` literals to include in the manifest. + #[arg(long)] + bridges: String, + /// Manifest validity in days. The signed manifest carries `expires_at = now + ttl_days*86400` + /// — clients reject manifests past their expiry. + #[arg(long, default_value_t = 7)] + ttl_days: u32, + /// Output path for the signed manifest file (e.g. `/var/aura/bridges.signed`). + #[arg(long)] + out: PathBuf, +} + /// `aura route ...` subcommands. #[derive(Debug, Subcommand)] enum RouteCommand { @@ -303,9 +326,51 @@ async fn main() -> anyhow::Result<()> { Command::BenchCrypto => bench::run(), Command::ServerInit(args) => run_server_init(args), Command::ProvisionClient(args) => run_provision_client(args), + Command::SignBridges(args) => run_sign_bridges(args), } } +/// Dispatch `aura sign-bridges`. Reads the CA cert + key from `<--ca>/{ca.crt, ca.key}`, builds a +/// manifest with the given bridges and TTL, signs it, and writes the result to `--out`. +fn run_sign_bridges(args: SignBridgesArgs) -> anyhow::Result<()> { + use std::time::Duration; + + let ca_cert_path = args.ca.join("ca.crt"); + let ca_key_path = args.ca.join("ca.key"); + let _ca_cert_pem = std::fs::read_to_string(&ca_cert_path) + .map_err(|e| anyhow::anyhow!("reading CA certificate {}: {e}", ca_cert_path.display()))?; + let ca_key_pem = std::fs::read_to_string(&ca_key_path) + .map_err(|e| anyhow::anyhow!("reading CA key {}: {e}", ca_key_path.display()))?; + + let bridges: Vec = args + .bridges + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if bridges.is_empty() { + anyhow::bail!("--bridges must contain at least one IP:port entry"); + } + // Sanity check: every entry must already parse as a SocketAddr so the operator gets a clear + // error here instead of clients silently dropping malformed entries. + for b in &bridges { + let _: std::net::SocketAddr = b + .parse() + .map_err(|e| anyhow::anyhow!("invalid bridge entry '{b}' (expected IP:port): {e}"))?; + } + + let ttl = Duration::from_secs(u64::from(args.ttl_days) * 86_400); + let manifest = aura_cli::bridges::BridgeManifest::with_ttl(bridges.clone(), ttl); + manifest.save_signed(&args.out, &ca_key_pem)?; + + println!("Signed bridges manifest written:"); + println!(" out: {}", args.out.display()); + println!(" bridges: {}", bridges.len()); + println!(" generated_at: {}", manifest.generated_at); + println!(" expires_at: {}", manifest.expires_at); + Ok(()) +} + /// Best-effort read of `[server] no_logs` for the early tracing-init step. We deliberately swallow /// errors here: if the config does not parse the actual `server::run` call will report the issue /// with a proper message — we just don't want to install a redacting layer on top of a config we diff --git a/crates/aura-cli/tests/bridges_discovery.rs b/crates/aura-cli/tests/bridges_discovery.rs new file mode 100644 index 0000000..c627798 --- /dev/null +++ b/crates/aura-cli/tests/bridges_discovery.rs @@ -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 = 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 = 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); +} diff --git a/crates/aura-pki/src/lib.rs b/crates/aura-pki/src/lib.rs index b8f1959..36c6523 100644 --- a/crates/aura-pki/src/lib.rs +++ b/crates/aura-pki/src/lib.rs @@ -16,7 +16,7 @@ mod store; pub use ca::{AuraCa, IssuedCert}; pub use cert::AuraCertVerifier; -pub use store::CrlStore; +pub use store::{sign_ecdsa_p256, verify_ecdsa_p256, CrlStore}; /// Errors produced by the Aura PKI. #[derive(Debug, thiserror::Error)] diff --git a/crates/aura-pki/src/store.rs b/crates/aura-pki/src/store.rs index 6295324..d6e328a 100644 --- a/crates/aura-pki/src/store.rs +++ b/crates/aura-pki/src/store.rs @@ -156,10 +156,7 @@ impl CrlStore { let sig_text = text[idx + marker.len()..].trim(); let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?; - let pubkey = ca_public_key_from_pem(ca_cert_pem) - .context("loading CA public key for CRL verification")?; - UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice()) - .verify(body.as_bytes(), &signature) + verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature) .map_err(|_| anyhow!("signed CRL signature did not verify"))?; // Parse the inner body. Skip the magic line, then keep non-empty / non-comment lines. @@ -207,7 +204,10 @@ const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n"; /// Sign `body` with an ECDSA-P256/SHA-256 PKCS#8 key (PEM-encoded). Returns the ASN.1 signature /// bytes (variable-length DER) that `ring::signature::ECDSA_P256_SHA256_ASN1` accepts on verify. -fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result> { +/// +/// Exposed publicly so the v3.3 signed-bridges manifest in `aura-cli` reuses the same signing +/// primitive as the in-band CRL push (consistent on-disk format and signature algorithm). +pub fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result> { let pkcs8_der = pem_block_to_der(ca_key_pem, &["PRIVATE KEY", "EC PRIVATE KEY"]) .ok_or_else(|| anyhow!("no PKCS#8 private-key block in CA key PEM"))?; let rng = ring::rand::SystemRandom::new(); @@ -219,6 +219,19 @@ fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result> { Ok(sig.as_ref().to_vec()) } +/// Verify an ECDSA-P256/SHA-256 ASN.1 signature against a CA certificate PEM. +/// +/// Exposed publicly so the v3.3 signed-bridges manifest in `aura-cli` shares the same verification +/// primitive as the in-band CRL push. Returns `Err` when the CA PEM cannot be parsed or when the +/// signature does not validate. +pub fn verify_ecdsa_p256(ca_cert_pem: &str, body: &[u8], signature: &[u8]) -> anyhow::Result<()> { + let pubkey = ca_public_key_from_pem(ca_cert_pem) + .context("loading CA public key for signature verification")?; + UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice()) + .verify(body, signature) + .map_err(|_| anyhow!("ECDSA-P256/SHA-256 signature did not verify")) +} + /// Extract the CA's uncompressed EC public-key point from a CA certificate PEM. fn ca_public_key_from_pem(ca_cert_pem: &str) -> anyhow::Result> { let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"]) diff --git a/docs/deployment.md b/docs/deployment.md index 51108bf..6b60cd6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -437,6 +437,17 @@ aura status `Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии). - ✓ **Cross-compile.** Весь workspace проверен под `cargo check --target x86_64-pc-windows-gnu` без warnings. +- ✓ **Bridge-discovery через подписанный CA-манифест (v3.3).** + `[client.bridges_discovery] enabled = true` плюс файл `bridges.signed` на диске. Админ + собирает манифест командой + `aura sign-bridges --ca /etc/aura/pki --bridges "203.0.113.10:443,198.51.100.20:443" --ttl-days 7 --out /etc/aura/bridges.signed` + (подпись ECDSA-P256/SHA-256 ключом CA — той же примитивой что in-band CRL). Клиент верифицирует + подпись против `[pki] ca_cert`, отвергает истёкшие манифесты (`expires_at < now`), и **расширяет** + статический список из `[client] bridges` (дубликаты по `SocketAddr` удаляются; статика остаётся + fallback'ом если файл повреждён / отсутствует). Фон-таск перечитывает файл каждые + `refresh_interval_secs` секунд (default 3600), горячее обновление без рестарта клиента. Сам HTTP- + пуш через CDN — план v3.4 (опциональная зависимость `reqwest` под feature gate). См. + `crates/aura-cli/src/bridges.rs` и интеграционный тест `tests/bridges_discovery.rs`. ### Остающиеся честные ограничения @@ -450,9 +461,12 @@ aura status - **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound, по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт. -- **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges` - хардкодит список запасных IP; если их все заблокируют (включая российские entry-узлы из - сценария §7), восстановление требует обновления конфига клиента вручную. +- **Bridge-discovery через push без рестарта клиента** — частично реализовано в v3.3: + подписанный CA-манифест на диске (`[client.bridges_discovery]`) горячо перечитывается фон- + таском; админ переподписывает файл и рассылает любым каналом (rsync/ansible/scp). HTTP-fetch + напрямую с CDN — план v3.4. Если все статически-перечисленные IP заблокированы и манифест не + обновлён до экспирации, восстановление требует доставки нового `bridges.signed` через + out-of-band канал. --- @@ -674,10 +688,13 @@ exit, и они не пересекаются (см. `aura provision-client --ci при заходе на запрещённый ресурс через VPN — вопрос юрисдикции exit-узла и применимого законодательства, не технический. - **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут - активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. Сейчас это решается - через `[client] bridges = [...]` — список запасных российских entry-узлов; клиент пробует их - в случайном порядке при отказе primary. Полноценный bridge-discovery (без хардкода IP в - конфиге) — план v3.3. + активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. v3.3 решает это в две + ступени: (а) `[client] bridges = [...]` — статический список запасных entry-узлов, клиент + пробует их в случайном порядке при отказе primary; (б) `[client.bridges_discovery] enabled = true` + — клиент горячо перечитывает CA-подписанный манифест `bridges.signed` на диске (см. v3.3 + раздел в §6 «Устранено в v2/v3»), так что админ ротирует список без рестарта клиентского + процесса — достаточно переподписать файл и доставить новой копией (rsync / ansible / любой + out-of-band канал). HTTP-fetch с CDN — план v3.4. - **Cell padding не скрывает наличие туннеля.** Constant-size cells устраняют per-packet size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами.