Secure the control socket's runtime directory, and stop chowning /tmp

On any Unix host that falls through to the last-resort
/tmp/fips-control.sock path, bind chowned the socket's parent
unconditionally, and that parent is /tmp itself. A root daemon on such a
host changed the group ownership of /tmp to fips at every start. The mode
was left alone, so nothing lost access, but the ownership was ours to
take and never ours to keep.

macOS has no /run, so the resolver's /var/run/fips arm only fired when
the directory already existed, and nothing on macOS creates it: /var/run
is cleared at boot and the shipped LaunchDaemon has no equivalent of the
FreeBSD rc.d fips_precmd. The packaged macOS daemon has therefore been
landing on /tmp/fips-control.sock every boot. A privileged macOS process
now selects /var/run/fips before its leaf exists, so that bind creates
it, and the clients follow once it is there. The two halves are the same
change: the bootstrap only works if bind may create and secure that
directory, and the /tmp chown had to go before bind could be trusted to.

Which parent bind may secure is keyed on the directory's identity rather
than on which call created it. is_managed_socket_parent matches only the
resolver's own candidates: /run/fips, /var/run/fips where the platform
policy consults it, and $XDG_RUNTIME_DIR/fips. Keying it on creation
alone was tried first and regressed Linux, because systemd removes
RuntimeDirectory=fips when the unit stops and recreates it as root:root
on the next start, while the tmpfiles fragment that sets the fips group
runs only at install and boot. The daemon's own chown was what repaired
that at every bind, so a fips-group operator lost fipsctl after the first
restart following a boot. Matching on identity restores it and still
leaves /tmp, and any operator-configured directory, alone.

The resolver is split into a pure core taking the policy, the
XDG_RUNTIME_DIR value and an is_dir predicate, so the macOS and Linux
policies are both exercised deterministically on a Linux runner with no
environment mutation. The deb-install suite gains the end-to-end half:
after a service restart it asserts /run/fips is 750 root:fips and that a
real non-root fips-group user can reach the socket, which is the property
an operator actually has.

Also corrects a configuration.md paragraph claiming the daemon and the
clients use different fallback orders, which stopped being true when the
resolver order disagreement was resolved and the prose was never updated.
This commit is contained in:
Jeff Gardner
2026-08-13 14:01:30 +00:00
committed by Johnathan Corgan
parent 672d828ef5
commit 5e3892edb8
8 changed files with 448 additions and 65 deletions
+10
View File
@@ -246,6 +246,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### 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 - 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 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, relays, the relays its advert nominates for signaling, and our own DM relays,
+1 -1
View File
@@ -187,7 +187,7 @@ so; `profile tick status` then reports `stopped_by_cap` until the next
| Path | Purpose | | Path | Purpose |
| ---- | ------- | | ---- | ------- |
| `/etc/fips/hosts` | Maps hostnames to npubs for the `connect`, `disconnect`, and `--peer` arguments. See [configuration.md](configuration.md). | | `/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, If you get `Permission denied` connecting to the socket on Linux,
add your user to the `fips` group (`sudo usermod -aG fips $USER`) add your user to the `fips` group (`sudo usermod -aG fips $USER`)
+3 -3
View File
@@ -54,7 +54,7 @@ peers: # Static peer list
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|-----------|------|---------|-------------| |-----------|------|---------|-------------|
| `node.control.enabled` | bool | `true` | Enable the control socket | | `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 The control socket provides access to node state and runtime management
via the `fipsctl` command-line tool. In addition to read-only status 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 management. See the [`fipsctl` reference](cli-fipsctl.md) for the
command list. 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 permissions (mode 0770, group `fips`). On Windows, it is a TCP listener
on localhost. TCP does not provide filesystem-level ACLs, so any local on localhost. TCP does not provide filesystem-level ACLs, so any local
user can connect to the control port. user can connect to the control port.
@@ -985,7 +985,7 @@ node:
after_messages: 65536 # rekey after N messages sent after_messages: 65536 # rekey after N messages sent
control: control:
enabled: true 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: buffers:
packet_channel: 1024 packet_channel: 1024
tun_channel: 1024 tun_channel: 1024
+15 -7
View File
@@ -8,20 +8,28 @@ length-bounded JSON over a stream socket.
## Connection ## Connection
### Linux / macOS ### Unix
A Unix domain socket. The default path is resolved in this order: A Unix domain socket. The default path is resolved in this order:
1. `/run/fips/control.sock` (or `/run/fips/gateway.sock` for the 1. `/run/fips/control.sock` (or `/run/fips/gateway.sock` for the
gateway), if `/run/fips` exists. This is what the `fips.service` gateway), if `/run/fips` exists. This is what the `fips.service`
systemd unit creates. systemd unit creates.
2. `$XDG_RUNTIME_DIR/fips/control.sock` otherwise. 2. On macOS and FreeBSD, `/var/run/fips/control.sock` if its private
3. `/tmp/fips-control.sock` if neither of the above is available. 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 The daemon sets the socket to group `fips`, mode `0770`. It sets a private
`fips` group at bind time and sets mode `0770`. Members of the `fips` parent directory to group `fips`, mode `0750`, both when it creates that
group can therefore connect without root. Add a user with directory and when a service manager pre-creates a canonical runtime directory
`sudo usermod -aG fips $USER` (re-login required). (`/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 The path can be overridden at the daemon side via
`node.control.socket_path` in the YAML config, and at the client side `node.control.socket_path` in the YAML config, and at the client side
+232 -34
View File
@@ -138,18 +138,119 @@ pub fn pub_file_path(config_path: &Path) -> PathBuf {
.join(PUB_FILENAME) .join(PUB_FILENAME)
} }
/// Resolve a default Unix-socket path under the canonical order: /// How `/var/run/fips` participates in Unix control-socket resolution.
/// `/run/fips/<filename>` → `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`. #[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 /// This is intentionally stricter than matching any leaf named `fips`: an
/// created by the daemon at bind time, or by the postinst script). /// explicitly configured existing directory remains operator-owned unless it
/// `XDG_RUNTIME_DIR` covers dev runs where `/run/fips` does not exist. /// is also a canonical resolver candidate.
/// `/tmp` is the last-resort fallback. #[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/<filename>` → `/var/run/fips/<filename>` on macOS/FreeBSD →
/// `$XDG_RUNTIME_DIR/fips/<filename>` → `/tmp/fips-<filename>`.
///
/// `/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 /// Selection is by *existence*, not writability. A fips-group member
/// whose shell session has not picked up the supplementary group (no /// whose shell session has not picked up the supplementary group (no
/// re-login after `usermod -aG fips`) cannot tempfile-probe a /// 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 — /// it once the kernel checks the actual group at `connect(2)` time —
/// and even where the user genuinely cannot connect, surfacing an /// and even where the user genuinely cannot connect, surfacing an
/// `EACCES` from the socket call is clearer than silently steering /// `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. /// is treated as missing.
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn resolve_default_socket(filename: &str) -> String { pub(crate) fn resolve_default_socket(filename: &str) -> String {
// 1. /run/fips — preferred whenever the directory exists. let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from);
if Path::new("/run/fips").is_dir() { resolve_default_socket_with(
return format!("/run/fips/{filename}"); filename,
} default_var_run_policy(),
xdg_runtime_dir.as_deref(),
// 1b. /var/run/fips — macOS and FreeBSD have no /run; the FreeBSD Path::is_dir,
// 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}")
} }
/// Default control socket path for fipsctl / fipstop. /// Default control socket path for fipsctl / fipstop.
/// ///
/// On Unix, delegates to [`resolve_default_socket`] for the canonical /// On Unix, delegates to [`resolve_default_socket`] for the canonical
/// `/run/fips` → `XDG_RUNTIME_DIR` → `/tmp` order. On Windows, returns the /// platform runtime directory → `XDG_RUNTIME_DIR` → `/tmp` order. On Windows,
/// default TCP port ("21210"). /// returns the default TCP port ("21210").
pub fn default_control_path() -> PathBuf { pub fn default_control_path() -> PathBuf {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -207,7 +291,7 @@ pub fn default_control_path() -> PathBuf {
/// Default gateway control socket path. /// 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 /// the main control socket). The gateway daemon itself uses a hardcoded
/// `/run/fips/gateway.sock` since gateway operation requires root for /// `/run/fips/gateway.sock` since gateway operation requires root for
/// NAT/conntrack management; this client-side resolver falls through /// NAT/conntrack management; this client-side resolver falls through
@@ -2386,6 +2470,120 @@ node:
assert!(cfg.accept_connections()); 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` /// Mutex serializing tests that mutate `XDG_RUNTIME_DIR`. `cargo test`
/// runs tests on multiple threads in the same process, and env mutation /// runs tests on multiple threads in the same process, and env mutation
/// is process-global, so concurrent env-touching tests would race. /// is process-global, so concurrent env-touching tests would race.
+5 -5
View File
@@ -868,11 +868,11 @@ impl ControlConfig {
/// Default control socket path. /// Default control socket path.
/// ///
/// On Unix, delegates to [`super::resolve_default_socket`] for the /// On Unix, delegates to [`super::resolve_default_socket`] for the shared
/// canonical `/run/fips` → `XDG_RUNTIME_DIR` → `/tmp` order shared with /// platform runtime-directory → `XDG_RUNTIME_DIR` → `/tmp` order. On
/// the client-side `default_control_path`. On Windows, returns a TCP /// Windows, returns a TCP port number as a string since Windows does not
/// port number as a string since Windows does not support Unix domain /// support Unix domain sockets; the control socket listens on localhost at
/// sockets; the control socket listens on localhost at this port. /// this port.
fn default_socket_path() -> String { fn default_socket_path() -> String {
#[cfg(unix)] #[cfg(unix)]
{ {
+162 -15
View File
@@ -131,6 +131,61 @@ mod unix_impl {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::net::UnixListener; 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<bool, std::io::Error> {
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). /// Control socket listener (Unix domain socket).
/// ///
/// Manages the Unix domain socket lifecycle: bind, accept, cleanup. /// Manages the Unix domain socket lifecycle: bind, accept, cleanup.
@@ -147,13 +202,20 @@ mod unix_impl {
pub fn bind(config: &ControlConfig) -> Result<Self, std::io::Error> { pub fn bind(config: &ControlConfig) -> Result<Self, std::io::Error> {
let socket_path = PathBuf::from(&config.socket_path); let socket_path = PathBuf::from(&config.socket_path);
// Create parent directory if it doesn't exist // Creation is useful for diagnostics, but ownership is keyed to
if let Some(parent) = socket_path.parent() // directory identity as well: systemd pre-creates /run/fips on
&& !parent.exists() // every Linux service start and initially owns it as root:root.
{ let managed_parent = match socket_path.parent() {
std::fs::create_dir_all(parent)?; Some(parent) => {
debug!(path = %parent.display(), "Created control socket directory"); 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 // Remove stale socket if it exists
if socket_path.exists() { if socket_path.exists() {
@@ -162,14 +224,13 @@ mod unix_impl {
let listener = UnixListener::bind(&socket_path)?; let listener = UnixListener::bind(&socket_path)?;
// Make the socket and its parent directory group-accessible so // Make the socket and its managed private directory group-accessible
// 'fips' group members can use fipsctl/fipstop without root. // so fips group members can use fipsctl/fipstop.
use std::os::unix::fs::PermissionsExt; set_control_socket_access(
std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o770))?; &socket_path,
Self::chown_to_fips_group(&socket_path); managed_parent.as_deref(),
if let Some(parent) = socket_path.parent() { Self::chown_to_fips_group,
Self::chown_to_fips_group(parent); )?;
}
info!(path = %socket_path.display(), "Control socket listening"); info!(path = %socket_path.display(), "Control socket listening");
@@ -291,6 +352,92 @@ mod unix_impl {
self.cleanup(); 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
);
}
}
} }
// ============================================================================ // ============================================================================
+20
View File
@@ -462,6 +462,26 @@ EOF
fail "fips.service did not stay up after gateway-enable restart" fail "fips.service did not stay up after gateway-enable restart"
fi 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 docker exec "$name" systemctl start fips-gateway.service >/dev/null 2>&1 || true
sleep 3 sleep 3
if docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null \ if docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null \