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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user