Files
AuraVPN/crates/aura-cli/src/admin.rs
T
xah30 ba8d6b796f feat(transport,cli,tunnel): v3.4 port auto-detect + bug fixes from live test
Live macOS test against the production server uncovered six bugs (one of which
turned out to be a port collision with sing-box, not a real bug); this commit
addresses all of them and adds v3.4 port discovery so the same collision is
handled transparently next time.

## v3.4 server port-discovery

- Defaults moved off 443/444 to 8443/8443/8444 (TransportSection::default,
  ServerInitOpts, ProvisionClientOpts, CLI flags). 443 is heavily contested in
  practice (sing-box, Hysteria2, reverse proxies) and the previous default
  silently lost the bind when a co-tenant was already there.
- MultiServer::bind_with_outer_or_scan: scans forward up to
  DEFAULT_PORT_SCAN_MAX (20) candidates per transport when the requested port
  is occupied; QUIC keeps walking if it lands on the custom-UDP port.
- MultiServer::bound_addrs(): the actual addresses each transport bound to.
- Server logs the bound addresses and writes a runtime snapshot
  (server.toml.runtime.json) when they differ from the requested ones, so
  `aura sign-bridges` can re-sign the bridges manifest later.
- BridgeManifest gains an optional `endpoints: Vec<BridgeEndpoint>` field
  with per-transport ports. Backward-compatible: old v3.3 clients ignore the
  field and continue to use the v1 `bridges` line.
- `aura sign-bridges --endpoints HOST:tcp=N:quic=N:udp=N` to mint v3.4
  manifests; bridges line is auto-synthesised for v3.3 clients.

## Bug fixes from the live test

- macOS TUN naming (#41): the tun crate rejects names that don't match
  ^utun[0-9]+$. On macOS we now substitute `""` (kernel auto-assigns utunN),
  capture the assigned name via inner.tun_name(), and propagate it through to
  os_routes::OsRouteGuard::install — so `route add -interface utunN` uses
  the real interface, not "aura0".
- Packet counters (#42): Stats { tx_packets, rx_packets } are now actually
  bumped by the data path. `aura status` shows live numbers instead of
  permanent zeros.
- render_client_toml schema (#44): provisioner emits proper
  `[[tunnel.split.vpn]] cidr = "..."` / `[[tunnel.split.direct]]` blocks from
  new --vpn-cidrs / --direct-cidrs flags. The v3.3 `vpn_cidrs = [...]` flat
  array was silently ignored by serde, leaving users with `rules: 0` even
  when their CIDRs looked right.
- #43 / #46 (TCP/443 dial early-eof / no payload back): diagnosed as the
  sing-box port collision, not an Aura bug. The v3.4 port-scan path makes it
  go away — the server picks a free port and clients learn it from the
  manifest.

## Test coverage

Three new unit tests for the port-scanner (UDP busy, TCP busy, zero budget);
two new tests for v3.4 BridgeManifest round-trip with endpoints; one
integration test for the new `[[tunnel.split.vpn]]` rendering; tests for the
runtime-state file write/read round-trip; agent-added router-counter tests
in aura-tunnel/tests/routes.rs.

cargo test --workspace, cargo clippy --workspace -- -D warnings, and
cargo fmt --check all pass.

#45 (silent client exit when underlying QUIC transport breaks) is still
outstanding — needs deeper investigation; deferred to a follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 17:14:45 +03:00

746 lines
27 KiB
Rust

//! Admin IPC: a tiny JSON line protocol over a Unix domain socket (Unix) or a named pipe (Windows).
//!
//! A running `aura server` / `aura client` hosts a [`serve`] listener over a shared [`AdminState`]
//! (the live `RouteTable`, a rule mirror, and tunnel [`Stats`]). The `aura route ...` and
//! `aura status` subcommands connect to the same socket and exchange one JSON object per line:
//!
//! ```text
//! -> {"cmd":"route_add","cidr":"8.8.8.0/24","action":"direct"}
//! <- {"ok":true}
//! -> {"cmd":"route_add","domain":"example.com","action":"vpn"}
//! <- {"ok":true}
//! -> {"cmd":"route_list"}
//! <- {"ok":true,"default":"vpn","cidrs":[{"cidr":"8.8.8.0/24","action":"direct"}],"domains":[...]}
//! -> {"cmd":"route_remove","cidr":"8.8.8.0/24"}
//! <- {"ok":true,"removed":true}
//! -> {"cmd":"status"}
//! <- {"ok":true,"peer_id":"client-1","rx_packets":0,"tx_packets":0,"default":"vpn","rules":1}
//! ```
//!
//! On error a response is `{"ok":false,"error":"..."}`.
//!
//! ## Why a rule mirror
//! The library [`RouteTable`] is the source of truth for *classification* but does not expose an
//! iterator over its rules, so the admin layer keeps a parallel [`RuleMirror`] updated in lockstep.
//! Every admin mutation touches both, so `route_list` can faithfully echo what is configured while
//! `classify` still goes through the real table.
//!
//! ## Cross-platform transport
//! The wire protocol is identical; only the per-platform stream type differs:
//!
//! * **Unix**: `tokio::net::UnixListener` / `UnixStream` over `/tmp/aura-admin.sock`.
//! * **Windows**: `tokio::net::windows::named_pipe::{NamedPipeServer, NamedPipeClient}` over
//! `\\.\pipe\aura-admin`. The standard Tokio pattern is to rebuild a fresh `ServerOptions`
//! instance after every accept so subsequent clients can also connect.
//!
//! See [`transport`] for the platform-specific listen/connect glue. The handler ([`handle_request`])
//! and the wire types are platform-agnostic.
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use aura_tunnel::{PacketCounters, RouteAction, RouteTable};
use ipnetwork::IpNetwork;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::RwLock;
use crate::config::parse_action;
/// Default admin transport endpoint used when a config / flag does not override it. On Unix this
/// is a filesystem path under `/tmp`; on Windows it is a named pipe path under `\\.\pipe\`.
#[cfg(unix)]
pub const DEFAULT_SOCKET: &str = "/tmp/aura-admin.sock";
/// Default admin transport endpoint on Windows: a named pipe in the local pipe namespace.
#[cfg(windows)]
pub const DEFAULT_SOCKET: &str = r"\\.\pipe\aura-admin";
/// Live tunnel statistics shared between the data path and the admin listener.
///
/// The two packet counters are `Arc<AtomicU64>` so the same atomics can be cloned into the
/// [`aura_tunnel::AuraRouter`] (via [`Stats::counters`]) and bumped from the data path. The admin
/// `Status` handler reads them through this struct; `aura status` sees live numbers because both
/// sides are looking at the same memory.
#[derive(Debug, Default)]
pub struct Stats {
/// Packets received from the peer (inbound, toward the TUN).
pub rx_packets: Arc<AtomicU64>,
/// Packets sent to the peer (outbound, from the TUN).
pub tx_packets: Arc<AtomicU64>,
/// Verified peer identity, set once a connection is established.
pub peer_id: StdMutex<Option<String>>,
}
impl Stats {
/// Create a zeroed stats block.
pub fn new() -> Self {
Self::default()
}
/// Record the verified peer identity.
pub fn set_peer_id(&self, id: Option<String>) {
if let Ok(mut g) = self.peer_id.lock() {
*g = id;
}
}
/// Hand out a [`PacketCounters`] handle pointing at the same `tx`/`rx` atomics.
///
/// The CLI passes this into [`aura_tunnel::AuraRouter::with_stats`] / the per-client server
/// router so the data path bumps the same counters the admin `Status` handler reads.
pub fn counters(&self) -> PacketCounters {
PacketCounters {
tx: Arc::clone(&self.tx_packets),
rx: Arc::clone(&self.rx_packets),
}
}
}
/// A parallel record of admin-configured rules, so `route_list` can enumerate them (the library
/// [`RouteTable`] does not expose iteration). Kept in lockstep with the table.
#[derive(Debug, Default)]
pub struct RuleMirror {
/// CIDR rules, ordered for stable listing.
pub cidrs: StdMutex<BTreeMap<IpNetwork, RouteAction>>,
/// Domain rules, ordered for stable listing.
pub domains: StdMutex<BTreeMap<String, RouteAction>>,
}
impl RuleMirror {
/// Build a mirror pre-populated from an existing table snapshot's rules.
///
/// The constructor takes already-extracted rule lists (the config layer has them at build
/// time) so the mirror starts consistent with the table the data path was given.
pub fn from_rules(
cidrs: impl IntoIterator<Item = (IpNetwork, RouteAction)>,
domains: impl IntoIterator<Item = (String, RouteAction)>,
) -> Self {
Self {
cidrs: StdMutex::new(cidrs.into_iter().collect()),
domains: StdMutex::new(domains.into_iter().collect()),
}
}
}
/// Shared state the admin listener operates on.
#[derive(Clone)]
pub struct AdminState {
/// The live split-tunnel routing table (classification source of truth).
pub routes: Arc<RwLock<RouteTable>>,
/// Mirror of configured rules for enumeration.
pub mirror: Arc<RuleMirror>,
/// Live tunnel statistics.
pub stats: Arc<Stats>,
}
impl AdminState {
/// Construct admin state from a shared table and stats, seeding the mirror from the given rules.
pub fn new(
routes: Arc<RwLock<RouteTable>>,
stats: Arc<Stats>,
cidrs: impl IntoIterator<Item = (IpNetwork, RouteAction)>,
domains: impl IntoIterator<Item = (String, RouteAction)>,
) -> Self {
Self {
routes,
mirror: Arc::new(RuleMirror::from_rules(cidrs, domains)),
stats,
}
}
}
// ---- wire protocol ---------------------------------------------------------------------------
/// A request from the `aura route` / `aura status` client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Request {
/// Add a CIDR or domain rule.
RouteAdd {
/// CIDR to add (mutually exclusive with `domain`).
#[serde(default, skip_serializing_if = "Option::is_none")]
cidr: Option<String>,
/// Domain to add (mutually exclusive with `cidr`).
#[serde(default, skip_serializing_if = "Option::is_none")]
domain: Option<String>,
/// Action: `"vpn"` or `"direct"`.
action: String,
},
/// List all rules and the default action.
RouteList,
/// Remove a CIDR rule (by exact network).
RouteRemove {
/// CIDR to remove.
cidr: String,
},
/// Query tunnel statistics.
Status,
}
/// One CIDR rule in a `route_list` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CidrEntry {
/// The CIDR network.
pub cidr: String,
/// The action applied to it.
pub action: String,
}
/// One domain rule in a `route_list` response.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DomainEntry {
/// The domain.
pub domain: String,
/// The action applied to it.
pub action: String,
}
/// A response to a [`Request`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
/// Whether the command succeeded.
pub ok: bool,
/// Error message when `ok` is false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Default action (route_list / status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
/// CIDR rules (route_list).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cidrs: Option<Vec<CidrEntry>>,
/// Domain rules (route_list).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domains: Option<Vec<DomainEntry>>,
/// Whether a `route_remove` actually removed something.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed: Option<bool>,
/// Verified peer id (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub peer_id: Option<String>,
/// Inbound packet count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rx_packets: Option<u64>,
/// Outbound packet count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tx_packets: Option<u64>,
/// Total rule count (status).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rules: Option<usize>,
}
impl Response {
/// A bare success response.
pub fn ok() -> Self {
Self {
ok: true,
error: None,
default: None,
cidrs: None,
domains: None,
removed: None,
peer_id: None,
rx_packets: None,
tx_packets: None,
rules: None,
}
}
/// An error response carrying `msg`.
pub fn err(msg: impl Into<String>) -> Self {
Self {
ok: false,
error: Some(msg.into()),
..Self::ok()
}
}
}
/// Render the action string for the wire.
fn action_str(a: RouteAction) -> &'static str {
match a {
RouteAction::Vpn => "vpn",
RouteAction::Direct => "direct",
}
}
/// Apply a single request against the shared state and produce a response.
///
/// Factored out from the socket I/O so it is directly unit-testable.
pub async fn handle_request(state: &AdminState, req: Request) -> Response {
match req {
Request::RouteAdd {
cidr,
domain,
action,
} => {
let action = match parse_action(&action) {
Ok(a) => a,
Err(e) => return Response::err(e.to_string()),
};
match (cidr, domain) {
(Some(cidr), None) => match cidr.parse::<IpNetwork>() {
Ok(net) => {
state.routes.write().await.add_cidr(net, action);
if let Ok(mut m) = state.mirror.cidrs.lock() {
m.insert(net, action);
}
Response::ok()
}
Err(e) => Response::err(format!("invalid cidr '{cidr}': {e}")),
},
(None, Some(domain)) => {
state.routes.write().await.add_domain(&domain, action);
if let Ok(mut m) = state.mirror.domains.lock() {
m.insert(domain, action);
}
Response::ok()
}
(Some(_), Some(_)) => {
Response::err("specify exactly one of 'cidr' or 'domain', not both")
}
(None, None) => Response::err("specify exactly one of 'cidr' or 'domain'"),
}
}
Request::RouteList => {
let default = action_str(state.routes.read().await.default_action()).to_string();
let cidrs = state
.mirror
.cidrs
.lock()
.map(|m| {
m.iter()
.map(|(net, a)| CidrEntry {
cidr: net.to_string(),
action: action_str(*a).to_string(),
})
.collect()
})
.unwrap_or_default();
let domains = state
.mirror
.domains
.lock()
.map(|m| {
m.iter()
.map(|(d, a)| DomainEntry {
domain: d.clone(),
action: action_str(*a).to_string(),
})
.collect()
})
.unwrap_or_default();
Response {
default: Some(default),
cidrs: Some(cidrs),
domains: Some(domains),
..Response::ok()
}
}
Request::RouteRemove { cidr } => match cidr.parse::<IpNetwork>() {
Ok(net) => {
// Removing from the live table requires rebuilding it (no remove API), preserving
// the default and every other rule from the mirror.
let removed = state
.mirror
.cidrs
.lock()
.map(|mut m| m.remove(&net).is_some())
.unwrap_or(false);
if removed {
rebuild_table(state).await;
}
Response {
removed: Some(removed),
..Response::ok()
}
}
Err(e) => Response::err(format!("invalid cidr '{cidr}': {e}")),
},
Request::Status => {
let default = action_str(state.routes.read().await.default_action()).to_string();
let rules = state.mirror.cidrs.lock().map(|m| m.len()).unwrap_or(0)
+ state.mirror.domains.lock().map(|m| m.len()).unwrap_or(0);
let peer_id = state.stats.peer_id.lock().ok().and_then(|g| g.clone());
Response {
default: Some(default),
peer_id,
rx_packets: Some(state.stats.rx_packets.load(Ordering::Relaxed)),
tx_packets: Some(state.stats.tx_packets.load(Ordering::Relaxed)),
rules: Some(rules),
..Response::ok()
}
}
}
}
/// Rebuild the live [`RouteTable`] from the mirror (used after a `route_remove`, since the library
/// table has no per-rule removal). The default action is preserved; domain rules are re-added (their
/// previously resolved host routes are dropped and will be re-resolved on demand — acceptable for an
/// admin remove in v1).
async fn rebuild_table(state: &AdminState) {
let default = state.routes.read().await.default_action();
let mut fresh = RouteTable::new(default);
if let Ok(m) = state.mirror.cidrs.lock() {
for (net, a) in m.iter() {
fresh.add_cidr(*net, *a);
}
}
if let Ok(m) = state.mirror.domains.lock() {
for (d, a) in m.iter() {
fresh.add_domain(d, *a);
}
}
*state.routes.write().await = fresh;
}
// ---- platform transport ---------------------------------------------------------------------
mod transport {
//! Platform glue for the admin transport. The Unix and Windows variants present the same
//! `listen` / `connect` interface so [`super::serve`] / [`super::request`] can be written
//! once over `AsyncRead + AsyncWrite` streams.
#[cfg(unix)]
pub use self::unix::{accept, connect, listen};
#[cfg(windows)]
pub use self::windows::{accept, connect, listen};
#[cfg(unix)]
mod unix {
use std::io;
use tokio::net::{UnixListener, UnixStream};
/// Bind a Unix domain socket at `path`, removing any stale socket file first.
pub fn listen(path: &str) -> io::Result<UnixListener> {
let _ = std::fs::remove_file(path);
UnixListener::bind(path)
}
/// Accept the next admin client. Returns the stream half on success.
pub async fn accept(listener: &UnixListener) -> io::Result<UnixStream> {
let (stream, _addr) = listener.accept().await?;
Ok(stream)
}
/// Connect to a Unix domain socket at `path`.
pub async fn connect(path: &str) -> io::Result<UnixStream> {
UnixStream::connect(path).await
}
}
#[cfg(windows)]
mod windows {
//! Windows transport: named pipe in the local namespace (`\\.\pipe\<name>`).
//!
//! Tokio's `NamedPipeServer` represents one already-bound endpoint. The standard accept
//! pattern is:
//!
//! 1. Build one endpoint with `ServerOptions::new().first_pipe_instance(true).create(path)`.
//! 2. `connect().await` to wait for a client to open the pipe.
//! 3. *Before* serving the request, build a fresh endpoint via the same options so the
//! next client has somewhere to connect — otherwise the namespace entry disappears
//! once we hand the current instance off to the request handler.
//!
//! We model that as a [`Listener`] wrapper that owns the latest "pending" instance plus
//! the `ServerOptions` template.
use std::io;
use tokio::net::windows::named_pipe::{
ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions,
};
use tokio::time::{sleep, Duration};
/// Named-pipe listener. Owns the next-to-be-connected instance.
pub struct Listener {
path: String,
pending: NamedPipeServer,
}
/// Create the initial pipe instance and wrap it in a [`Listener`].
pub fn listen(path: &str) -> io::Result<Listener> {
let pending = ServerOptions::new()
.first_pipe_instance(true)
.create(path)?;
Ok(Listener {
path: path.to_string(),
pending,
})
}
/// Wait for a client, then rebuild the pending instance so subsequent clients can also
/// connect; return the now-connected server endpoint.
pub async fn accept(listener: &mut Listener) -> io::Result<NamedPipeServer> {
listener.pending.connect().await?;
// Rotate: keep the connected instance to return, replace `pending` with a fresh one.
let next = ServerOptions::new().create(&listener.path)?;
let connected = std::mem::replace(&mut listener.pending, next);
Ok(connected)
}
/// Connect to a named pipe at `path`. Retries briefly on `ERROR_PIPE_BUSY` (the kernel
/// returns this when every server instance is busy answering another client; a short
/// pause + retry is the documented idiom).
pub async fn connect(path: &str) -> io::Result<NamedPipeClient> {
// ERROR_PIPE_BUSY = 231.
const PIPE_BUSY: i32 = 231;
for _ in 0..50 {
match ClientOptions::new().open(path) {
Ok(c) => return Ok(c),
Err(e) if e.raw_os_error() == Some(PIPE_BUSY) => {
sleep(Duration::from_millis(20)).await;
}
Err(e) => return Err(e),
}
}
// One last attempt; if it still fails surface the underlying error.
ClientOptions::new().open(path)
}
}
}
/// Run the admin listener until the task is cancelled.
///
/// Binds the platform listener at `path` and serves one request/response per accepted line over
/// the shared `state`. On Unix this is a Unix domain socket; on Windows this is a named pipe.
pub async fn serve(path: &str, state: AdminState) -> anyhow::Result<()> {
#[cfg(unix)]
{
let listener = transport::listen(path)
.map_err(|e| anyhow::anyhow!("binding admin socket {path}: {e}"))?;
tracing::info!(socket = path, "admin IPC listening");
loop {
let stream = match transport::accept(&listener).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "admin accept failed");
continue;
}
};
let state_clone = state.clone();
tokio::spawn(async move {
let (read_half, write_half) = stream.into_split();
serve_connection(read_half, write_half, state_clone).await;
});
}
}
#[cfg(windows)]
{
let mut listener = transport::listen(path)
.map_err(|e| anyhow::anyhow!("binding admin pipe {path}: {e}"))?;
tracing::info!(pipe = path, "admin IPC listening");
loop {
let stream = match transport::accept(&mut listener).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "admin pipe accept failed");
continue;
}
};
let state_clone = state.clone();
// The Tokio NamedPipeServer implements AsyncRead + AsyncWrite directly; we cannot
// `into_split` it the way we do with UnixStream, so wrap it in tokio::io::split.
tokio::spawn(async move {
let (read_half, write_half) = tokio::io::split(stream);
serve_connection(read_half, write_half, state_clone).await;
});
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, state);
anyhow::bail!("admin IPC is not supported on this platform (need unix sockets or windows named pipes)")
}
}
/// Common per-connection loop: read one JSON-line request, write one JSON-line response, repeat
/// until the client disconnects.
async fn serve_connection<R, W>(read_half: R, mut write_half: W, state: AdminState)
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut lines = BufReader::new(read_half).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let resp = match serde_json::from_str::<Request>(&line) {
Ok(req) => handle_request(&state, req).await,
Err(e) => Response::err(format!("bad request: {e}")),
};
let mut buf = serde_json::to_vec(&resp)
.unwrap_or_else(|_| b"{\"ok\":false,\"error\":\"serialize failed\"}".to_vec());
buf.push(b'\n');
if write_half.write_all(&buf).await.is_err() {
break;
}
}
}
/// Connect to the admin transport, send one [`Request`], and return the [`Response`].
pub async fn request(path: &str, req: &Request) -> anyhow::Result<Response> {
#[cfg(unix)]
{
let stream = transport::connect(path).await.map_err(|e| {
anyhow::anyhow!(
"connecting to admin socket {path}: {e} (is `aura server`/`aura client` running?)"
)
})?;
let (read_half, write_half) = stream.into_split();
return request_over(read_half, write_half, req).await;
}
#[cfg(windows)]
{
let stream = transport::connect(path).await.map_err(|e| {
anyhow::anyhow!(
"connecting to admin pipe {path}: {e} (is `aura server`/`aura client` running?)"
)
})?;
let (read_half, write_half) = tokio::io::split(stream);
return request_over(read_half, write_half, req).await;
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, req);
anyhow::bail!("admin IPC is not supported on this platform")
}
}
/// Generic request/response over any split stream.
async fn request_over<R, W>(
read_half: R,
mut write_half: W,
req: &Request,
) -> anyhow::Result<Response>
where
R: tokio::io::AsyncRead + Unpin,
W: tokio::io::AsyncWrite + Unpin,
{
let mut buf = serde_json::to_vec(req)?;
buf.push(b'\n');
write_half.write_all(&buf).await?;
write_half.flush().await?;
let mut lines = BufReader::new(read_half).lines();
let line = lines
.next_line()
.await?
.ok_or_else(|| anyhow::anyhow!("admin socket closed without a response"))?;
Ok(serde_json::from_str(&line)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> AdminState {
AdminState::new(
Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))),
Arc::new(Stats::new()),
std::iter::empty(),
std::iter::empty(),
)
}
#[tokio::test]
async fn route_add_cidr_then_classify_and_list() {
let st = state();
let resp = handle_request(
&st,
Request::RouteAdd {
cidr: Some("8.8.8.0/24".into()),
domain: None,
action: "direct".into(),
},
)
.await;
assert!(resp.ok, "route_add should succeed: {:?}", resp.error);
assert_eq!(
st.routes.read().await.classify("8.8.8.8".parse().unwrap()),
RouteAction::Direct
);
let list = handle_request(&st, Request::RouteList).await;
assert_eq!(list.default.as_deref(), Some("vpn"));
let cidrs = list.cidrs.unwrap();
assert_eq!(cidrs.len(), 1);
assert_eq!(cidrs[0].cidr, "8.8.8.0/24");
assert_eq!(cidrs[0].action, "direct");
}
#[tokio::test]
async fn route_remove_updates_table_and_mirror() {
let st = state();
for cidr in ["8.8.8.0/24", "1.1.1.0/24"] {
handle_request(
&st,
Request::RouteAdd {
cidr: Some(cidr.into()),
domain: None,
action: "direct".into(),
},
)
.await;
}
let resp = handle_request(
&st,
Request::RouteRemove {
cidr: "8.8.8.0/24".into(),
},
)
.await;
assert_eq!(resp.removed, Some(true));
// The removed rule no longer classifies as Direct (falls back to default VPN).
assert_eq!(
st.routes.read().await.classify("8.8.8.8".parse().unwrap()),
RouteAction::Vpn
);
// The other rule survives.
assert_eq!(
st.routes.read().await.classify("1.1.1.1".parse().unwrap()),
RouteAction::Direct
);
let list = handle_request(&st, Request::RouteList).await;
assert_eq!(list.cidrs.unwrap().len(), 1);
}
#[tokio::test]
async fn route_add_rejects_bad_cidr() {
let st = state();
let resp = handle_request(
&st,
Request::RouteAdd {
cidr: Some("not-a-cidr".into()),
domain: None,
action: "vpn".into(),
},
)
.await;
assert!(!resp.ok);
}
#[tokio::test]
async fn status_reports_default_and_counters() {
let st = state();
st.stats.tx_packets.store(5, Ordering::Relaxed);
st.stats.set_peer_id(Some("client-9".into()));
let resp = handle_request(&st, Request::Status).await;
assert!(resp.ok);
assert_eq!(resp.default.as_deref(), Some("vpn"));
assert_eq!(resp.tx_packets, Some(5));
assert_eq!(resp.peer_id.as_deref(), Some("client-9"));
}
/// The platform-default endpoint is set correctly for each target. (Inspection-only on
/// non-host platforms; the cfg picks the right constant at compile time.)
#[test]
fn default_socket_const_is_platform_appropriate() {
#[cfg(unix)]
assert_eq!(DEFAULT_SOCKET, "/tmp/aura-admin.sock");
#[cfg(windows)]
assert_eq!(DEFAULT_SOCKET, r"\\.\pipe\aura-admin");
}
}