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
+110
View File
@@ -104,6 +104,35 @@ pub struct ServerSection {
/// fallback that interprets `[tunnel] pool_cidr` as a [`PoolStrategy::DynamicOnly`] pool.
#[serde(default)]
pub pool: ServerPoolSection,
/// `[server.nat]` sub-section: v2 auto-NAT (IP forward + MASQUERADE) applied at startup and
/// rolled back at shutdown. Omitting it (the default) leaves the host network untouched —
/// this is the v1 behaviour where the operator manually pre-configures forwarding.
#[serde(default)]
pub nat: Option<ServerNatSection>,
/// Optional non-root user to drop privileges to **after** all startup work that needs root
/// (TUN open, low-port bind, NAT configuration). When omitted (or already non-root) the
/// server keeps its current credentials.
#[serde(default)]
pub run_as: Option<String>,
}
/// `[server.nat]` section: v2 auto-NAT configuration. See [`crate::nat`] for the apply / rollback
/// semantics. Optional — when the section is omitted the server makes no changes to the host's
/// IP forwarding state, matching v1 behaviour.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct ServerNatSection {
/// Master switch. When `false` (or the section is omitted) the server does NOT touch the
/// host network — the operator is expected to have configured forwarding by hand. When
/// `true` the server applies the platform-appropriate set of commands at startup and
/// rolls them back on shutdown.
pub auto: bool,
/// Name of the host interface traffic egresses through (e.g. `"eth0"` on Linux, `"en0"` on
/// macOS). REQUIRED when `auto = true` — there is no auto-detection in v1 (that is v3).
pub egress_iface: String,
/// When `true`, every command is only logged (`would run: ...`) and not executed. Useful
/// for verifying the plan without root privileges and for the unit tests.
pub dry_run: bool,
}
/// `[tunnel]` section of `server.toml`.
@@ -158,6 +187,10 @@ pub struct ClientSection {
pub server_addr: String,
/// Outer-TLS SNI (camouflage hostname) presented to the server.
pub sni: String,
/// Optional non-root user to drop privileges to **after** the TUN is open. When omitted
/// (or already non-root) the client keeps its current credentials. See [`crate::privdrop`].
#[serde(default)]
pub run_as: Option<String>,
}
/// `[tunnel]` section of `client.toml`.
@@ -1177,6 +1210,83 @@ quic_port = 443
assert!(eps.quic.is_none());
}
/// `[server.nat]` parses end-to-end (auto + egress_iface + dry_run) and exposes the values
/// to the server startup path.
#[test]
fn parses_server_nat_section() {
let s = r#"
[server]
name = "edge"
[server.nat]
auto = true
egress_iface = "eth0"
dry_run = true
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(s).expect("parse server.toml with [server.nat]");
let nat = cfg.server.nat.as_ref().expect("nat section present");
assert!(nat.auto, "auto = true");
assert_eq!(nat.egress_iface, "eth0");
assert!(nat.dry_run, "dry_run = true");
}
/// Backwards compat: an old server.toml without `[server.nat]` parses fine and exposes
/// `nat = None`. This preserves the v1 "operator configures NAT by hand" behaviour.
#[test]
fn server_nat_section_optional() {
let s = r#"
[server]
name = "edge"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(s).expect("parse minimal v1 server.toml");
assert!(cfg.server.nat.is_none(), "nat section absent by default");
}
/// `run_as` is parsed off both [server] and [client] sections and is optional.
#[test]
fn parses_run_as_on_both_configs() {
let s = r#"
[server]
name = "edge"
run_as = "nobody"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
pool_cidr = "10.7.0.0/24"
"#;
let cfg = ServerConfigFile::parse(s).expect("parse server.toml with run_as");
assert_eq!(cfg.server.run_as.as_deref(), Some("nobody"));
let c = r#"
[client]
name = "x"
server_addr = "1.2.3.4:443"
sni = "a"
run_as = "nobody"
[pki]
ca_cert = "a"
cert = "b"
key = "c"
[tunnel]
local_ip = "10.7.0.2"
"#;
let cfg = ClientConfigFile::parse(c).expect("parse client.toml with run_as");
assert_eq!(cfg.client.run_as.as_deref(), Some("nobody"));
}
/// An unknown transport name in `order` is a hard error (not silently dropped).
#[test]
fn rejects_unknown_transport_name() {