feat(proto,cli): v3.1 multi-hop scaffold — control kinds + config sections
Foundation for v3.1 onion routing (client → entry-relay → exit-server).
The relay/circuit runtime is implemented in a follow-up commit; this
scaffold lands the wire-level control extensions and the config schema:
- aura-proto: ControlKind gains ExtendBridge (client→relay), CircuitReady
(relay→client), CircuitFailed (relay→client, with utf-8 reason); helpers
encode_extend_bridge / decode_extend_bridge (1-byte family + 4/16 addr
bytes + u16 port). Integration test in tests/control_extend.rs covers
IPv4/IPv6 roundtrip + full magic-envelope wrap.
- aura-cli config: [server.relay] {enabled, allow_extend_to} +
[client.circuit] {enabled, hops} sections; relay_whitelist() helper
parses IP:port literals. All new fields serde-default, back-compat.
- crl_push.rs touched only to leave the new ControlKinds passing through
the existing magic-envelope dispatcher unchanged.
Workspace: 247 tests passed (+12), clippy/fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,14 @@ pub struct ServerSection {
|
||||
/// this is the v1 behaviour where the operator manually pre-configures forwarding.
|
||||
#[serde(default)]
|
||||
pub nat: Option<ServerNatSection>,
|
||||
/// `[server.relay]` sub-section: v3.1 multi-hop / onion routing role. When `enabled = true`,
|
||||
/// this server runs as an **entry-relay** — it briefly listens for a client-issued
|
||||
/// `ExtendBridge` control envelope right after the handshake and (if accepted) splices the
|
||||
/// connection to a downstream exit-server. Omitting the section (or `enabled = false`) keeps
|
||||
/// the v1/v2 behaviour where every accepted connection is registered with the
|
||||
/// [`crate::server_router::ServerRouter`] as a normal VPN client.
|
||||
#[serde(default)]
|
||||
pub relay: RelaySection,
|
||||
/// Optional non-root user to drop privileges to **after** all startup work that needs root
|
||||
/// (TUN open, low-port bind, NAT configuration). When omitted (or already non-root) the
|
||||
/// server keeps its current credentials.
|
||||
@@ -122,6 +130,30 @@ pub struct ServerSection {
|
||||
pub no_logs: bool,
|
||||
}
|
||||
|
||||
/// `[server.relay]` section: v3.1 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
|
||||
/// client-issued [`aura_proto::ControlKind::ExtendBridge`] envelope describing a downstream
|
||||
/// `exit_addr`. When the address matches one of `allow_extend_to`, the server opens a raw
|
||||
/// UDP bridge to that exit and forwards every byte between the client and the exit verbatim —
|
||||
/// the inner client↔exit Aura handshake passes through opaquely, so the relay never sees
|
||||
/// destination IPs or plaintext bytes.
|
||||
///
|
||||
/// Omitting the section (the default) gives the v2 behaviour: every accepted connection is a
|
||||
/// VPN client and the relay path is dead code.
|
||||
#[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.
|
||||
pub allow_extend_to: Vec<String>,
|
||||
}
|
||||
|
||||
/// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback
|
||||
/// semantics. Optional — when the section is omitted the server makes no changes to the host's
|
||||
/// IP forwarding state, matching v1 behaviour.
|
||||
@@ -182,6 +214,26 @@ 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.
|
||||
///
|
||||
/// 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).
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// `[client]` section.
|
||||
@@ -734,6 +786,30 @@ impl ServerConfigFile {
|
||||
pub fn tcp_opts(&self) -> TcpOpts {
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
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) => {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConfigFile {
|
||||
@@ -811,6 +887,29 @@ 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.
|
||||
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 addr: SocketAddr = raw
|
||||
.parse()
|
||||
.with_context(|| format!("invalid [client.circuit] hop '{raw}' (expected IP:port)"))?;
|
||||
out.push(addr);
|
||||
}
|
||||
if self.circuit.enabled && out.len() != 2 {
|
||||
return Err(anyhow!(
|
||||
"[client.circuit] requires exactly 2 hops (entry, exit) in v3.1; got {}",
|
||||
out.len()
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Build a [`RouteTable`] from `[tunnel.split]`.
|
||||
///
|
||||
/// CIDR rules are applied directly. Domain rules are recorded via [`RouteTable::add_domain`]
|
||||
|
||||
Reference in New Issue
Block a user