diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6c0681..54760add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -246,6 +246,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The packaged macOS daemon now recreates and binds its control socket at + `/var/run/fips/control.sock` instead of falling through to the shared + `/tmp/fips-control.sock` path after boot. The previous fallback also made the + root daemon attempt to chown `/tmp` itself to the `fips` group because socket + setup unconditionally changed the parent directory. A privileged macOS + process now selects the private runtime path before its leaf exists, clients + follow it once created, and socket setup changes ownership and mode only for + a private parent directory it creates or recognizes as a canonical FIPS + runtime directory. + - Nostr NAT traversal signals are now sent only to relays the client pool actually holds. A signal is addressed to the merge of the peer's NIP-17 inbox relays, the relays its advert nominates for signaling, and our own DM relays, diff --git a/docs/reference/cli-fipsctl.md b/docs/reference/cli-fipsctl.md index 3593117f..c9631cd0 100644 --- a/docs/reference/cli-fipsctl.md +++ b/docs/reference/cli-fipsctl.md @@ -187,7 +187,7 @@ so; `profile tick status` then reports `stopped_by_cap` until the next | Path | Purpose | | ---- | ------- | | `/etc/fips/hosts` | Maps hostnames to npubs for the `connect`, `disconnect`, and `--peer` arguments. See [configuration.md](configuration.md). | -| Control socket (default) | Same resolution as the daemon: `/run/fips/control.sock` if present, else `$XDG_RUNTIME_DIR/fips/control.sock`, else `/tmp/fips-control.sock` (Unix); TCP `localhost:21210` (Windows). | +| Control socket (default) | Same resolution as the daemon: `/run/fips/control.sock` if present; then `/var/run/fips/control.sock` on macOS/FreeBSD if present; then `$XDG_RUNTIME_DIR/fips/control.sock`; finally `/tmp/fips-control.sock` (Unix). A privileged macOS daemon bootstraps the private `/var/run/fips` directory. Windows uses TCP `localhost:21210`. | If you get `Permission denied` connecting to the socket on Linux, add your user to the `fips` group (`sudo usermod -aG fips $USER`) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ce60ebbc..b9c3987f 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -54,7 +54,7 @@ peers: # Static peer list | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `node.control.enabled` | bool | `true` | Enable the control socket | -| `node.control.socket_path` | string | *(auto)* | **Linux:** Socket file path. Resolved at daemon startup: `$XDG_RUNTIME_DIR/fips/control.sock` if `XDG_RUNTIME_DIR` is set, else `/run/fips/control.sock` if `/run/fips` can be created (typical when running under the shipped systemd unit), else `/tmp/fips-control.sock`. (Note: the `fipsctl` / `fipstop` clients use a different fallback order — `/run/fips` first if it already exists, then `XDG_RUNTIME_DIR`, then `/tmp` — so when both schemes apply, set this field explicitly to avoid mismatch.) **Windows:** TCP port number (default: `21210`); the control socket listens on `127.0.0.1` at this port. | +| `node.control.socket_path` | string | *(auto)* | **Unix:** Socket file path. Resolution is shared by daemon and clients: `/run/fips/control.sock` when `/run/fips` exists; then `/var/run/fips/control.sock` on macOS/FreeBSD when its private directory exists; then `$XDG_RUNTIME_DIR/fips/control.sock`; finally `/tmp/fips-control.sock`. A privileged macOS daemon selects `/var/run/fips/control.sock` even when the private directory must be created after boot. **Windows:** TCP port number (default: `21210`); the control socket listens on `127.0.0.1` at this port. | The control socket provides access to node state and runtime management via the `fipsctl` command-line tool. In addition to read-only status @@ -62,7 +62,7 @@ queries, `fipsctl connect` and `fipsctl disconnect` enable runtime peer management. See the [`fipsctl` reference](cli-fipsctl.md) for the command list. -On Linux, the control socket is a Unix domain socket with filesystem +On Unix, the control socket is a Unix domain socket with filesystem permissions (mode 0770, group `fips`). On Windows, it is a TCP listener on localhost. TCP does not provide filesystem-level ACLs, so any local user can connect to the control port. @@ -985,7 +985,7 @@ node: after_messages: 65536 # rekey after N messages sent control: enabled: true - socket_path: null # null = auto ($XDG_RUNTIME_DIR → /run/fips → /tmp fallback) + socket_path: null # null = auto (platform runtime dir → XDG → /tmp) buffers: packet_channel: 1024 tun_channel: 1024 diff --git a/docs/reference/control-socket.md b/docs/reference/control-socket.md index 2422704f..181f2ccf 100644 --- a/docs/reference/control-socket.md +++ b/docs/reference/control-socket.md @@ -8,20 +8,28 @@ length-bounded JSON over a stream socket. ## Connection -### Linux / macOS +### Unix A Unix domain socket. The default path is resolved in this order: 1. `/run/fips/control.sock` (or `/run/fips/gateway.sock` for the gateway), if `/run/fips` exists. This is what the `fips.service` systemd unit creates. -2. `$XDG_RUNTIME_DIR/fips/control.sock` otherwise. -3. `/tmp/fips-control.sock` if neither of the above is available. +2. On macOS and FreeBSD, `/var/run/fips/control.sock` if its private + directory exists. A privileged macOS daemon selects this path before the + directory exists and creates it at bind time, so the packaged LaunchDaemon + recreates its runtime state after every boot. The FreeBSD rc.d service + creates the directory before starting FIPS. +3. `$XDG_RUNTIME_DIR/fips/control.sock` otherwise. +4. `/tmp/fips-control.sock` if none of the above is available. -The daemon `chown`s the socket file and its parent directory to the -`fips` group at bind time and sets mode `0770`. Members of the `fips` -group can therefore connect without root. Add a user with -`sudo usermod -aG fips $USER` (re-login required). +The daemon sets the socket to group `fips`, mode `0770`. It sets a private +parent directory to group `fips`, mode `0750`, both when it creates that +directory and when a service manager pre-creates a canonical runtime directory +(`/run/fips`, `/var/run/fips`, or `$XDG_RUNTIME_DIR/fips`). Existing shared or +custom parents remain unchanged, so fallback locations such as `/tmp` retain +their system ownership and mode. Members of the `fips` group can connect +without root. The path can be overridden at the daemon side via `node.control.socket_path` in the YAML config, and at the client side diff --git a/src/config/mod.rs b/src/config/mod.rs index 9c3a9c78..0eb4b0f5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -138,18 +138,119 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf { .join(PUB_FILENAME) } -/// Resolve a default Unix-socket path under the canonical order: -/// `/run/fips/` → `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-`. +/// How `/var/run/fips` participates in Unix control-socket resolution. +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VarRunPolicy { + /// Whether an existing `/var/run/fips` directory participates in resolution. + consult_existing: bool, + /// Whether to select `/var/run/fips` before its private leaf exists. + create_private_dir: bool, +} + +#[cfg(target_os = "macos")] +fn default_var_run_policy() -> VarRunPolicy { + // LaunchDaemons run as root unless their plist declares another user. + // Selecting the private runtime path before it exists lets ControlSocket + // create it at every boot; non-root development runs retain XDG and /tmp + // fallbacks until a packaged daemon has created /var/run/fips. + VarRunPolicy { + consult_existing: true, + create_private_dir: unsafe { libc::geteuid() } == 0, + } +} + +#[cfg(target_os = "freebsd")] +fn default_var_run_policy() -> VarRunPolicy { + // The rc.d service creates /var/run/fips before starting the daemon. + VarRunPolicy { + consult_existing: true, + create_private_dir: false, + } +} + +#[cfg(all(unix, not(any(target_os = "macos", target_os = "freebsd"))))] +fn default_var_run_policy() -> VarRunPolicy { + VarRunPolicy { + consult_existing: false, + create_private_dir: false, + } +} + +/// Pure path-selection core used by the host resolver and deterministic tests. +#[cfg(unix)] +fn resolve_default_socket_with( + filename: &str, + var_run_policy: VarRunPolicy, + xdg_runtime_dir: Option<&Path>, + is_dir: impl Fn(&Path) -> bool, +) -> String { + if is_dir(Path::new("/run/fips")) { + return format!("/run/fips/{filename}"); + } + + if var_run_policy.consult_existing { + let private_var_run = Path::new("/var/run/fips"); + let may_create_private_dir = + var_run_policy.create_private_dir && is_dir(Path::new("/var/run")); + if is_dir(private_var_run) || may_create_private_dir { + return format!("/var/run/fips/{filename}"); + } + } + + if let Some(xdg) = xdg_runtime_dir + && is_dir(xdg) + { + return xdg + .join("fips") + .join(filename) + .to_string_lossy() + .into_owned(); + } + + format!("/tmp/fips-{filename}") +} + +/// Return whether `parent` is one of the private runtime directories used by +/// the default Unix socket resolver. /// -/// `/run/fips` is the packaged convention (`root:fips 0770` directory -/// created by the daemon at bind time, or by the postinst script). -/// `XDG_RUNTIME_DIR` covers dev runs where `/run/fips` does not exist. -/// `/tmp` is the last-resort fallback. +/// This is intentionally stricter than matching any leaf named `fips`: an +/// explicitly configured existing directory remains operator-owned unless it +/// is also a canonical resolver candidate. +#[cfg(unix)] +fn is_managed_socket_parent_with( + parent: &Path, + var_run_policy: VarRunPolicy, + xdg_runtime_dir: Option<&Path>, +) -> bool { + parent == Path::new("/run/fips") + || (var_run_policy.consult_existing && parent == Path::new("/var/run/fips")) + || xdg_runtime_dir.is_some_and(|xdg| parent == xdg.join("fips")) +} + +/// Return whether `parent` is a private runtime directory managed by the +/// default Unix socket resolver on this host. +#[cfg(unix)] +pub(crate) fn is_managed_socket_parent(parent: &Path) -> bool { + let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from); + is_managed_socket_parent_with(parent, default_var_run_policy(), xdg_runtime_dir.as_deref()) +} + +/// Resolve a default Unix-socket path under the canonical order: +/// `/run/fips/` → `/var/run/fips/` on macOS/FreeBSD → +/// `$XDG_RUNTIME_DIR/fips/` → `/tmp/fips-`. +/// +/// `/run/fips` is the packaged Linux convention. FreeBSD's rc.d service +/// creates `/var/run/fips` before starting the daemon. A privileged macOS +/// daemon selects `/var/run/fips` even when the private leaf does not exist so +/// it can be recreated at bind time after every boot; non-root macOS clients +/// select it once the daemon has created it. `XDG_RUNTIME_DIR` covers dev runs, +/// and `/tmp` is the last-resort fallback. /// /// Selection is by *existence*, not writability. A fips-group member /// whose shell session has not picked up the supplementary group (no /// re-login after `usermod -aG fips`) cannot tempfile-probe a -/// `root:fips 0770` directory but can still connect to a socket inside +/// `root:fips 0750` directory but can still connect to a socket inside /// it once the kernel checks the actual group at `connect(2)` time — /// and even where the user genuinely cannot connect, surfacing an /// `EACCES` from the socket call is clearer than silently steering @@ -163,37 +264,20 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf { /// is treated as missing. #[cfg(unix)] pub(crate) fn resolve_default_socket(filename: &str) -> String { - // 1. /run/fips — preferred whenever the directory exists. - if Path::new("/run/fips").is_dir() { - return format!("/run/fips/{filename}"); - } - - // 1b. /var/run/fips — macOS and FreeBSD have no /run; the FreeBSD - // rc.d script creates this directory at service start. - #[cfg(any(target_os = "macos", target_os = "freebsd"))] - if Path::new("/var/run/fips").is_dir() { - return format!("/var/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}") + let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from); + resolve_default_socket_with( + filename, + default_var_run_policy(), + xdg_runtime_dir.as_deref(), + Path::is_dir, + ) } /// Default control socket path for fipsctl / fipstop. /// /// 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"). +/// platform runtime directory → `XDG_RUNTIME_DIR` → `/tmp` order. On Windows, +/// returns the default TCP port ("21210"). pub fn default_control_path() -> PathBuf { #[cfg(unix)] { @@ -207,7 +291,7 @@ pub fn default_control_path() -> PathBuf { /// Default gateway control socket path. /// -/// On Unix, delegates to [`resolve_default_socket`] (same canonical order as +/// On Unix, delegates to [`resolve_default_socket`] (the same platform 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 @@ -2386,6 +2470,120 @@ node: assert!(cfg.accept_connections()); } + #[cfg(unix)] + #[test] + fn test_privileged_macos_bootstraps_private_var_run_path() { + let path = resolve_default_socket_with( + "control.sock", + VarRunPolicy { + consult_existing: true, + create_private_dir: true, + }, + Some(Path::new("/valid/xdg")), + |candidate| matches!(candidate.to_str(), Some("/var/run" | "/valid/xdg")), + ); + + assert_eq!(path, "/var/run/fips/control.sock"); + } + + #[cfg(unix)] + #[test] + fn test_non_privileged_macos_uses_xdg_before_private_var_run_exists() { + let path = resolve_default_socket_with( + "control.sock", + VarRunPolicy { + consult_existing: true, + create_private_dir: false, + }, + Some(Path::new("/valid/xdg")), + |candidate| candidate == Path::new("/valid/xdg"), + ); + + assert_eq!(path, "/valid/xdg/fips/control.sock"); + } + + #[cfg(unix)] + #[test] + fn test_clients_follow_existing_private_var_run_path() { + let path = resolve_default_socket_with( + "control.sock", + VarRunPolicy { + consult_existing: true, + create_private_dir: false, + }, + Some(Path::new("/valid/xdg")), + |candidate| matches!(candidate.to_str(), Some("/var/run/fips" | "/valid/xdg")), + ); + + assert_eq!(path, "/var/run/fips/control.sock"); + } + + #[cfg(unix)] + #[test] + fn test_linux_policy_ignores_var_run_fips() { + let path = resolve_default_socket_with( + "control.sock", + VarRunPolicy { + consult_existing: false, + create_private_dir: false, + }, + Some(Path::new("/valid/xdg")), + |candidate| matches!(candidate.to_str(), Some("/var/run/fips" | "/valid/xdg")), + ); + + assert_eq!(path, "/valid/xdg/fips/control.sock"); + } + + #[cfg(unix)] + #[test] + fn test_managed_socket_parent_matches_only_resolver_candidates() { + let policy = VarRunPolicy { + consult_existing: true, + create_private_dir: true, + }; + + assert!(is_managed_socket_parent_with( + Path::new("/run/fips"), + policy, + Some(Path::new("/valid/xdg")), + )); + assert!(is_managed_socket_parent_with( + Path::new("/var/run/fips"), + policy, + Some(Path::new("/valid/xdg")), + )); + assert!(is_managed_socket_parent_with( + Path::new("/valid/xdg/fips"), + policy, + Some(Path::new("/valid/xdg")), + )); + assert!(!is_managed_socket_parent_with( + Path::new("/tmp"), + policy, + Some(Path::new("/valid/xdg")), + )); + assert!(!is_managed_socket_parent_with( + Path::new("/srv/application/fips"), + policy, + Some(Path::new("/valid/xdg")), + )); + } + + #[cfg(unix)] + #[test] + fn test_linux_managed_socket_parent_excludes_var_run() { + let linux_policy = VarRunPolicy { + consult_existing: false, + create_private_dir: false, + }; + + assert!(!is_managed_socket_parent_with( + Path::new("/var/run/fips"), + linux_policy, + None, + )); + } + /// 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. diff --git a/src/config/node.rs b/src/config/node.rs index 3709bc00..c3a283bc 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -868,11 +868,11 @@ impl ControlConfig { /// Default control socket path. /// - /// 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. + /// On Unix, delegates to [`super::resolve_default_socket`] for the shared + /// platform runtime-directory → `XDG_RUNTIME_DIR` → `/tmp` order. 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)] { diff --git a/src/control/mod.rs b/src/control/mod.rs index 5e770df8..ddbc384f 100644 --- a/src/control/mod.rs +++ b/src/control/mod.rs @@ -131,6 +131,61 @@ mod unix_impl { use std::path::{Path, PathBuf}; use tokio::net::UnixListener; + /// Ensure the socket's parent exists and report whether this call created + /// the leaf directory. + /// + /// `create_dir` gives us an atomic ownership decision: an `AlreadyExists` + /// result means another actor owns the existing directory, while success + /// means it is safe for this bind to apply FIPS ownership and mode. Missing + /// ancestors are created recursively, but only the requested leaf is later + /// treated as the socket's private directory. + fn ensure_socket_parent(parent: &Path) -> Result { + if parent.as_os_str().is_empty() { + return Ok(false); + } + + match std::fs::create_dir(parent) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if parent.is_dir() { + Ok(false) + } else { + Err(error) + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let ancestor = parent.parent().ok_or(error)?; + ensure_socket_parent(ancestor)?; + ensure_socket_parent(parent) + } + Err(error) => Err(error), + } + } + + /// Apply access policy to a newly bound control socket. + /// + /// The socket is always group-owned. `managed_parent` is either a private + /// directory this bind created or a canonical FIPS runtime directory. A + /// shared or operator-owned existing parent is omitted so it retains its + /// ownership and mode. + fn set_control_socket_access( + socket_path: &Path, + managed_parent: Option<&Path>, + mut chown_to_fips_group: impl FnMut(&Path), + ) -> Result<(), std::io::Error> { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o770))?; + chown_to_fips_group(socket_path); + + if let Some(parent) = managed_parent { + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o750))?; + chown_to_fips_group(parent); + } + + Ok(()) + } + /// Control socket listener (Unix domain socket). /// /// Manages the Unix domain socket lifecycle: bind, accept, cleanup. @@ -147,13 +202,20 @@ mod unix_impl { pub fn bind(config: &ControlConfig) -> Result { let socket_path = PathBuf::from(&config.socket_path); - // Create parent directory if it doesn't exist - if let Some(parent) = socket_path.parent() - && !parent.exists() - { - std::fs::create_dir_all(parent)?; - debug!(path = %parent.display(), "Created control socket directory"); - } + // Creation is useful for diagnostics, but ownership is keyed to + // directory identity as well: systemd pre-creates /run/fips on + // every Linux service start and initially owns it as root:root. + let managed_parent = match socket_path.parent() { + Some(parent) => { + let created = ensure_socket_parent(parent)?; + if created { + debug!(path = %parent.display(), "Created private control socket directory"); + } + (created || crate::config::is_managed_socket_parent(parent)) + .then(|| parent.to_owned()) + } + None => None, + }; // Remove stale socket if it exists if socket_path.exists() { @@ -162,14 +224,13 @@ mod unix_impl { let listener = UnixListener::bind(&socket_path)?; - // Make the socket and its parent directory group-accessible so - // 'fips' group members can use fipsctl/fipstop without root. - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?; - Self::chown_to_fips_group(&socket_path); - if let Some(parent) = socket_path.parent() { - Self::chown_to_fips_group(parent); - } + // Make the socket and its managed private directory group-accessible + // so fips group members can use fipsctl/fipstop. + set_control_socket_access( + &socket_path, + managed_parent.as_deref(), + Self::chown_to_fips_group, + )?; info!(path = %socket_path.display(), "Control socket listening"); @@ -291,6 +352,92 @@ mod unix_impl { self.cleanup(); } } + + #[cfg(test)] + mod tests { + use super::{ensure_socket_parent, set_control_socket_access}; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn parent_setup_distinguishes_existing_and_created_directories() { + let temp = tempfile::tempdir().unwrap(); + let existing = temp.path().join("existing"); + std::fs::create_dir(&existing).unwrap(); + assert!(!ensure_socket_parent(&existing).unwrap()); + + let nested = temp.path().join("missing").join("fips"); + assert!(ensure_socket_parent(&nested).unwrap()); + assert!(nested.is_dir()); + assert!(!ensure_socket_parent(&nested).unwrap()); + } + + #[test] + fn access_setup_leaves_an_existing_shared_parent_unchanged() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("shared"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o711)).unwrap(); + let socket = parent.join("control.sock"); + std::fs::File::create(&socket).unwrap(); + + let mut chowned = Vec::new(); + set_control_socket_access(&socket, None, |path| chowned.push(path.to_path_buf())) + .unwrap(); + + assert_eq!(chowned, vec![socket.clone()]); + assert_eq!( + std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o711 + ); + assert_eq!( + std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777, + 0o770 + ); + } + + #[test] + fn access_setup_secures_a_new_private_parent() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("fips"); + std::fs::create_dir(&parent).unwrap(); + let socket = parent.join("control.sock"); + std::fs::File::create(&socket).unwrap(); + + let mut chowned = Vec::new(); + set_control_socket_access(&socket, Some(&parent), |path| { + chowned.push(path.to_path_buf()) + }) + .unwrap(); + + assert_eq!(chowned, vec![socket, parent.clone()]); + assert_eq!( + std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o750 + ); + } + + #[test] + fn access_setup_secures_an_existing_managed_parent() { + let temp = tempfile::tempdir().unwrap(); + let parent = temp.path().join("managed"); + std::fs::create_dir(&parent).unwrap(); + std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); + let socket = parent.join("control.sock"); + std::fs::File::create(&socket).unwrap(); + + let mut chowned = Vec::new(); + set_control_socket_access(&socket, Some(&parent), |path| { + chowned.push(path.to_path_buf()) + }) + .unwrap(); + + assert_eq!(chowned, vec![socket, parent.clone()]); + assert_eq!( + std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777, + 0o750 + ); + } + } } // ============================================================================ diff --git a/testing/deb-install/test.sh b/testing/deb-install/test.sh index 432ef1c0..0b542744 100755 --- a/testing/deb-install/test.sh +++ b/testing/deb-install/test.sh @@ -462,6 +462,26 @@ EOF fail "fips.service did not stay up after gateway-enable restart" fi + # systemd removes and recreates RuntimeDirectory=fips across this restart + # as root:root 0750. The daemon must restore the fips group on every start, + # not only when it created the directory itself. + local runtime_access + runtime_access=$(docker exec "$name" stat -c '%a %U:%G' /run/fips 2>/dev/null || true) + if [ "$runtime_access" = "750 root:fips" ]; then + pass "/run/fips ownership restored after service restart" + else + fail "/run/fips wrong after service restart: '$runtime_access' (expected '750 root:fips')" + fi + + if docker exec "$name" bash -c ' + useradd --system --no-create-home --user-group --groups fips fips-test-client + runuser -u fips-test-client -- fipsctl show status >/dev/null + '; then + pass "non-root fips group member reaches control socket after restart" + else + fail "non-root fips group member cannot reach control socket after restart" + fi + docker exec "$name" systemctl start fips-gateway.service >/dev/null 2>&1 || true sleep 3 if docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null \