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:
+240
-66
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user