Files
fips/src/utils/sockbind.rs
T
Johnathan Corgan 7ccc7adab0 Merge maint into master, carrying the twenty-one security fixes
Not a replay: master had independently reorganized or reimplemented nearly
every area the batch touches, so most of this is re-derivation. Four files
maint modified do not exist on master at all, and git marks those DU with no
conflict markers, so the lazy resolution would have dropped 499 lines while
the tree still built and still passed tests.

Two conflicts turned out to be the same defect already present on master in a
different place, and fixing it there reaches further than the original did.
Master had centralized socket binding into sockbind across three sockets, one
of which maint does not have, with the same create-wide-then-narrow window; and
master's shared PerAddrRateLimiter swept the whole map on every admit with no
entry ceiling, which is the routing-error limiter finding in a primitive the
lookup-forward limiter also uses.

Two others would have added dead code if replayed. Master had already deleted
the pre-refactor restart block the epoch dampener patched, so it went onto
InboundDecision::RestartThenPromote instead; and master already carried the
relocated reactive-MTU floor check, so the copy inside the conflict was the old
post-apply one that had been removed.

The lookup dedup eviction was re-derived into the hoisted lookup core, and its
target-side signing gate re-added to the request path, where the merge had
dropped it entirely and left the limiter inert.

Two regression tests were repaired rather than accepted. The gap-tracker test
adapted into master's ReceiverState style asserted an empty burst after a jump
to the ceiling counter, which is not what that code does; and the epoch
dampener's stamp-on-acceptance ordering was pinned by nothing, so a sustained
replay could have starved a restarting peer with every test still green. Both
now red when the fix they guard is reverted.

Gate: fmt, build, clippy -D warnings, 2273 tests passing, six repo guards on
the committed tree.
2026-08-23 15:49:39 +01:00

293 lines
11 KiB
Rust

//! Bind a Unix domain socket under the FIPS access policy.
//!
//! Every FIPS Unix socket needs the same four things before it can listen: a
//! parent directory that exists and whose ownership is known, any stale socket
//! file removed, a listener bound, and group access applied so members of the
//! `fips` group can reach it.
//!
//! The policy lives in one place so a change to it reaches every socket rather
//! than whichever copy an author happened to be looking at. All three sockets
//! use it: the control socket, the gateway control socket and the native
//! datagram API socket. The gateway previously kept its own copy, which had
//! already drifted in that it never set the parent's mode at all.
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(unix)]
use tracing::{debug, warn};
/// Bind a Unix listener at `path` under the FIPS access policy.
///
/// `what` names the socket for diagnostics: it is the noun in the "already in
/// use" error a caller sees when another process is listening there, and a
/// structured field on the directory and stale-socket log lines. The caller
/// emits its own "listening" line, so each socket keeps its own wording.
///
/// Creates missing ancestors, removes a stale socket file, binds, then applies
/// mode `0o770` to the socket and `0o750` to the parent when this bind owns the
/// parent. An `AddrInUse` error means a live listener already holds the path.
#[cfg(unix)]
pub fn bind(path: &Path, what: &str) -> Result<UnixListener, std::io::Error> {
// 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 path.parent() {
Some(parent) => {
let created = ensure_socket_parent(parent)?;
if created {
debug!(path = %parent.display(), socket = what, "Created private socket directory");
}
(created || crate::config::is_managed_socket_parent(parent)).then(|| parent.to_owned())
}
None => None,
};
if path.exists() {
remove_stale_socket(path, what)?;
}
// Bound through `sockperm` rather than `UnixListener::bind` directly:
// bind(2) creates the socket inode as `0777 & !umask`, so under a
// permissive umask it is world-accessible for the window between the
// bind and the `set_socket_access` chmod below. That chmod stays the
// authority on the final mode; this only closes the window.
let listener = crate::utils::sockperm::bind(path)?;
set_socket_access(path, managed_parent.as_deref(), chown_to_fips_group)?;
Ok(listener)
}
/// 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.
#[cfg(unix)]
fn ensure_socket_parent(parent: &Path) -> Result<bool, std::io::Error> {
use std::os::unix::fs::DirBuilderExt;
if parent.as_os_str().is_empty() {
return Ok(false);
}
// Mode carried on creation rather than applied afterwards. A directory
// made by plain `create_dir` is `0777 & !umask`, and an intermediate
// ancestor is never chmodded by anything below, so under a permissive
// umask it would stay world-writable for the life of the host — and a
// world-writable parent lets an unprivileged account plant an entry at
// the socket path. 0750 is what `set_socket_access` applies to a managed
// parent anyway, and what the systemd unit and FreeBSD rc script already
// use.
match std::fs::DirBuilder::new().mode(0o750).create(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 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.
///
/// `chown_to_fips_group` is a parameter so the policy can be tested without
/// requiring the `fips` group to exist on the machine running the tests.
#[cfg(unix)]
fn set_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(())
}
/// Remove a stale socket file.
///
/// If the file exists but no one is listening, remove it so we can bind. This
/// handles unclean daemon exits. A live listener yields `AddrInUse` instead, so
/// two daemons cannot silently take the same path.
///
/// The gap between the connect probe and the bind that follows is accepted
/// rather than closed. Reaching it needs write access to the socket's parent
/// directory, which the packaged layouts give to root alone (0750 and
/// root-owned under both systemd and the FreeBSD rc script), and an account
/// holding it can deny the daemon its socket more simply by squatting the path
/// before the daemon starts. The removal itself unlinks a symlink rather than
/// its target, so it is not an arbitrary delete.
#[cfg(unix)]
fn remove_stale_socket(path: &Path, what: &str) -> Result<(), std::io::Error> {
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!("{what} socket already in use: {}", path.display()),
)),
Err(_) => {
debug!(path = %path.display(), socket = what, "Removing stale socket");
std::fs::remove_file(path)?;
Ok(())
}
}
}
/// Set group ownership of a path to the `fips` group (best-effort).
///
/// A missing group is not an error: a source build on a developer machine has
/// no `fips` group, and the socket is still usable by its owner.
#[cfg(unix)]
fn chown_to_fips_group(path: &Path) {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let group_name = CString::new("fips").unwrap();
let grp = unsafe { libc::getgrnam(group_name.as_ptr()) };
if grp.is_null() {
debug!(
"'fips' group not found, skipping chown for {}",
path.display()
);
return;
}
let gid = unsafe { (*grp).gr_gid };
let c_path = match CString::new(path.as_os_str().as_bytes()) {
Ok(p) => p,
Err(_) => return,
};
let ret = unsafe { libc::chown(c_path.as_ptr(), u32::MAX, gid) };
if ret != 0 {
warn!(
path = %path.display(),
error = %std::io::Error::last_os_error(),
"Failed to chown socket to 'fips' group"
);
}
}
/// Remove a socket file at teardown, ignoring a path that is already gone.
#[cfg(unix)]
pub fn cleanup(path: &PathBuf, what: &str) {
if !path.exists() {
return;
}
match std::fs::remove_file(path) {
Ok(()) => debug!(path = %path.display(), socket = what, "Socket file removed"),
Err(error) => {
warn!(path = %path.display(), socket = what, error = %error, "Failed to remove socket file")
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::{ensure_socket_parent, set_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_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_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_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
);
}
}