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:
+585
-38
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user