feat(cli): v3.2 multi-hop — per-hop cert, cell padding, 3-hop, CIDR whitelist

Closes the v3.1 unlinkability gap and resists volume/timing correlation:

1) Per-hop client cert (identity-unlinkable hops). [[client.circuit.hops]]
   now accepts {addr, cert_path, key_path, [server_name]} per hop — each
   hop sees a different CN, so a relay and an exit cannot correlate the
   same client by certificate. Old flat `hops = ["ip:port"]` form still
   parses (serde untagged enum) and falls back to [pki] cert/key.
   `aura provision-client --circuit-hops N` mints N fresh UUIDv4 certs.

2) Cell padding. CellPaddingConn wrapper pads every outgoing packet to a
   fixed size (default 1280 bytes; `cell_size = N` configurable) before
   it hits the inner AEAD. Format: u16_be(real_len) || pkt || zero_pad.
   On-wire sizes become constant -> defeats volume/timing fingerprints.
   Opt-in via [client.circuit] cell_padding = true and the mirror
   [server] cell_padding_for_circuit_clients = true.

3) 3-hop support. dial_circuit now accepts N >= 2 hops; iterative
   ExtendBridge nests N-1 forwarders and N handshakes. Client owns the
   full chain via CircuitConnection (forwarders abort on drop).
   New integration test multihop_v3_2_three_hops_end_to_end runs three
   in-process actors (A relay -> B relay -> C exit) on loopback and
   verifies peer_id == C's CN.

4) CIDR whitelist. [server.relay] allow_extend_to entries accept
   "10.0.0.0/24" (subnet, any port), "10.0.0.0/24:443" (subnet + port),
   "[2001:db8::/32]:443" (IPv6 with port), as well as exact IP:port.
   Empty list keeps the v3.1 open-relay (warn).

19 new tests; workspace 276 passed (+19), clippy -D warnings clean, fmt clean.
257 baseline tests untouched; all v2 / v3.1 / LE configs work as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 20:07:12 +03:00
parent f26ed7fce0
commit 9b98004424
13 changed files with 1768 additions and 298 deletions
+585 -38
View File
@@ -135,9 +135,18 @@ pub struct ServerSection {
/// written. Default `false` (verbose). See [`crate::no_logs`].
#[serde(default)]
pub no_logs: bool,
/// v3.2: when `true`, **every** accepted UDP connection that ends up serving as a normal VPN
/// client (i.e. not bridged through the relay path) is wrapped in
/// [`crate::cells::CellPaddingConn`] using `[server.relay] cell_size` bytes per cell. This is
/// the server-side complement to `[client.circuit] cell_padding`: the **exit** of a multi-hop
/// circuit MUST enable this so its inner-handshake session decodes the client's padded cells.
/// Default `false` (v3.1-compatible). Operators running an exit-only server with cell-padded
/// circuit clients should set this to `true`.
#[serde(default)]
pub cell_padding_for_circuit_clients: bool,
}
/// `[server.relay]` section: v3.1 multi-hop / onion routing.
/// `[server.relay]` section: v3.1 / v3.2 multi-hop / onion routing.
///
/// When `enabled = true`, an accepted connection is **not** immediately registered with the
/// [`crate::server_router::ServerRouter`]. Instead the server listens (for a short window) for a
@@ -149,16 +158,44 @@ pub struct ServerSection {
///
/// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a
/// VPN client and the relay path is dead code.
///
/// ## v3.2 `cell_padding`
///
/// When `cell_padding = true`, this server treats every bridged client connection as a
/// constant-size cell stream (see [`crate::cells`]) — every accepted [`aura_proto::PacketConnection`]
/// on the relay path is wrapped in [`crate::cells::CellPaddingConn`] using `cell_size` bytes per
/// cell (default 1280). The **client must enable the matching flag in `[client.circuit]`** or the
/// transport bytes will not be a valid cell stream. Default `false` (v3.1-compatible).
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct RelaySection {
/// Master switch. `false` (default) keeps the v2 behaviour intact.
pub enabled: bool,
/// Whitelist of allowed downstream exit addresses (`IP:port`). DNS hostnames are NOT resolved
/// in v3.1 — they are logged as a warning and ignored. An empty list means "all addresses
/// allowed", which is dangerous (open relay); the runtime logs a warning when this combination
/// is detected.
/// Whitelist of allowed downstream exit destinations. Each entry is either:
///
/// * A literal `IP:port` — exact match.
/// * A CIDR `IP/prefix` — matches any port at any IP in the subnet (v3.2).
/// * A CIDR with explicit port `IP/prefix:port` — matches the port on any IP in the subnet
/// (v3.2). For IPv6 the syntax is `[2001:db8::/32]:443`; bare-IPv6 syntax mirrors the
/// `SocketAddr` brackets convention.
///
/// DNS hostnames are NOT resolved (logged at WARN and skipped). An empty list means "all
/// addresses allowed" — dangerous (open relay); the runtime logs a warning when this is
/// detected.
pub allow_extend_to: Vec<String>,
/// When `true`, every relayed connection's bytes pass through [`crate::cells::CellPaddingConn`]
/// at `cell_size`. The client MUST enable the matching flag. Default `false`.
pub cell_padding: bool,
/// Cell size for [`crate::cells::CellPaddingConn`] when `cell_padding = true`. Default 1280.
/// MUST match the client's `[client.circuit] cell_size`.
#[serde(default = "default_cell_size")]
pub cell_size: usize,
}
/// Default cell size (bytes) for the cell-padding wrapper. 1280 is the IPv6 minimum MTU and a
/// commonly-seen HTTPS path MTU, so it is unlikely to look suspicious on the wire.
fn default_cell_size() -> usize {
crate::cells::CellPaddingConn::DEFAULT_CELL_SIZE
}
/// `[server.outer_cert]` section: v3 explicit outer-TLS cert/key for the QUIC and TCP transports.
@@ -274,7 +311,7 @@ pub struct ServerMimicrySection {
/// Top-level `client.toml` document.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientConfigFile {
/// `[client]` section: identity and server address.
/// `[client]` section: identity and server address (and v3.1 / v3.2 `[client.circuit]` sub).
pub client: ClientSection,
/// `[pki]` section: CA + leaf cert/key file paths.
pub pki: PkiSection,
@@ -286,26 +323,106 @@ pub struct ClientConfigFile {
/// `[transport]` section: fallback order and per-transport ports/options.
#[serde(default)]
pub transport: TransportSection,
/// `[client.circuit]` section: v3.1 multi-hop / onion routing dial. When `enabled = true`,
/// instead of dialing the server directly via [`aura_transport::dial`], the client builds a
/// 2-hop circuit `client → entry-relay → exit-server` from `hops`. Default `enabled = false`.
#[serde(default)]
pub circuit: CircuitSection,
}
/// `[client.circuit]` section: v3.1 multi-hop / onion routing on the client.
impl ClientConfigFile {
/// Shorthand accessor for `[client.circuit]`. The section lives on the [`ClientSection`] so
/// the TOML key path matches (`client.circuit`); callers conventionally write `cfg.circuit`.
pub fn circuit(&self) -> &CircuitSection {
&self.client.circuit
}
}
/// `[client.circuit]` section: v3.1 / v3.2 multi-hop / onion routing on the client.
///
/// See the module-level docs of [`crate::circuit`] for the wire protocol. When `enabled = true`,
/// `hops` MUST contain exactly two `IP:port` entries — the entry relay (UDP) and the exit server
/// (UDP). v3.1 supports only UDP transport for both hops; configuring `enabled = true` with a
/// non-UDP transport order is a hard error at dial time (the dial helper checks the order).
/// See the module-level docs of [`crate::circuit`] for the wire protocol.
///
/// ## Two hop formats (both accepted)
///
/// **v3.1 flat** (back-compat — every hop uses the global `[pki]` cert/key):
///
/// ```toml
/// [client.circuit]
/// enabled = true
/// hops = ["198.51.100.5:443", "203.0.113.10:443"]
/// ```
///
/// **v3.2 per-hop** (each hop carries its own client cert so the entry and the exit cannot
/// link the two handshakes by certificate CN):
///
/// ```toml
/// [client.circuit]
/// enabled = true
///
/// [[client.circuit.hops]]
/// addr = "198.51.100.5:443"
/// cert_path = "~/.config/aura/circuit/entry.crt"
/// key_path = "~/.config/aura/circuit/entry.key"
///
/// [[client.circuit.hops]]
/// addr = "203.0.113.10:443"
/// cert_path = "~/.config/aura/circuit/exit.crt"
/// key_path = "~/.config/aura/circuit/exit.key"
/// ```
///
/// In v3.2 the `hops` array MAY also mix string entries with table entries — the string entries
/// fall back to the global `[pki]` cert/key, as in v3.1.
///
/// `hops.len()` must be 2 OR 3 (v3.2 extended). v3.1 only accepted 2.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct CircuitSection {
/// Master switch. `false` (default) keeps the v2 single-hop dial path.
pub enabled: bool,
/// Ordered list of hops: `[entry_relay, exit_server]`. Exactly two literal `IP:port` entries.
pub hops: Vec<String>,
/// Ordered list of hops. Each entry is either a literal `"IP:port"` string (v3.1 flat
/// format — uses the global `[pki]` cert/key) or a table with per-hop overrides:
/// `{ addr, cert_path, key_path, server_name? }` (v3.2). Serde's `untagged` enum
/// resolves the two formats transparently.
pub hops: Vec<CircuitHop>,
/// v3.2: pad every outgoing packet to a constant `cell_size`-byte cell before sending it
/// through the circuit. Must match the relay's `[server.relay] cell_padding`. Default `false`.
pub cell_padding: bool,
/// v3.2: cell size in bytes when `cell_padding = true`. Default 1280. Must match the relay's
/// `[server.relay] cell_size`.
#[serde(default = "default_cell_size")]
pub cell_size: usize,
}
/// One entry in `[[client.circuit.hops]]`. Accepts either a flat `"IP:port"` string (v3.1 back
/// compat — uses the global `[pki]` cert/key for the outer handshake to this hop) or a table with
/// per-hop cert/key overrides (v3.2). The two variants are distinguished by serde's
/// `#[serde(untagged)]`.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum CircuitHop {
/// v3.1 flat: just the wire `IP:port`. The hop's outer handshake uses the client's global
/// `[pki]` cert/key, same as every other hop — NOT identity-unlinkable.
Addr(String),
/// v3.2 full: `IP:port` plus per-hop cert/key paths. The optional `server_name` overrides the
/// SAN expected on this hop's server cert (defaults to the global `[client] sni`).
Full {
/// Wire address of the hop.
addr: String,
/// PEM file holding this client's certificate for the handshake to **this hop**. Path may
/// begin with `~`.
cert_path: PathBuf,
/// PEM file holding the matching PKCS#8 private key. Path may begin with `~`.
key_path: PathBuf,
/// Optional SAN expected on the hop's server cert. When omitted, the global `[client] sni`
/// is used (matching v3.1 behaviour where every hop's SAN comes from one place).
#[serde(default)]
server_name: Option<String>,
},
}
impl CircuitHop {
/// The wire address of this hop, regardless of variant.
pub fn addr(&self) -> &str {
match self {
Self::Addr(s) => s.as_str(),
Self::Full { addr, .. } => addr.as_str(),
}
}
}
/// `[client]` section.
@@ -332,6 +449,12 @@ pub struct ClientSection {
/// See [`crate::dial_targets::build_dial_targets`].
#[serde(default)]
pub bridges: Vec<String>,
/// `[client.circuit]` sub-section: v3.1 / v3.2 multi-hop / onion routing dial. When
/// `enabled = true`, instead of dialing the server directly via [`aura_transport::dial`], the
/// client builds an N-hop circuit (N = 2 or 3) from `hops`. Default `enabled = false`.
/// Living inside `[client]` matches the TOML path operators write: `[client.circuit]`.
#[serde(default)]
pub circuit: CircuitSection,
}
/// `[tunnel]` section of `client.toml`.
@@ -859,23 +982,50 @@ impl ServerConfigFile {
TcpOpts::default()
}
/// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s, skipping (with a
/// `warn` log) any entries that are not bare `IP:port` strings. v3.1 does NOT perform DNS
/// resolution; the operator must supply literal IPs.
/// Parse `[server.relay] allow_extend_to` into a vector of [`SocketAddr`]s (v3.1 back
/// compat). Use [`Self::relay_allow_rules`] if you also want to honour CIDR entries
/// introduced in v3.2.
///
/// Returns the parsed addresses paired with their original strings (so the caller can log
/// what was skipped). An empty result for a non-empty config means every entry was unparsable.
/// Returns the parsed addresses. Non-`IP:port` entries are skipped with a warn log.
pub fn relay_whitelist(&self) -> Vec<SocketAddr> {
let mut out = Vec::new();
for raw in &self.server.relay.allow_extend_to {
match raw.parse::<SocketAddr>() {
Ok(a) => out.push(a),
Err(e) => {
Err(_) => {
// v3.2 may have non-literal entries (CIDRs); skip silently here — the v3.2
// path uses [`Self::relay_allow_rules`] which understands both.
}
}
}
out
}
/// v3.2: parse `[server.relay] allow_extend_to` into a list of structured allow-rules that
/// may be literal `IP:port`, bare CIDR (any port), or CIDR with an explicit port. The
/// returned vector is meant to be fed straight to [`RelayAllowRule::matches`].
///
/// Format:
///
/// * `"203.0.113.10:443"` — exact `SocketAddr`.
/// * `"10.0.0.0/24"` — any port at any IP in the IPv4 subnet.
/// * `"10.0.0.0/24:443"` — port 443 at any IP in the IPv4 subnet.
/// * `"[2001:db8::/32]:443"` — port 443 at any IP in the IPv6 subnet (square-bracket form).
/// * `"2001:db8::/32"` — any port at any IP in the IPv6 subnet (no port).
///
/// Unparseable entries are logged at WARN and skipped. An empty result for a non-empty config
/// means every entry was rejected; the caller decides whether to refuse all extends or to
/// treat that as an open relay.
pub fn relay_allow_rules(&self) -> Vec<RelayAllowRule> {
let mut out = Vec::new();
for raw in &self.server.relay.allow_extend_to {
match RelayAllowRule::parse(raw) {
Some(r) => out.push(r),
None => {
tracing::warn!(
entry = %raw,
error = %e,
"[server.relay] allow_extend_to: skipping entry — only literal IP:port is \
supported in v3.1 (DNS resolution is out of scope)"
"[server.relay] allow_extend_to: skipping unparseable entry \
(expected IP:port, CIDR, or CIDR:port)"
);
}
}
@@ -884,6 +1034,92 @@ impl ServerConfigFile {
}
}
/// A single entry in `[server.relay] allow_extend_to`, normalised to one of three shapes:
///
/// * [`RelayAllowRule::Exact`] — literal `IP:port`, matches only that exact `SocketAddr`.
/// * [`RelayAllowRule::Cidr`] — bare CIDR, matches any port at any IP in the subnet.
/// * [`RelayAllowRule::CidrPort`] — CIDR with explicit port, matches only that port at any IP in
/// the subnet.
///
/// `matches(addr)` returns `true` when the given destination satisfies the rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelayAllowRule {
/// Exact `SocketAddr` match — the v3.1 literal-IP:port form.
Exact(SocketAddr),
/// CIDR with no port restriction.
Cidr(IpNetwork),
/// CIDR with a specific port.
CidrPort(IpNetwork, u16),
}
impl RelayAllowRule {
/// Parse one `allow_extend_to` entry. Returns `None` on any format error (the caller is
/// expected to log at WARN).
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if s.is_empty() {
return None;
}
// Detect the IPv6-with-explicit-port form first: `[...]:port`.
if let Some(stripped) = s.strip_prefix('[') {
// Find the closing bracket. Whatever follows must be `:port` (or empty for bare).
if let Some(end) = stripped.find(']') {
let inside = &stripped[..end];
let after = &stripped[end + 1..];
// `inside` is either a bare IPv6 (no slash) or an IPv6 CIDR.
let net = if inside.contains('/') {
inside.parse::<IpNetwork>().ok()?
} else {
// bare IPv6: treat as a /128 CIDR for uniformity.
let ip: std::net::Ipv6Addr = inside.parse().ok()?;
IpNetwork::V6(ipnetwork::Ipv6Network::new(ip, 128).ok()?)
};
if after.is_empty() {
return Some(Self::Cidr(net));
}
let port = after.strip_prefix(':')?.parse::<u16>().ok()?;
return Some(Self::CidrPort(net, port));
}
return None;
}
// Not IPv6-bracketed: try as a literal SocketAddr first (v4 `1.2.3.4:443`, or v6 in plain
// form — though the latter wouldn't fit here without brackets, leave it to SocketAddr).
if let Ok(a) = s.parse::<SocketAddr>() {
return Some(Self::Exact(a));
}
// Try CIDR (with optional port suffix). Split on `:` *after* the slash so we do not eat
// an IPv6 inside a bracket — we already handled that branch above.
if let Some(slash) = s.find('/') {
// Everything before slash is the IP; everything after slash is `prefix[:port]`.
let ip_part = &s[..slash];
let after = &s[slash + 1..];
// If `after` contains a colon, port is the trailing piece.
if let Some(colon) = after.find(':') {
let prefix_str = &after[..colon];
let port_str = &after[colon + 1..];
let prefix: u8 = prefix_str.parse().ok()?;
let port: u16 = port_str.parse().ok()?;
let ip: IpAddr = ip_part.parse().ok()?;
let net = IpNetwork::new(ip, prefix).ok()?;
return Some(Self::CidrPort(net, port));
} else {
let net: IpNetwork = s.parse().ok()?;
return Some(Self::Cidr(net));
}
}
None
}
/// Does this rule allow `addr`?
pub fn matches(&self, addr: SocketAddr) -> bool {
match self {
Self::Exact(a) => *a == addr,
Self::Cidr(net) => net.contains(addr.ip()),
Self::CidrPort(net, p) => *p == addr.port() && net.contains(addr.ip()),
}
}
}
impl ClientConfigFile {
/// Parse a `client.toml` document from a string.
pub fn parse(text: &str) -> anyhow::Result<Self> {
@@ -959,29 +1195,91 @@ impl ClientConfigFile {
})
}
/// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Returns an error if any
/// entry fails to parse as `IP:port` or the count is wrong for v3.1 (exactly 2). When
/// `[client.circuit]` is disabled this still validates the configured hops so misconfiguration
/// is caught early; the caller decides whether to actually use the result.
///
/// v3.1 does NOT perform DNS resolution; the operator must supply literal IPs.
/// Parse `[client.circuit] hops` into a vector of [`SocketAddr`]s. Both the v3.1 flat string
/// form and the v3.2 per-hop table form are accepted (the addresses are extracted from
/// either). Returns an error if any address fails to parse or the count is wrong for v3.2
/// (must be 2 or 3 when enabled).
pub fn circuit_hops(&self) -> anyhow::Result<Vec<SocketAddr>> {
let mut out = Vec::with_capacity(self.circuit.hops.len());
for raw in &self.circuit.hops {
let mut out = Vec::with_capacity(self.client.circuit.hops.len());
for hop in &self.client.circuit.hops {
let raw = hop.addr();
let addr: SocketAddr = raw.parse().with_context(|| {
format!("invalid [client.circuit] hop '{raw}' (expected IP:port)")
format!("invalid [client.circuit] hop addr '{raw}' (expected IP:port)")
})?;
out.push(addr);
}
if self.circuit.enabled && out.len() != 2 {
if self.client.circuit.enabled && !(2..=3).contains(&out.len()) {
return Err(anyhow!(
"[client.circuit] requires exactly 2 hops (entry, exit) in v3.1; got {}",
"[client.circuit] requires 2 or 3 hops in v3.2; got {}",
out.len()
));
}
Ok(out)
}
/// v3.2: build the per-hop dial configs for [`crate::circuit::dial_circuit`].
///
/// For each `CircuitHop` entry:
///
/// * [`CircuitHop::Addr`] (flat string): uses the global `[pki]` cert/key and the global
/// `[client] sni` as the expected server SAN (v3.1 back compat).
/// * [`CircuitHop::Full`] (table): loads the per-hop cert/key PEMs and applies the optional
/// `server_name` override (defaulting to `[client] sni`).
pub fn build_circuit_hop_configs(&self) -> anyhow::Result<Vec<crate::circuit::HopConfig>> {
let mut hops = Vec::with_capacity(self.client.circuit.hops.len());
// Cache the global PKI cert/key once — every flat entry needs them.
let global_ca = read_pem(&self.pki.ca_cert)?;
let global_cert = read_pem(&self.pki.cert)?;
let global_key = read_pem(&self.pki.key)?;
for hop in &self.client.circuit.hops {
match hop {
CircuitHop::Addr(s) => {
let addr: SocketAddr = s.parse().with_context(|| {
format!("invalid [client.circuit] hop addr '{s}' (expected IP:port)")
})?;
let proto_cfg = aura_proto::ClientConfig {
ca_cert_pem: global_ca.clone(),
client_cert_pem: global_cert.clone(),
client_key_pem: global_key.clone(),
server_name: self.client.sni.clone(),
};
hops.push(crate::circuit::HopConfig { addr, proto_cfg });
}
CircuitHop::Full {
addr,
cert_path,
key_path,
server_name,
} => {
let parsed_addr: SocketAddr = addr.parse().with_context(|| {
format!("invalid [client.circuit] hop addr '{addr}' (expected IP:port)")
})?;
let cert_pem = read_pem(&cert_path.to_string_lossy())?;
let key_pem = read_pem(&key_path.to_string_lossy())?;
let proto_cfg = aura_proto::ClientConfig {
ca_cert_pem: global_ca.clone(),
client_cert_pem: cert_pem,
client_key_pem: key_pem,
server_name: server_name
.clone()
.unwrap_or_else(|| self.client.sni.clone()),
};
hops.push(crate::circuit::HopConfig {
addr: parsed_addr,
proto_cfg,
});
}
}
}
if self.client.circuit.enabled && !(2..=3).contains(&hops.len()) {
return Err(anyhow!(
"[client.circuit] requires 2 or 3 hops in v3.2; got {}",
hops.len()
));
}
Ok(hops)
}
/// Build a [`RouteTable`] from `[tunnel.split]`.
///
/// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`]
@@ -1626,4 +1924,253 @@ order = ["udp", "smoke-signals"]
let cfg = ClientConfigFile::parse(bad).expect("parse");
assert!(cfg.dial_config().is_err());
}
// -------- v3.2: [[client.circuit.hops]] / CIDR whitelist / cell_padding ---------------------
/// v3.1 back-compat: the flat `hops = ["a:port", "b:port"]` form still parses, with the v3.2
/// `CircuitSection::hops` now typed as `Vec<CircuitHop>` via the `untagged` enum. The TOML
/// table is `[client.circuit]` because `CircuitSection` lives inside `ClientSection`.
#[test]
fn circuit_v3_1_flat_hops_back_compat() {
let c = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "vpn.example.com"
[client.circuit]
enabled = true
hops = ["198.51.100.5:443", "203.0.113.10:443"]
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(c).expect("parse v3.1 flat hops");
assert!(cfg.client.circuit.enabled);
assert_eq!(cfg.client.circuit.hops.len(), 2);
match &cfg.client.circuit.hops[0] {
CircuitHop::Addr(s) => assert_eq!(s, "198.51.100.5:443"),
_ => panic!("expected flat Addr variant"),
}
let addrs = cfg.circuit_hops().expect("addrs");
assert_eq!(addrs.len(), 2);
assert_eq!(addrs[0].to_string(), "198.51.100.5:443");
// cell_padding defaults to false (v3.1 behaviour).
assert!(!cfg.client.circuit.cell_padding);
assert_eq!(cfg.client.circuit.cell_size, 1280);
}
/// v3.2 per-hop format: `[[client.circuit.hops]]` tables parse and `build_circuit_hop_configs`
/// honours per-hop cert/key paths (the read fails here because the paths point at synthetic
/// names; we only check addr-level parsing in this test).
#[test]
fn circuit_v3_2_per_hop_table_parses() {
let c = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "vpn.example.com"
[client.circuit]
enabled = true
[[client.circuit.hops]]
addr = "198.51.100.5:443"
cert_path = "/path/entry.crt"
key_path = "/path/entry.key"
[[client.circuit.hops]]
addr = "203.0.113.10:443"
cert_path = "/path/exit.crt"
key_path = "/path/exit.key"
server_name = "alt-exit.example.com"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(c).expect("parse v3.2 per-hop hops");
assert!(cfg.client.circuit.enabled);
assert_eq!(cfg.client.circuit.hops.len(), 2);
match &cfg.client.circuit.hops[1] {
CircuitHop::Full {
addr,
cert_path,
key_path,
server_name,
} => {
assert_eq!(addr, "203.0.113.10:443");
assert_eq!(cert_path.to_string_lossy(), "/path/exit.crt");
assert_eq!(key_path.to_string_lossy(), "/path/exit.key");
assert_eq!(server_name.as_deref(), Some("alt-exit.example.com"));
}
_ => panic!("expected Full variant for hop[1]"),
}
let addrs = cfg.circuit_hops().expect("addrs");
assert_eq!(addrs.len(), 2);
assert_eq!(addrs[1].to_string(), "203.0.113.10:443");
}
/// v3.2 allows 3 hops (entry, middle, exit) — both for the addr-only validator and as part of
/// the new per-hop tables.
#[test]
fn circuit_v3_2_three_hops_parses() {
let c = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "vpn.example.com"
[client.circuit]
enabled = true
hops = ["198.51.100.5:443", "198.51.100.99:443", "203.0.113.10:443"]
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(c).expect("parse");
let addrs = cfg.circuit_hops().expect("addrs");
assert_eq!(addrs.len(), 3);
assert_eq!(addrs[2].to_string(), "203.0.113.10:443");
}
/// `[client.circuit] cell_padding = true` parses and the `cell_size` default kicks in.
#[test]
fn circuit_cell_padding_flag_parses() {
let c = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "vpn.example.com"
[client.circuit]
enabled = true
hops = ["198.51.100.5:443", "203.0.113.10:443"]
cell_padding = true
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(c).expect("parse");
assert!(cfg.client.circuit.cell_padding);
assert_eq!(cfg.client.circuit.cell_size, 1280);
}
/// `[server.relay]` allow_extend_to with an exact `IP:port` matches only that exact address.
#[test]
fn cidr_whitelist_exact_ip() {
let rule = RelayAllowRule::parse("203.0.113.10:443").expect("parse exact");
assert!(rule.matches("203.0.113.10:443".parse().unwrap()));
assert!(!rule.matches("203.0.113.10:444".parse().unwrap()));
assert!(!rule.matches("203.0.113.11:443".parse().unwrap()));
}
/// CIDR with no port matches any port at any IP in the subnet (v3.2).
#[test]
fn cidr_whitelist_subnet() {
let rule = RelayAllowRule::parse("10.0.0.0/24").expect("parse cidr");
assert!(rule.matches("10.0.0.5:443".parse().unwrap()));
assert!(rule.matches("10.0.0.250:8080".parse().unwrap()));
assert!(
!rule.matches("10.0.1.5:443".parse().unwrap()),
"outside /24"
);
assert!(!rule.matches("11.0.0.5:443".parse().unwrap()));
}
/// CIDR with explicit port matches only that port within the subnet.
#[test]
fn cidr_whitelist_subnet_with_port() {
let rule = RelayAllowRule::parse("10.0.0.0/24:443").expect("parse cidr+port");
assert!(rule.matches("10.0.0.5:443".parse().unwrap()));
assert!(
!rule.matches("10.0.0.5:8080".parse().unwrap()),
"wrong port"
);
assert!(
!rule.matches("11.0.0.5:443".parse().unwrap()),
"outside subnet"
);
}
/// IPv6 CIDR forms: bare `2001:db8::/32` (no port) and `[2001:db8::/32]:443` (with port).
#[test]
fn cidr_whitelist_v6() {
let bare = RelayAllowRule::parse("2001:db8::/32").expect("parse v6 cidr");
assert!(bare.matches("[2001:db8::1]:443".parse().unwrap()));
assert!(bare.matches("[2001:db8:abcd::5]:9999".parse().unwrap()));
assert!(
!bare.matches("[2001:db9::1]:443".parse().unwrap()),
"outside /32"
);
let with_port = RelayAllowRule::parse("[2001:db8::/32]:443").expect("parse v6 cidr+port");
assert!(with_port.matches("[2001:db8::1]:443".parse().unwrap()));
assert!(
!with_port.matches("[2001:db8::1]:8080".parse().unwrap()),
"wrong port on v6 cidr+port rule"
);
}
/// `relay_allow_rules` parses a heterogeneous list (literal + CIDR + CIDR:port) and skips bad
/// entries with a warn log (still returning the valid ones).
#[test]
fn relay_allow_rules_heterogeneous_list() {
let s = r#"
[server]
name = "edge"
[server.relay]
enabled = true
allow_extend_to = [
"203.0.113.10:443",
"10.0.0.0/24",
"10.1.0.0/24:443",
"garbage-not-an-ip",
]
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(s).expect("parse");
let rules = cfg.relay_allow_rules();
assert_eq!(rules.len(), 3, "3 valid rules; 1 garbage entry skipped");
assert!(rules[0].matches("203.0.113.10:443".parse().unwrap()));
assert!(rules[1].matches("10.0.0.5:9999".parse().unwrap()));
assert!(rules[2].matches("10.1.0.5:443".parse().unwrap()));
assert!(!rules[2].matches("10.1.0.5:444".parse().unwrap()));
}
/// `[server.relay] cell_padding` parses and the default `cell_size` kicks in (1280).
#[test]
fn relay_cell_padding_parses() {
let s = r#"
[server]
name = "edge"
[server.relay]
enabled = true
allow_extend_to = ["203.0.113.10:443"]
cell_padding = true
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(s).expect("parse");
assert!(cfg.server.relay.cell_padding);
assert_eq!(cfg.server.relay.cell_size, 1280);
}
}