mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-05 19:18:25 +00:00
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:
committed by
Johnathan Corgan
parent
672d828ef5
commit
5e3892edb8
@@ -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,
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user