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
+240 -66
View File
@@ -1,4 +1,4 @@
//! Admin IPC: a tiny JSON line protocol over a Unix domain socket.
//! 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
@@ -25,11 +25,16 @@
//! Every admin mutation touches both, so `route_list` can faithfully echo what is configured while
//! `classify` still goes through the real table.
//!
//! ## Platform note
//! The transport is `tokio::net::UnixListener` / `UnixStream`, available on Unix (the project's
//! Linux + macOS targets). On Windows this would be a named pipe; that path is a documented
//! `cfg`-gated stub ([`serve`] / [`request`] return an explanatory error) so the rest of the CLI
//! still compiles there.
//! ## 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};
@@ -38,12 +43,18 @@ use std::sync::{Arc, Mutex as StdMutex};
use aura_tunnel::{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 socket path used when a config / flag does not override it.
/// 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.
#[derive(Debug, Default)]
@@ -368,70 +379,229 @@ async fn rebuild_table(state: &AdminState) {
*state.routes.write().await = fresh;
}
/// Run the admin listener until the task is cancelled.
///
/// Removes any stale socket at `path`, binds a [`tokio::net::UnixListener`], and serves connections
/// (one request/response per accepted line) over the shared `state`.
#[cfg(unix)]
pub async fn serve(path: &str, state: AdminState) -> anyhow::Result<()> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
// ---- platform transport ---------------------------------------------------------------------
// Best-effort cleanup of a previous run's socket file.
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)
.map_err(|e| anyhow::anyhow!("binding admin socket {path}: {e}"))?;
tracing::info!(socket = path, "admin IPC listening");
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};
loop {
let (stream, _addr) = match listener.accept().await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(error = %e, "admin accept failed");
continue;
}
#[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,
};
let state = state.clone();
tokio::spawn(async move {
let (read_half, mut write_half) = stream.into_split();
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;
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)
}
}
}
/// Windows stub: the admin socket uses Unix domain sockets; a named-pipe transport is future work.
#[cfg(not(unix))]
pub async fn serve(_path: &str, _state: AdminState) -> anyhow::Result<()> {
anyhow::bail!("admin IPC over Unix sockets is unavailable on this platform (Windows named-pipe transport is not yet implemented)")
/// 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)")
}
}
/// Connect to the admin socket, send one [`Request`], and return the [`Response`].
#[cfg(unix)]
pub async fn request(path: &str, req: &Request) -> anyhow::Result<Response> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
/// 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;
}
}
}
let stream = UnixStream::connect(path).await.map_err(|e| {
anyhow::anyhow!(
"connecting to admin socket {path}: {e} (is `aura server`/`aura client` running?)"
)
})?;
let (read_half, mut write_half) = stream.into_split();
/// 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?;
@@ -445,12 +615,6 @@ pub async fn request(path: &str, req: &Request) -> anyhow::Result<Response> {
Ok(serde_json::from_str(&line)?)
}
/// Windows stub mirroring [`serve`].
#[cfg(not(unix))]
pub async fn request(_path: &str, _req: &Request) -> anyhow::Result<Response> {
anyhow::bail!("admin IPC over Unix sockets is unavailable on this platform (Windows named-pipe transport is not yet implemented)")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -552,4 +716,14 @@ mod tests {
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");
}
}