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,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--
|
||||||
|
//! <hex-encoded ECDSA-P256/SHA-256 signature over the body above, exclusive of this marker line>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>) -> 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<String>, 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<Vec<u8>> {
|
||||||
|
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<Self> {
|
||||||
|
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::<Vec<_>>().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<Self> {
|
||||||
|
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<SocketAddr> {
|
||||||
|
let mut out = Vec::with_capacity(self.bridges.len());
|
||||||
|
for raw in &self.bridges {
|
||||||
|
match raw.trim().parse::<SocketAddr>() {
|
||||||
|
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<String> {
|
||||||
|
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<SocketAddr>` snapshot behind an
|
||||||
|
/// `Arc<RwLock<...>>` 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<RwLock<Vec<SocketAddr>>>,
|
||||||
|
/// The static list from `[client] bridges` (used as a fallback when the manifest is missing).
|
||||||
|
static_bridges: Vec<SocketAddr>,
|
||||||
|
/// 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<SocketAddr>,
|
||||||
|
) -> 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<RwLock<...>>` can be read concurrently by the dial loop.
|
||||||
|
pub fn handle(&self) -> Arc<RwLock<Vec<SocketAddr>>> {
|
||||||
|
Arc::clone(&self.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current effective list. Cheap (a single `RwLock` read).
|
||||||
|
pub async fn current(&self) -> Vec<SocketAddr> {
|
||||||
|
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<tokio::task::JoinHandle<()>> {
|
||||||
|
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<SocketAddr> {
|
||||||
|
let mut out: Vec<SocketAddr> = 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<Vec<u8>> {
|
||||||
|
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<u8> {
|
||||||
|
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<SocketAddr> = vec![
|
||||||
|
"203.0.113.10:443".parse().unwrap(),
|
||||||
|
"198.51.100.20:443".parse().unwrap(),
|
||||||
|
];
|
||||||
|
let from_manifest: Vec<SocketAddr> = 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<SocketAddr> = 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<SocketAddr> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ use aura_tunnel::{AuraDns, AuraRouter, AuraTun, RouteAction};
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
use crate::admin::{self, AdminState, Stats};
|
use crate::admin::{self, AdminState, Stats};
|
||||||
|
use crate::bridges::BridgesDiscoveryWatcher;
|
||||||
use crate::circuit;
|
use crate::circuit;
|
||||||
use crate::config::{expand_tilde, ClientConfigFile};
|
use crate::config::{expand_tilde, ClientConfigFile};
|
||||||
use crate::crl_push::AcceptPushedCrlConn;
|
use crate::crl_push::AcceptPushedCrlConn;
|
||||||
@@ -95,6 +96,56 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> {
|
|||||||
"starting Aura client"
|
"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<RwLock<...>> 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<BridgesDiscoveryWatcher> = 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<std::net::SocketAddr> = Vec::new();
|
||||||
|
for raw in &cfg.client.bridges {
|
||||||
|
if let Ok(sa) = raw.parse::<std::net::SocketAddr>() {
|
||||||
|
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
|
// 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.)
|
// lock. (We rebuild the parsed CIDRs from the config rather than reaching into the table.)
|
||||||
let cidr_mirror = collect_cidr_rules(&cfg);
|
let cidr_mirror = collect_cidr_rules(&cfg);
|
||||||
|
|||||||
@@ -465,6 +465,39 @@ pub struct ClientSection {
|
|||||||
/// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`.
|
/// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub circuit: CircuitSection,
|
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`.
|
/// `[tunnel]` section of `client.toml`.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod bench;
|
pub mod bench;
|
||||||
|
pub mod bridges;
|
||||||
pub mod cells;
|
pub mod cells;
|
||||||
pub mod circuit;
|
pub mod circuit;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ enum Command {
|
|||||||
/// and assemble a `client.toml` in a portable bundle directory. See
|
/// and assemble a `client.toml` in a portable bundle directory. See
|
||||||
/// [`init::ProvisionClientOpts`].
|
/// [`init::ProvisionClientOpts`].
|
||||||
ProvisionClient(ProvisionClientArgs),
|
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.
|
/// `aura pki ...` subcommands.
|
||||||
@@ -249,6 +254,24 @@ struct ProvisionClientArgs {
|
|||||||
force: bool,
|
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.
|
/// `aura route ...` subcommands.
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
enum RouteCommand {
|
enum RouteCommand {
|
||||||
@@ -303,9 +326,51 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Command::BenchCrypto => bench::run(),
|
Command::BenchCrypto => bench::run(),
|
||||||
Command::ServerInit(args) => run_server_init(args),
|
Command::ServerInit(args) => run_server_init(args),
|
||||||
Command::ProvisionClient(args) => run_provision_client(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<String> = 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
|
/// 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
|
/// 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
|
/// with a proper message — we just don't want to install a redacting layer on top of a config we
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ mod store;
|
|||||||
|
|
||||||
pub use ca::{AuraCa, IssuedCert};
|
pub use ca::{AuraCa, IssuedCert};
|
||||||
pub use cert::AuraCertVerifier;
|
pub use cert::AuraCertVerifier;
|
||||||
pub use store::CrlStore;
|
pub use store::{sign_ecdsa_p256, verify_ecdsa_p256, CrlStore};
|
||||||
|
|
||||||
/// Errors produced by the Aura PKI.
|
/// Errors produced by the Aura PKI.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
|||||||
@@ -156,10 +156,7 @@ impl CrlStore {
|
|||||||
let sig_text = text[idx + marker.len()..].trim();
|
let sig_text = text[idx + marker.len()..].trim();
|
||||||
let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?;
|
let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?;
|
||||||
|
|
||||||
let pubkey = ca_public_key_from_pem(ca_cert_pem)
|
verify_ecdsa_p256(ca_cert_pem, body.as_bytes(), &signature)
|
||||||
.context("loading CA public key for CRL verification")?;
|
|
||||||
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice())
|
|
||||||
.verify(body.as_bytes(), &signature)
|
|
||||||
.map_err(|_| anyhow!("signed CRL signature did not verify"))?;
|
.map_err(|_| anyhow!("signed CRL signature did not verify"))?;
|
||||||
|
|
||||||
// Parse the inner body. Skip the magic line, then keep non-empty / non-comment lines.
|
// 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
|
/// 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.
|
/// 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<Vec<u8>> {
|
///
|
||||||
|
/// 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<Vec<u8>> {
|
||||||
let pkcs8_der = pem_block_to_der(ca_key_pem, &["PRIVATE KEY", "EC PRIVATE KEY"])
|
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"))?;
|
.ok_or_else(|| anyhow!("no PKCS#8 private-key block in CA key PEM"))?;
|
||||||
let rng = ring::rand::SystemRandom::new();
|
let rng = ring::rand::SystemRandom::new();
|
||||||
@@ -219,6 +219,19 @@ fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|||||||
Ok(sig.as_ref().to_vec())
|
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.
|
/// 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<Vec<u8>> {
|
fn ca_public_key_from_pem(ca_cert_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||||
let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"])
|
let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"])
|
||||||
|
|||||||
+24
-7
@@ -437,6 +437,17 @@ aura status
|
|||||||
`Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии).
|
`Session` (поле `_adapter` в `AuraTun` держит адаптер живым на всё время сессии).
|
||||||
- ✓ **Cross-compile.** Весь workspace проверен под `cargo check --target
|
- ✓ **Cross-compile.** Весь workspace проверен под `cargo check --target
|
||||||
x86_64-pc-windows-gnu` без warnings.
|
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,
|
- **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound,
|
||||||
по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только
|
по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только
|
||||||
десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт.
|
десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт.
|
||||||
- **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges`
|
- **Bridge-discovery через push без рестарта клиента** — частично реализовано в v3.3:
|
||||||
хардкодит список запасных IP; если их все заблокируют (включая российские entry-узлы из
|
подписанный CA-манифест на диске (`[client.bridges_discovery]`) горячо перечитывается фон-
|
||||||
сценария §7), восстановление требует обновления конфига клиента вручную.
|
таском; админ переподписывает файл и рассылает любым каналом (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-узла и применимого
|
при заходе на запрещённый ресурс через VPN — вопрос юрисдикции exit-узла и применимого
|
||||||
законодательства, не технический.
|
законодательства, не технический.
|
||||||
- **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут
|
- **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут
|
||||||
активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. Сейчас это решается
|
активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. v3.3 решает это в две
|
||||||
через `[client] bridges = [...]` — список запасных российских entry-узлов; клиент пробует их
|
ступени: (а) `[client] bridges = [...]` — статический список запасных entry-узлов, клиент
|
||||||
в случайном порядке при отказе primary. Полноценный bridge-discovery (без хардкода IP в
|
пробует их в случайном порядке при отказе primary; (б) `[client.bridges_discovery] enabled = true`
|
||||||
конфиге) — план v3.3.
|
— клиент горячо перечитывает 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
|
- **Cell padding не скрывает наличие туннеля.** Constant-size cells устраняют per-packet
|
||||||
size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий
|
size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий
|
||||||
объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами.
|
объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами.
|
||||||
|
|||||||
Reference in New Issue
Block a user