Merge branch 'master' into next

Forward-merge of 12 master commits past the previous merge
(823b830, master @ 18019bb): dep-audit bumps (rand,
clap, tun, rtnetlink, windows-service, plus the bump-safe
lockfile batch), bloom-storm chaos scenario, control-socket
resolver consolidation, gateway dns.listen default change,
OpenWrt ipk README refresh, gateway tutorial review, Ethernet
MTU rustdoc fix, dead session-variant drop, CHANGELOG prep.

Conflict resolution:

- CHANGELOG.md: both bullets kept under [Unreleased] / Fixed.
  Master's spanning-tree internal-path-propagation fix precedes
  next's tree-ancestry-test determinism entry and the
  responder-Disconnect XX-handshake entry.
- src/protocol/session.rs: kept next's SessionSetup/SessionAck
  variants and rustdoc. Master's drop of those variants suits
  v0.3.0's FSP phase-byte dispatch but is undone by next's
  v0.4.0 wire format, which retains the variants and uses the
  inner msg_type byte for handshake identification.
- src/transport/ethernet/mod.rs: kept next's "interface MTU - 4"
  comment. Master corrected the v0.3.0 3-byte rustdoc; next
  redesigned the framing to a 4-byte header (type/flags/length)
  for shared-media beacons, so master's correction does not
  apply to next's format.

Auto-merged cleanly: Cargo.toml (next's 0.4.0-dev + the new dep
pins from master), Cargo.lock, all gateway docs,
docs/reference/configuration.md, packaging files,
.github/workflows/ci.yml, testing/chaos/sim/* and
testing/ci-local.sh (bloom-storm additions), src/config/*.

Local verification: cargo build --release, cargo test (1252
passed, 4 ignored), cargo clippy -D warnings, cargo fmt --check
all green.
This commit is contained in:
Johnathan Corgan
2026-05-08 18:47:13 +00:00
26 changed files with 1518 additions and 434 deletions
+17 -4
View File
@@ -8,7 +8,20 @@ use std::net::SocketAddrV6;
use serde::{Deserialize, Serialize};
/// Default gateway DNS listen address.
const DEFAULT_DNS_LISTEN: &str = "[::]:53";
///
/// Loopback-only on the unprivileged port 5353. The canonical
/// gateway deployment is a host already serving DHCP/DNS to a LAN
/// segment (e.g., an OpenWrt AP), where port 53 is taken by the
/// existing resolver and `.fips` queries are forwarded to the
/// gateway over loopback. Operators on a host without a pre-existing
/// resolver on 53 can opt back into the wildcard bind by setting
/// `dns.listen: "[::]:53"` explicitly.
///
/// `[::1]` is IPv6 loopback only; Linux IPv6 sockets bound to
/// explicit `::1` do not accept v4-mapped traffic. Forwarders that
/// reach the gateway over IPv4 loopback (`127.0.0.1`) need to be
/// pointed at an explicit IPv4 listen address instead.
const DEFAULT_DNS_LISTEN: &str = "[::1]:5353";
/// Default upstream DNS resolver (FIPS daemon).
///
@@ -118,7 +131,7 @@ pub struct PortForward {
/// Gateway DNS resolver configuration (`gateway.dns.*`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GatewayDnsConfig {
/// Listen address and port (default: `0.0.0.0:53`).
/// Listen address and port (default: `[::1]:5353`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
@@ -133,7 +146,7 @@ pub struct GatewayDnsConfig {
}
impl GatewayDnsConfig {
/// Get the listen address (default: `0.0.0.0:53`).
/// Get the listen address (default: `[::1]:5353`).
pub fn listen(&self) -> &str {
self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN)
}
@@ -205,7 +218,7 @@ lan_interface: "eth0"
assert!(!config.enabled);
assert_eq!(config.pool, "fd01::/112");
assert_eq!(config.lan_interface, "eth0");
assert_eq!(config.dns.listen(), "[::]:53");
assert_eq!(config.dns.listen(), "[::1]:5353");
assert_eq!(config.dns.upstream(), "[::1]:5354");
assert_eq!(config.dns.ttl(), 60);
assert_eq!(config.grace_period(), 60);
+186 -19
View File
@@ -90,21 +90,77 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf {
.join(PUB_FILENAME)
}
/// Resolve a default Unix-socket path under the canonical order:
/// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`.
///
/// `/run/fips` is the packaged convention (`root:fips 0770` directory created
/// by the daemon at bind time). `XDG_RUNTIME_DIR` covers non-root dev runs
/// where `/run/fips` does not exist or is not writable. `/tmp` is the
/// last-resort fallback.
///
/// Hardening notes:
/// - `/run/fips` is accepted only if the directory exists and is writable by
/// the current process. `create_dir_all` reporting `Ok(())` is *not*
/// sufficient: it returns `Ok` for an existing root-owned dir that we
/// cannot write to, which would silently steer a non-root daemon onto a
/// path that fails at bind time. Writability is probed via tempfile create
/// rather than mode bits so ACLs and group membership (the dir is
/// `root:fips 0770`) are honored.
/// - `XDG_RUNTIME_DIR` is validated as an existing directory before being
/// used; a stale post-logout value (after `pam_systemd` reaps the dir) is
/// treated as missing.
#[cfg(unix)]
pub(crate) fn resolve_default_socket(filename: &str) -> String {
// 1. /run/fips — accept only if the directory exists and is writable.
let run_fips = Path::new("/run/fips");
if run_fips.is_dir() && is_writable_dir(run_fips) {
return format!("/run/fips/{filename}");
}
// Also accept /run/fips if we can create it (covers the first-boot
// daemon-as-root case before the directory has been materialized). The
// actual chown happens at bind time.
if std::fs::create_dir_all(run_fips).is_ok() && is_writable_dir(run_fips) {
return format!("/run/fips/{filename}");
}
// 2. $XDG_RUNTIME_DIR/fips/ — only if the variable points at an existing
// directory.
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
let xdg_path = Path::new(&xdg);
if xdg_path.is_dir() {
return format!("{xdg}/fips/{filename}");
}
}
// 3. Last resort: /tmp with a name-mangled prefix so multiple users
// don't collide.
format!("/tmp/fips-{filename}")
}
#[cfg(unix)]
fn is_writable_dir(path: &Path) -> bool {
// Probe via tempfile creation rather than mode bits: mode-bit checks miss
// ACLs and group-membership effects (the /run/fips dir is `root:fips
// 0770` and the daemon may run as a user that's in the `fips` group).
let probe = path.join(format!(".fips-write-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
/// Default control socket path for fipsctl / fipstop.
///
/// On Unix, checks the system-wide path first (used when the daemon runs as
/// a systemd service), then falls back to the user's XDG runtime directory.
/// On Windows, returns the default TCP port ("21210").
/// On Unix, delegates to [`resolve_default_socket`] for the canonical
/// `/run/fips` → `XDG_RUNTIME_DIR` → `/tmp` order. On Windows, returns the
/// default TCP port ("21210").
pub fn default_control_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else {
PathBuf::from("/tmp/fips-control.sock")
}
PathBuf::from(resolve_default_socket("control.sock"))
}
#[cfg(windows)]
{
@@ -114,18 +170,16 @@ pub fn default_control_path() -> PathBuf {
/// Default gateway control socket path.
///
/// On Unix, follows the same pattern as the main control socket.
/// On Windows, returns a placeholder TCP port ("21211").
/// On Unix, delegates to [`resolve_default_socket`] (same canonical order as
/// the main control socket). The gateway daemon itself uses a hardcoded
/// `/run/fips/gateway.sock` since gateway operation requires root for
/// NAT/conntrack management; this client-side resolver falls through
/// gracefully for non-root dev runs that need a gateway socket path. On
/// Windows, returns a placeholder TCP port ("21211").
pub fn default_gateway_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/gateway.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
} else {
PathBuf::from("/tmp/fips-gateway.sock")
}
PathBuf::from(resolve_default_socket("gateway.sock"))
}
#[cfg(windows)]
{
@@ -1466,4 +1520,117 @@ peers:
let cfg = UdpConfig::default();
assert!(cfg.accept_connections());
}
/// Mutex serializing tests that mutate `XDG_RUNTIME_DIR`. `cargo test`
/// runs tests on multiple threads in the same process, and env mutation
/// is process-global, so concurrent env-touching tests would race.
#[cfg(unix)]
static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(unix)]
#[test]
fn test_resolve_default_socket_call_sites_agree() {
// The three resolver call sites must all produce strings that agree
// on the directory, differing only in the filename suffix.
let _g = ENV_MUTEX.lock().unwrap();
let control_client = default_control_path().to_string_lossy().into_owned();
let gateway_client = default_gateway_path().to_string_lossy().into_owned();
let control_daemon = ControlConfig::default().socket_path;
// Daemon-side and client-side control paths must be identical.
assert_eq!(
control_daemon, control_client,
"daemon and client default control-socket paths diverged: \
daemon={control_daemon}, client={control_client}"
);
// Control and gateway must share a parent directory (or /tmp prefix).
let control_dir = std::path::Path::new(&control_client)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let gateway_dir = std::path::Path::new(&gateway_client)
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
assert_eq!(
control_dir, gateway_dir,
"control and gateway default-socket paths picked different directories: \
control={control_client}, gateway={gateway_client}"
);
}
#[cfg(unix)]
#[test]
fn test_resolve_default_socket_xdg_when_no_run_fips() {
// With /run/fips unwritable (non-root tests) and XDG_RUNTIME_DIR
// pointing at an existing directory, the resolver picks XDG.
let _g = ENV_MUTEX.lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
// SAFETY: serialized via ENV_MUTEX above.
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path());
}
let path = resolve_default_socket("control.sock");
// Restore env before asserting so a panic doesn't leak state.
unsafe {
match prev_xdg {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
// If /run/fips happens to be writable in the test environment (CI
// running as root, for instance), the resolver legitimately picks
// /run/fips and skips XDG entirely. Accept either outcome but
// demand that one of the two canonical prefixes is chosen — never
// /tmp when XDG was valid.
assert!(
path.starts_with("/run/fips/")
|| path.starts_with(&format!("{}/fips/", temp_dir.path().display())),
"expected /run/fips or XDG path, got: {path}"
);
}
#[cfg(unix)]
#[test]
fn test_resolve_default_socket_tmp_when_xdg_invalid() {
// With XDG_RUNTIME_DIR pointing at a non-existent directory and
// /run/fips unwritable, the resolver falls through to /tmp.
let _g = ENV_MUTEX.lock().unwrap();
let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
// Use a path that definitely does not exist.
let bogus = "/nonexistent-xdg-runtime-dir-for-fips-test-zzz";
// SAFETY: serialized via ENV_MUTEX.
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", bogus);
}
let path = resolve_default_socket("gateway.sock");
unsafe {
match prev_xdg {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
// Accept either /run/fips/ (test running as root with that dir
// writable) or /tmp/fips-... (the dev-machine fallback). Never
// accept the bogus XDG dir leaking through.
assert!(
path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock",
"expected /run/fips or /tmp fallback, got: {path}"
);
assert!(
!path.starts_with(bogus),
"stale/invalid XDG_RUNTIME_DIR leaked into resolver: {path}"
);
}
}
+6 -11
View File
@@ -784,20 +784,15 @@ impl ControlConfig {
/// Default control socket path.
///
/// On Unix, returns a Unix domain socket path (XDG_RUNTIME_DIR, /run/fips,
/// or /tmp fallback). On Windows, returns a TCP port number as a string
/// since Windows does not support Unix domain sockets; the control socket
/// listens on localhost at this port.
/// On Unix, delegates to [`super::resolve_default_socket`] for the
/// canonical `/run/fips` → `XDG_RUNTIME_DIR` → `/tmp` order shared with
/// the client-side `default_control_path`. On Windows, returns a TCP
/// port number as a string since Windows does not support Unix domain
/// sockets; the control socket listens on localhost at this port.
fn default_socket_path() -> String {
#[cfg(unix)]
{
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
format!("{runtime_dir}/fips/control.sock")
} else if std::fs::create_dir_all("/run/fips").is_ok() {
"/run/fips/control.sock".to_string()
} else {
"/tmp/fips-control.sock".to_string()
}
super::resolve_default_socket("control.sock")
}
#[cfg(windows)]
{
+4 -2
View File
@@ -267,8 +267,10 @@ pub struct EthernetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ethertype: Option<u16>,
/// MTU override. Defaults to the interface's MTU minus 1 (for frame type prefix).
/// Cannot exceed the interface's actual MTU.
/// MTU override. Defaults to the interface's MTU minus 3 bytes of frame
/// header (`[type:1][length:2 LE][payload]`). The 2-byte length field is
/// required to trim NIC minimum-frame padding before AEAD verification.
/// Cannot exceed the interface's actual MTU minus 3.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
+8
View File
@@ -15,6 +15,14 @@ use tokio::sync::watch;
use tracing::{debug, info, warn};
/// Socket path for the gateway control socket.
///
/// Hardcoded to `/run/fips/gateway.sock`: gateway operation requires root
/// for NAT/conntrack management, so the daemon side never needs to fall
/// back to `XDG_RUNTIME_DIR` or `/tmp`. Client tools resolve the path via
/// [`crate::config::default_gateway_path`], which uses the shared
/// [`crate::config::resolve_default_socket`] helper and falls through to
/// `XDG_RUNTIME_DIR` / `/tmp` for non-root dev runs that don't have
/// `/run/fips` writable.
pub const GATEWAY_SOCKET_PATH: &str = "/run/fips/gateway.sock";
/// Maximum request size in bytes (4 KB).