feat(cli): auto-NAT + privilege drop + Windows named-pipe admin

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>
This commit is contained in:
xah30
2026-05-27 02:09:38 +03:00
parent 821f7711e7
commit c6f0d7af9b
14 changed files with 1214 additions and 73 deletions
+126 -6
View File
@@ -1,11 +1,15 @@
//! Admin socket roundtrip: start the admin listener on a temp Unix socket 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.
//! 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 in the temp dir).
#![cfg(unix)]
//! 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;
@@ -15,6 +19,7 @@ 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!(
@@ -28,6 +33,20 @@ fn socket_path() -> PathBuf {
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)));
@@ -145,3 +164,104 @@ async fn admin_socket_route_roundtrip() {
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.
}
+27
View File
@@ -0,0 +1,27 @@
//! Integration tests for the auto-NAT helper (`aura_cli::nat::NatGuard`).
//!
//! These tests only exercise the dry-run code path. Real NAT mutation needs root and a host with
//! `iptables` (Linux) or `pfctl` (macOS), neither of which is appropriate for the unit test runner.
//! The dry-run path is platform-portable: it logs `would run: ...` for both the Linux and macOS
//! plans and never touches the host. The same code path is what the operator can use to inspect
//! the apply plan with `cargo run -- server --config ...` when `[server.nat] dry_run = true`.
use aura_cli::nat::NatGuard;
/// Dry-run is supported on every host (Linux, macOS, Windows) and returns a guard with no
/// recorded rollback commands. Dropping it logs the "would undo" lines without panicking.
#[test]
fn dry_run_enable_succeeds() {
let guard = NatGuard::enable("10.7.0.0/24", "eth0", true)
.expect("dry_run NatGuard::enable must succeed");
drop(guard);
}
/// The dry-run path tolerates arbitrary interface names — it never tries to look them up, just
/// logs what it would do. Also exercises a different pool CIDR.
#[test]
fn dry_run_enable_accepts_any_iface_name() {
let guard = NatGuard::enable("192.168.99.0/24", "en0", true)
.expect("dry_run must succeed with any iface name");
drop(guard);
}
+26
View File
@@ -0,0 +1,26 @@
//! Integration tests for `aura_cli::privdrop::drop_to_user`.
//!
//! These tests run unprivileged (the developer or CI is not root), so `drop_to_user` MUST take
//! the "already non-root, skip" fast path and return Ok. Actually exercising the syscalls
//! requires running the binary under sudo, which is out of scope for a unit test.
use aura_cli::privdrop::drop_to_user;
/// On a non-root host the call is a no-op: it logs a "skipped" line and returns Ok regardless of
/// whether the requested user actually exists (we never reach the lookup path).
#[test]
fn drop_to_user_is_noop_when_not_root() {
let res = drop_to_user("nobody");
assert!(
res.is_ok(),
"drop_to_user must be a no-op on a non-root host, got {res:?}"
);
}
/// A non-existent user is still tolerated when not root (because we never reach the lookup at
/// all). This guarantees the dev/CI flow never blows up on a misconfigured `[server] run_as`.
#[test]
fn drop_to_user_does_not_lookup_user_when_not_root() {
let res = drop_to_user("this-user-definitely-does-not-exist-aura-12345");
assert!(res.is_ok(), "no lookup happens on a non-root host: {res:?}");
}