c6f0d7af9b
Three v2-hardening features in aura-cli, one pass:
- nat::NatGuard: RAII auto-config of IP forwarding + MASQUERADE on server
startup. Linux (sysctl ip_forward + iptables -t nat MASQUERADE) and
macOS (sysctl ip.forwarding + pfctl with /tmp/aura-nat.conf). dry_run
works on every platform (logs "would run: ..."). Reverts everything in
Drop. New [server.nat] {auto, egress_iface, dry_run}; absent section =
back-compat no-op. Removes v1's "manual NAT/forwarding" step.
- privdrop::drop_to_user: drop euid/gid after binding TUN + privileged
ports. Linux setresuid/setresgid, macOS setgid+setuid (permanent drop),
Windows no-op with warning. New [server] / [client] run_as = "..."
(optional). Skipped with info-log if already non-root.
- admin: split transport into cfg(unix) Unix-socket and cfg(windows) Tokio
named-pipe modules sharing one JSON-line serve/request flow over
AsyncRead/AsyncWrite. DEFAULT_SOCKET = "/tmp/aura-admin.sock" on Unix,
r"\\.\pipe\aura-admin" on Windows. Removes v1's "admin Unix-only".
Deps: nix 0.29 user feature under [target.'cfg(unix)'.dependencies] (cli-
local, not workspace). Workspace: 155 tests passed (+13), clippy -D warnings
clean, fmt clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
268 lines
8.5 KiB
Rust
268 lines
8.5 KiB
Rust
//! Admin socket roundtrip: start the admin listener on a temp Unix socket or Windows named pipe
|
|
//! over a shared [`RouteTable`], connect a client, send `route_add` / `route_list` /
|
|
//! `route_remove` / `status`, and assert the table changed and the responses are correct.
|
|
//!
|
|
//! Runs without root or network (an `AF_UNIX` socket on Unix, a per-pid named pipe on Windows).
|
|
//!
|
|
//! The `windows` half compiles only under `cfg(windows)` (the named-pipe types live in
|
|
//! `tokio::net::windows::named_pipe`); on macOS/Linux the `unix` test runs and the windows
|
|
//! version is excluded by cfg. The cross-platform `cargo build --workspace` still must succeed
|
|
//! everywhere (`cfg(windows)` code is simply excluded on non-Windows hosts).
|
|
|
|
#[cfg(unix)]
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use aura_cli::admin::{self, AdminState, Request, Stats};
|
|
use aura_tunnel::{RouteAction, RouteTable};
|
|
use tokio::sync::RwLock;
|
|
|
|
/// A unique socket path for this test (Unix socket paths are length-limited; temp dir keeps it
|
|
/// short enough on macOS/Linux).
|
|
#[cfg(unix)]
|
|
fn socket_path() -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push(format!(
|
|
"aura-admin-{}-{}.sock",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
p
|
|
}
|
|
|
|
/// A unique named-pipe path for this test (Windows pipes live in `\\.\pipe\`).
|
|
#[cfg(windows)]
|
|
fn pipe_path() -> String {
|
|
format!(
|
|
r"\\.\pipe\aura-admin-test-{}-{}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn admin_socket_route_roundtrip() {
|
|
let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
|
|
let stats = Arc::new(Stats::new());
|
|
stats.set_peer_id(Some("client-test".to_string()));
|
|
let state = AdminState::new(
|
|
Arc::clone(&routes),
|
|
Arc::clone(&stats),
|
|
std::iter::empty(),
|
|
std::iter::empty(),
|
|
);
|
|
|
|
let path = socket_path();
|
|
let path_str = path.to_string_lossy().to_string();
|
|
|
|
// Spawn the listener.
|
|
let serve_path = path_str.clone();
|
|
let listener = tokio::spawn(async move {
|
|
let _ = admin::serve(&serve_path, state).await;
|
|
});
|
|
|
|
// Wait until the socket file exists (the listener binds before serving).
|
|
for _ in 0..200 {
|
|
if path.exists() {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
}
|
|
assert!(path.exists(), "admin socket was not created");
|
|
|
|
// route_add (cidr, direct).
|
|
let resp = admin::request(
|
|
&path_str,
|
|
&Request::RouteAdd {
|
|
cidr: Some("8.8.8.0/24".into()),
|
|
domain: None,
|
|
action: "direct".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_add request");
|
|
assert!(resp.ok, "route_add ok: {:?}", resp.error);
|
|
|
|
// The shared table actually changed.
|
|
assert_eq!(
|
|
routes.read().await.classify("8.8.8.8".parse().unwrap()),
|
|
RouteAction::Direct
|
|
);
|
|
|
|
// route_add (domain, vpn).
|
|
let resp = admin::request(
|
|
&path_str,
|
|
&Request::RouteAdd {
|
|
cidr: None,
|
|
domain: Some("example.com".into()),
|
|
action: "vpn".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_add domain");
|
|
assert!(resp.ok);
|
|
|
|
// route_list reflects both rules and the default.
|
|
let resp = admin::request(&path_str, &Request::RouteList)
|
|
.await
|
|
.expect("route_list");
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.default.as_deref(), Some("vpn"));
|
|
let cidrs = resp.cidrs.expect("cidrs present");
|
|
assert_eq!(cidrs.len(), 1);
|
|
assert_eq!(cidrs[0].cidr, "8.8.8.0/24");
|
|
assert_eq!(cidrs[0].action, "direct");
|
|
let domains = resp.domains.expect("domains present");
|
|
assert_eq!(domains.len(), 1);
|
|
assert_eq!(domains[0].domain, "example.com");
|
|
|
|
// status reflects peer id + default + rule count.
|
|
let resp = admin::request(&path_str, &Request::Status)
|
|
.await
|
|
.expect("status");
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.peer_id.as_deref(), Some("client-test"));
|
|
assert_eq!(resp.default.as_deref(), Some("vpn"));
|
|
assert_eq!(resp.rules, Some(2));
|
|
|
|
// route_remove the CIDR; classification falls back to default VPN.
|
|
let resp = admin::request(
|
|
&path_str,
|
|
&Request::RouteRemove {
|
|
cidr: "8.8.8.0/24".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_remove");
|
|
assert_eq!(resp.removed, Some(true));
|
|
assert_eq!(
|
|
routes.read().await.classify("8.8.8.8".parse().unwrap()),
|
|
RouteAction::Vpn
|
|
);
|
|
|
|
// A malformed CIDR yields an error response (not a panic / disconnect).
|
|
let resp = admin::request(
|
|
&path_str,
|
|
&Request::RouteAdd {
|
|
cidr: Some("nonsense".into()),
|
|
domain: None,
|
|
action: "vpn".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_add bad cidr");
|
|
assert!(!resp.ok);
|
|
assert!(resp.error.is_some());
|
|
|
|
listener.abort();
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
/// Windows analogue of the Unix roundtrip: bind the admin listener on a unique named pipe,
|
|
/// drive a sequence of route_add / route_list / status / route_remove requests through it, and
|
|
/// assert the shared [`RouteTable`] mutated as expected. The wire protocol and `handle_request`
|
|
/// path are identical to Unix; only the transport differs.
|
|
///
|
|
/// Compiled only on Windows (the `windows::named_pipe` module is not available on Unix), but
|
|
/// the file as a whole compiles everywhere so `cargo build --workspace` on a macOS dev host
|
|
/// still type-checks the cfg-gated code path that gets selected at compile time.
|
|
#[cfg(windows)]
|
|
#[tokio::test]
|
|
async fn admin_pipe_route_roundtrip() {
|
|
let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn)));
|
|
let stats = Arc::new(Stats::new());
|
|
stats.set_peer_id(Some("client-test".to_string()));
|
|
let state = AdminState::new(
|
|
Arc::clone(&routes),
|
|
Arc::clone(&stats),
|
|
std::iter::empty(),
|
|
std::iter::empty(),
|
|
);
|
|
|
|
let path = pipe_path();
|
|
|
|
// Spawn the listener.
|
|
let serve_path = path.clone();
|
|
let listener = tokio::spawn(async move {
|
|
let _ = admin::serve(&serve_path, state).await;
|
|
});
|
|
|
|
// Give the listener a moment to bind the pipe (the named-pipe accept loop is async; a
|
|
// short retry loop in `request` would also catch this, but we keep the test symmetric
|
|
// with the Unix variant).
|
|
for _ in 0..200 {
|
|
// Best-effort: try to open the pipe; if it's not yet up the request will retry.
|
|
if tokio::net::windows::named_pipe::ClientOptions::new()
|
|
.open(&path)
|
|
.is_ok()
|
|
{
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
|
}
|
|
|
|
// route_add (cidr, direct).
|
|
let resp = admin::request(
|
|
&path,
|
|
&Request::RouteAdd {
|
|
cidr: Some("8.8.8.0/24".into()),
|
|
domain: None,
|
|
action: "direct".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_add request");
|
|
assert!(resp.ok, "route_add ok: {:?}", resp.error);
|
|
|
|
// The shared table actually changed.
|
|
assert_eq!(
|
|
routes.read().await.classify("8.8.8.8".parse().unwrap()),
|
|
RouteAction::Direct
|
|
);
|
|
|
|
// route_list reflects both rules and the default.
|
|
let resp = admin::request(&path, &Request::RouteList)
|
|
.await
|
|
.expect("route_list");
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.default.as_deref(), Some("vpn"));
|
|
let cidrs = resp.cidrs.expect("cidrs present");
|
|
assert_eq!(cidrs.len(), 1);
|
|
assert_eq!(cidrs[0].cidr, "8.8.8.0/24");
|
|
assert_eq!(cidrs[0].action, "direct");
|
|
|
|
// status reflects peer id + default + rule count.
|
|
let resp = admin::request(&path, &Request::Status)
|
|
.await
|
|
.expect("status");
|
|
assert!(resp.ok);
|
|
assert_eq!(resp.peer_id.as_deref(), Some("client-test"));
|
|
assert_eq!(resp.default.as_deref(), Some("vpn"));
|
|
assert_eq!(resp.rules, Some(1));
|
|
|
|
// route_remove the CIDR; classification falls back to default VPN.
|
|
let resp = admin::request(
|
|
&path,
|
|
&Request::RouteRemove {
|
|
cidr: "8.8.8.0/24".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("route_remove");
|
|
assert_eq!(resp.removed, Some(true));
|
|
assert_eq!(
|
|
routes.read().await.classify("8.8.8.8".parse().unwrap()),
|
|
RouteAction::Vpn
|
|
);
|
|
|
|
listener.abort();
|
|
// Named pipes are auto-released when the last handle is dropped; no explicit cleanup.
|
|
}
|