diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2bdd3..36dda74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- macOS: `peers.allow`, `peers.deny`, and the `hosts` file are now read + from `/usr/local/etc/fips/`, matching the install layout the macOS + packaging ships (`packaging/macos/`). The default-path constants were + hardcoded to `/etc/fips/...` with only a `#[cfg(unix)]` / `#[cfg(windows)]` + split, so on macOS the daemon looked in a directory that does not exist: + `load_file` / `load_hosts_file` hit their `NotFound` no-op arm and silently + returned an empty ACL / empty host map. A populated `peers.deny` therefore + reported `effective_mode: "default_open"` and `enforcement_active: false` + via `fipsctl acl show`, and host-file aliases went unloaded, with no error + or warning. The default constants now follow the platform's packaging — + `/usr/local/etc/fips/` on macOS, `/etc/fips/` on Linux and other Unix + for the ACL files, and `/etc/fips/` on Linux and `%ProgramData%\fips\` + on Windows for the hosts file — and are pinned by platform-gated unit + tests so the layout cannot silently drift again. At startup the daemon + warns once if any of these files exist at the old `/etc/fips/` location + but not at the current default. Linux and Windows behavior is unchanged. + **macOS users with existing files in `/etc/fips/` should move them to + `/usr/local/etc/fips/`.** + +- macOS: `fipsctl keygen` now writes `fips.key` / `fips.pub` to + `/usr/local/etc/fips/` by default, matching the install layout the macOS + packaging ships. The default output directory was hardcoded to + `/etc/fips` for all Unix, but the daemon derives its identity key paths + from the config file's directory — `/usr/local/etc/fips/fips.yaml` on + macOS — so a generated identity landed where the daemon never reads it + and the node silently kept an ephemeral identity. Linux and other Unix + keep `/etc/fips`, Windows is unchanged, and the values are pinned by + platform-gated unit tests. + +- macOS: the system-wide config search path now includes + `/usr/local/etc/fips/fips.yaml` in addition to `/etc/fips/fips.yaml`, + matching the install layout the macOS packaging ships. Previously only + `/etc/fips/fips.yaml` was probed, so a bare `fips` run without `--config` + skipped the installed config and derived identity key paths from a + non-existent directory. `/etc/fips/fips.yaml` is still probed first so + existing installs keep working. Both the macOS entry in the search path + and the directory `fipsctl keygen` writes to read the shared + `SYSTEM_CONFIG_DIR` constant, so the two cannot drift apart. The + launchd-installed daemon was unaffected (it always passes `--config`). + Linux and Windows behavior is unchanged. Because the daemon derives the + identity key directory from whichever config file loaded last, a macOS host + carrying `fips.yaml` at both locations would have resolved `fips.key` to the + new directory, found none, and under `persistent` generated a fresh + identity — silently changing its npub, routing address and mesh IPv6. The + daemon now adopts a key stranded at `/etc/fips/fips.key` and warns to move + it, instead of generating one. The fallback is confined to keys resolved + from the system config directory, so a run using `./fips.yaml` or a user + config is never redirected to a system key. + - Nostr NAT traversal no longer breaks after the host suspends. The traversal clock cached a Unix timestamp once at startup and advanced it with a monotonic `Instant`, which does not tick while a machine is asleep, so after diff --git a/docs/how-to/persistent-identity.md b/docs/how-to/persistent-identity.md index 81934f7..975ee50 100644 --- a/docs/how-to/persistent-identity.md +++ b/docs/how-to/persistent-identity.md @@ -72,6 +72,49 @@ present after the first successful daemon start. If the daemon never came up cleanly (config error, permission problem), the key files will be missing. +### macOS note + +The macOS package (`.pkg`) installs config and keys under +`/usr/local/etc/fips/` instead of `/etc/fips/`. The paths above become: + +| Linux / other Unix | macOS | +| --- | --- | +| `/etc/fips/fips.yaml` | `/usr/local/etc/fips/fips.yaml` | +| `/etc/fips/fips.key` | `/usr/local/etc/fips/fips.key` | +| `/etc/fips/fips.pub` | `/usr/local/etc/fips/fips.pub` | + +`fipsctl keygen` writes to `/usr/local/etc/fips/` by default on macOS. +The daemon still probes `/etc/fips/fips.yaml` as a fallback (so an +existing install is not broken by an upgrade), but the macOS packaging +only installs files under `/usr/local/etc/fips/`. + +If you have files in `/etc/fips/` from a manual install, move them: + +```sh +sudo mv /etc/fips/fips.yaml /usr/local/etc/fips/fips.yaml +sudo mv /etc/fips/fips.key /usr/local/etc/fips/fips.key +sudo mv /etc/fips/fips.pub /usr/local/etc/fips/fips.pub +sudo mv /etc/fips/peers.allow /usr/local/etc/fips/peers.allow 2>/dev/null || true +sudo mv /etc/fips/peers.deny /usr/local/etc/fips/peers.deny 2>/dev/null || true +sudo mv /etc/fips/hosts /usr/local/etc/fips/hosts 2>/dev/null || true +``` + +The daemon logs a warning at startup if any of `peers.allow`, `peers.deny`, +or `hosts` exist at the old `/etc/fips/` path but not at +`/usr/local/etc/fips/`. `fips.yaml` is deliberately not included: both +directories stay on the config search path, so a config file left at +`/etc/fips/` is still read and is not a stranded file. + +`fips.key` is handled differently again, because losing it is worse than +not reading it. The daemon derives the key directory from whichever config +file loaded last, so a host carrying `fips.yaml` at both locations resolves +the key to `/usr/local/etc/fips/`. If no key is there and one exists at +`/etc/fips/fips.key`, the daemon **uses the old key and warns**, rather than +generating a new identity: a fresh keypair would change the node's npub, +routing address and mesh IPv6 with no migration path. Move the key when you +see that warning; the fallback exists to make the upgrade survivable, not +to be relied on. + ### File layout and permissions | Path | Mode | Owner | Contents | diff --git a/docs/reference/cli-fips.md b/docs/reference/cli-fips.md index 0d0ff4e..6895d32 100644 --- a/docs/reference/cli-fips.md +++ b/docs/reference/cli-fips.md @@ -66,7 +66,7 @@ highest-priority value wins. | Priority | Path | Purpose | | -------- | ---- | ------- | -| 1 | `/etc/fips/fips.yaml` | System-wide defaults | +| 1 | `/usr/local/etc/fips/fips.yaml` (macOS), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults | | 2 | `~/.config/fips/fips.yaml` | User preferences | | 3 | `~/.fips.yaml` | Legacy user config | | 4 | `./fips.yaml` | Deployment-specific overrides | diff --git a/docs/reference/cli-fipsctl.md b/docs/reference/cli-fipsctl.md index 3361e0a..3593117 100644 --- a/docs/reference/cli-fipsctl.md +++ b/docs/reference/cli-fipsctl.md @@ -89,7 +89,7 @@ daemon. | Flag | Argument | Default | Description | | ---- | -------- | ------- | ----------- | -| `-d`, `--dir` | `DIR` | `/etc/fips` (Unix), `%APPDATA%\fips` (Windows) | Output directory for `fips.key` and `fips.pub`. | +| `-d`, `--dir` | `DIR` | `/usr/local/etc/fips` (macOS), `/etc/fips` (other Unix), `%APPDATA%\fips` (Windows) | Output directory for `fips.key` and `fips.pub`. Matches the directory the platform's packaging installs config into, which is where the daemon derives the key paths from. | | `-f`, `--force` | — | off | Overwrite an existing `fips.key`. | | `-s`, `--stdout` | — | off | Print `nsec` then `npub` to stdout instead of writing files. | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f861087..ce60ebb 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -13,7 +13,7 @@ locations, lowest to highest priority: | Priority | Path | Purpose | |----------|------|---------| -| 1 (lowest) | `/etc/fips/fips.yaml` | System-wide defaults | +| 1 (lowest) | `/usr/local/etc/fips/fips.yaml` (macOS), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults | | 2 | `~/.config/fips/fips.yaml` | User preferences | | 3 | `~/.fips.yaml` | Legacy user config | | 4 (highest) | `./fips.yaml` | Deployment-specific overrides | diff --git a/src/bin/fips.rs b/src/bin/fips.rs index cc33687..50926ab 100644 --- a/src/bin/fips.rs +++ b/src/bin/fips.rs @@ -111,6 +111,11 @@ async fn run_daemon( } } + // The hosts/ACL defaults on macOS moved from /etc/fips to + // /usr/local/etc/fips; flag files stranded at the old location. + #[cfg(target_os = "macos")] + fips::node::warn_on_legacy_config_paths(); + // Identity provisioning: config nsec > key file > generate ephemeral let resolved = match resolve_identity(&config, &loaded_paths) { Ok(r) => r, diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 6cd868d..4f68d33 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -324,10 +324,13 @@ fn print_response(value: &serde_json::Value) { } /// Default directory for keygen output. +/// +/// Must match the platform's config dir, since the daemon derives key +/// paths from the config file's location. fn default_key_dir() -> PathBuf { #[cfg(unix)] { - PathBuf::from("/etc/fips") + PathBuf::from(fips::config::SYSTEM_CONFIG_DIR) } #[cfg(windows)] { @@ -652,6 +655,20 @@ fn sparkline(values: &[f64], min: f64, max: f64) -> String { mod tests { use super::*; + // macOS packaging ships config under /usr/local/etc/fips/. + #[cfg(target_os = "macos")] + #[test] + fn test_default_key_dir_follows_macos_packaging_layout() { + assert_eq!(default_key_dir(), PathBuf::from("/usr/local/etc/fips")); + } + + // Non-macOS Unix keeps the historic /etc/fips/ location. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn test_default_key_dir_keeps_etc_fips_layout() { + assert_eq!(default_key_dir(), PathBuf::from("/etc/fips")); + } + #[test] fn test_acl_show_command_name() { assert_eq!(AclCommands::Show.command_name(), "show_acl"); diff --git a/src/config/mod.rs b/src/config/mod.rs index 88db329..69e6770 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,7 +3,8 @@ //! Loads configuration from YAML files with a cascading priority system: //! 1. `./fips.yaml` (current directory - highest priority) //! 2. `~/.config/fips/fips.yaml` (user config directory) -//! 3. `/etc/fips/fips.yaml` (system - lowest priority) +//! 3. `/etc/fips/fips.yaml` (system - lowest priority; on macOS +//! `/usr/local/etc/fips/fips.yaml` is also probed, after `/etc/fips/`) //! //! Values from higher priority files override those from lower priority files. //! @@ -47,6 +48,15 @@ pub use transport::{ /// Default config filename. const CONFIG_FILENAME: &str = "fips.yaml"; +/// System-wide config directory, following the platform's packaging layout +/// (`/usr/local/etc/fips` on macOS, `/etc/fips` otherwise). The daemon +/// derives identity key paths from the config file's location, so anything +/// that reads or writes config-adjacent files should use this one constant. +#[cfg(target_os = "macos")] +pub const SYSTEM_CONFIG_DIR: &str = "/usr/local/etc/fips"; +#[cfg(not(target_os = "macos"))] +pub const SYSTEM_CONFIG_DIR: &str = "/etc/fips"; + /// Default key filename, placed alongside the config file. const KEY_FILENAME: &str = "fips.key"; @@ -83,6 +93,42 @@ pub fn key_file_path(config_path: &Path) -> PathBuf { .join(KEY_FILENAME) } +/// Legacy system config directory, from before the platform-packaging move. +/// +/// Equal to [`SYSTEM_CONFIG_DIR`] everywhere except where packaging installs +/// outside `/etc`, which makes [`legacy_key_fallback`] inert on those +/// platforms without needing a `cfg` of its own. +const LEGACY_SYSTEM_CONFIG_DIR: &str = "/etc/fips"; + +/// Find an identity key stranded at the legacy system config directory. +/// +/// Adding a second system config directory to the search path moves the +/// directory `resolve_identity` derives the key path from, because that +/// directory comes from whichever config file loaded last. A node that +/// carries a config at both locations would otherwise find no key at the new +/// one and, under `persistent`, generate a fresh identity — silently changing +/// its npub, routing address and mesh IPv6, none of which has a migration +/// path. +/// +/// Returns the legacy key only when all of these hold, which confines the +/// fallback to exactly that regression: +/// +/// - the two directories actually differ, so this is inert on Linux +/// - no key exists at `key_path` +/// - `key_path` sits in `system_dir`, so an operator using `./fips.yaml` or a +/// user config is never redirected to a system key +/// - a key does exist at `legacy_dir` +fn legacy_key_fallback(key_path: &Path, system_dir: &Path, legacy_dir: &Path) -> Option { + if system_dir == legacy_dir || key_path.exists() { + return None; + } + if key_path.parent() != Some(system_dir) { + return None; + } + let legacy = legacy_dir.join(KEY_FILENAME); + legacy.exists().then_some(legacy) +} + /// Derive the public key file path from a config file path. pub fn pub_file_path(config_path: &Path) -> PathBuf { config_path @@ -307,7 +353,31 @@ pub fn resolve_identity( }); } - // No key file yet — generate and persist + // No key at the resolved location. Before generating a new identity, + // check whether one is stranded at the legacy system config directory: + // generating here would silently change the node's npub, routing + // address and mesh IPv6. + if let Some(legacy) = legacy_key_fallback( + &key_path, + Path::new(SYSTEM_CONFIG_DIR), + Path::new(LEGACY_SYSTEM_CONFIG_DIR), + ) { + let nsec = read_key_file(&legacy)?; + let identity = Identity::from_secret_str(&nsec)?; + tracing::warn!( + legacy = %legacy.display(), + current = %key_path.display(), + "Identity key found at the legacy path but not at the current default; \ + using it so the node keeps its identity — move it to the current path" + ); + let _ = write_pub_file(&pub_path, &identity.npub()); + return Ok(ResolvedIdentity { + nsec, + source: IdentitySource::KeyFile(legacy), + }); + } + + // No key file anywhere — generate and persist let identity = Identity::generate(); let nsec = encode_nsec(&identity.keypair().secret_key()); let npub = identity.npub(); @@ -454,7 +524,8 @@ impl Config { /// Load configuration from the standard search paths. /// /// Files are loaded in reverse priority order and merged: - /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority) + /// 1. `/etc/fips/fips.yaml` (and `/usr/local/etc/fips/fips.yaml` on macOS; + /// loaded first, lowest priority) /// 2. `~/.config/fips/fips.yaml` (user config) /// 3. `./fips.yaml` (loaded last, highest priority) /// @@ -549,9 +620,18 @@ impl Config { pub fn search_paths() -> Vec { let mut paths = Vec::new(); - // System config (lowest priority) + // System config — /etc/fips is always probed so existing installs + // keep working after an upgrade. paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME)); + // macOS packaging installs config under /usr/local/etc/fips + // (Homebrew-style prefix); probe it after /etc/fips so the + // packaged file wins over a stale /etc/fips leftover. Read from + // SYSTEM_CONFIG_DIR rather than a second literal, so this path and + // the directory `fipsctl keygen` writes into cannot drift apart. + #[cfg(target_os = "macos")] + paths.push(PathBuf::from(SYSTEM_CONFIG_DIR).join(CONFIG_FILENAME)); + // User config directory if let Some(config_dir) = dirs::config_dir() { paths.push(config_dir.join("fips").join(CONFIG_FILENAME)); @@ -1057,13 +1137,103 @@ node: // Should include current directory assert!(paths.iter().any(|p| p.ends_with("fips.yaml"))); - // Should include /etc/fips on Unix - #[cfg(unix)] + // Should always include /etc/fips as a system config path assert!( paths .iter() .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml")) ); + + // macOS should also include /usr/local/etc/fips + #[cfg(target_os = "macos")] + assert!( + paths + .iter() + .any(|p| p.starts_with("/usr/local/etc/fips") && p.ends_with("fips.yaml")) + ); + } + + // --- legacy identity-key fallback --- + // + // Guards the regression the second system config directory introduces: + // the key directory follows whichever config file loaded last, so a node + // carrying config at both locations would find no key at the new one and + // generate a fresh identity. These drive `legacy_key_fallback` with real + // directories under a temp root rather than asserting on the constants, + // and they run on every platform because the function takes both + // directories as arguments. + + fn write_stub_key(dir: &Path) -> PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let p = dir.join(KEY_FILENAME); + std::fs::write(&p, "nsec1stub\n").unwrap(); + p + } + + #[test] + fn legacy_key_is_adopted_when_the_new_location_has_none() { + let root = TempDir::new().unwrap(); + let legacy = root.path().join("etc/fips"); + let system = root.path().join("usr/local/etc/fips"); + std::fs::create_dir_all(&system).unwrap(); + let legacy_key = write_stub_key(&legacy); + + let key_path = system.join(KEY_FILENAME); + assert_eq!( + legacy_key_fallback(&key_path, &system, &legacy), + Some(legacy_key), + "a key stranded at the legacy path must be adopted, not regenerated" + ); + } + + #[test] + fn a_key_at_the_new_location_wins_over_the_legacy_one() { + let root = TempDir::new().unwrap(); + let legacy = root.path().join("etc/fips"); + let system = root.path().join("usr/local/etc/fips"); + write_stub_key(&legacy); + let key_path = write_stub_key(&system); + + assert_eq!(legacy_key_fallback(&key_path, &system, &legacy), None); + } + + #[test] + fn no_fallback_when_the_two_directories_are_the_same() { + // The Linux case: nothing moved, so the fallback must be inert even + // though a key exists at that one directory. + let root = TempDir::new().unwrap(); + let dir = root.path().join("etc/fips"); + write_stub_key(&dir); + let absent = dir.join("nonexistent").join(KEY_FILENAME); + + assert_eq!(legacy_key_fallback(&absent, &dir, &dir), None); + } + + #[test] + fn no_fallback_for_a_config_outside_the_system_directory() { + // An operator running with ./fips.yaml or a user config must never be + // silently redirected to a system key. + let root = TempDir::new().unwrap(); + let legacy = root.path().join("etc/fips"); + let system = root.path().join("usr/local/etc/fips"); + let elsewhere = root.path().join("home/someone"); + std::fs::create_dir_all(&elsewhere).unwrap(); + write_stub_key(&legacy); + + let key_path = elsewhere.join(KEY_FILENAME); + assert_eq!(legacy_key_fallback(&key_path, &system, &legacy), None); + } + + #[test] + fn no_fallback_when_the_legacy_location_is_empty_too() { + let root = TempDir::new().unwrap(); + let legacy = root.path().join("etc/fips"); + let system = root.path().join("usr/local/etc/fips"); + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::create_dir_all(&system).unwrap(); + + let key_path = system.join(KEY_FILENAME); + assert_eq!(legacy_key_fallback(&key_path, &system, &legacy), None); } #[test] diff --git a/src/control/queries.rs b/src/control/queries.rs index 9c82847..2403673 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -2442,11 +2442,26 @@ mod tests { /// runtime state (no peers, links, sessions, transports, or cache /// entries). This keeps every per-element list empty and every /// scalar deterministic modulo `VOLATILE_KEYS`. + /// + /// The peer ACL is isolated from the host filesystem: the reloader is + /// repointed at non-existent paths under the process temp dir, so the + /// snapshot is stable regardless of whether an operator has edited the + /// real `peers.allow` / `peers.deny` on the dev/CI machine. Without this, + /// `show_acl` would reflect the host's live ACL state (e.g. a populated + /// `peers.deny` flips `effective_mode` from `default_open` to + /// `denylist`), making the snapshot machine-dependent. fn build_test_node() -> Node { let identity = Identity::from_secret_bytes(&TEST_SEED).expect("test seed is a valid secret key"); let config = Config::new(); - Node::with_identity(identity, config).expect("default config is valid") + let mut node = Node::with_identity(identity, config).expect("default config is valid"); + let nonce = std::process::id(); + let tmp = std::env::temp_dir(); + node.isolate_peer_acl_for_test( + tmp.join(format!("fips-snapshot-test-{nonce}.allow")), + tmp.join(format!("fips-snapshot-test-{nonce}.deny")), + ); + node } /// Recursively walk a JSON value, replacing the value of any key diff --git a/src/node/acl.rs b/src/node/acl.rs index c42bd5f..dbccbb1 100644 --- a/src/node/acl.rs +++ b/src/node/acl.rs @@ -23,11 +23,52 @@ use std::time::SystemTime; use tracing::{debug, info, warn}; /// Default path for the peer allow list. +/// +/// On macOS the install layout (see `packaging/macos/`) ships config under +/// `/usr/local/etc/fips/` rather than `/etc/fips/`; the default follows the +/// platform's packaging so the daemon reads the file the operator was told +/// to edit. Linux and other Unix keep the historic `/etc/fips/` location. +#[cfg(target_os = "macos")] +pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/usr/local/etc/fips/peers.allow"; +#[cfg(not(target_os = "macos"))] pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/etc/fips/peers.allow"; /// Default path for the peer deny list. +/// +/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the macOS `/usr/local/etc/fips/` +/// rationale. +#[cfg(target_os = "macos")] +pub const DEFAULT_PEERS_DENY_PATH: &str = "/usr/local/etc/fips/peers.deny"; +#[cfg(not(target_os = "macos"))] pub const DEFAULT_PEERS_DENY_PATH: &str = "/etc/fips/peers.deny"; +/// Warn about config files stranded at the pre-move default location. +/// +/// The macOS defaults for `hosts`, `peers.allow` and `peers.deny` moved +/// from `/etc/fips` to `/usr/local/etc/fips`, the directory the macOS +/// packaging actually populates. The old location is no longer read by +/// the default path constants, and a `peers.deny` silently left behind +/// there would fail open (a missing deny list is not an error), so surface +/// the situation loudly once at startup. +#[cfg(target_os = "macos")] +pub fn warn_on_legacy_config_paths() { + for (current, name) in [ + (crate::upper::hosts::DEFAULT_HOSTS_PATH, "hosts"), + (DEFAULT_PEERS_ALLOW_PATH, "peers.allow"), + (DEFAULT_PEERS_DENY_PATH, "peers.deny"), + ] { + let legacy = format!("/etc/fips/{name}"); + if std::path::Path::new(&legacy).exists() && !std::path::Path::new(current).exists() { + warn!( + legacy = %legacy, + current = %current, + "Config file found at legacy path but not at the current default; \ + it is no longer read — move it to the current path" + ); + } + } +} + /// Result of evaluating a peer against the ACL. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PeerAclDecision { @@ -451,6 +492,16 @@ impl Node { decision ))) } + + /// Test-only: replace the peer-ACL reloader with one that reads from + /// the given paths, isolating the node from the host's real + /// `peers.allow` / `peers.deny` files. Used by snapshot tests + /// that must be deterministic regardless of whether an operator has + /// edited the system ACL files on the dev/CI machine. + #[cfg(test)] + pub(crate) fn isolate_peer_acl_for_test(&mut self, allow: PathBuf, deny: PathBuf) { + self.peer_acl = PeerAclReloader::with_paths(allow, deny); + } } #[cfg(test)] @@ -487,6 +538,27 @@ mod tests { acl } + // Guard against the macOS path regression: the install layout + // (`packaging/macos/`) ships config under `/usr/local/etc/fips/`, so the + // default ACL paths must follow it, or `peers.allow`/`peers.deny` are + // silently unread on macOS (see the `NotFound` no-op in `load_file`). + #[cfg(target_os = "macos")] + #[test] + fn test_default_acl_paths_follow_macos_packaging_layout() { + assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/usr/local/etc/fips/peers.allow"); + assert_eq!(DEFAULT_PEERS_DENY_PATH, "/usr/local/etc/fips/peers.deny"); + } + + // Non-macOS Unix/Linux keeps the historic `/etc/fips/` location; this + // runs on the Linux CI matrix and pins the value so a future refactor + // can't silently drift it. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn test_default_acl_paths_keep_etc_fips_layout() { + assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/etc/fips/peers.allow"); + assert_eq!(DEFAULT_PEERS_DENY_PATH, "/etc/fips/peers.deny"); + } + #[test] fn test_acl_decision_allowed_and_display() { assert!(PeerAclDecision::AllowList.allowed()); diff --git a/src/node/mod.rs b/src/node/mod.rs index 60ac71b..6487751 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -5,6 +5,8 @@ //! Bloom filters, coordinate caches, transports, links, and peers. pub(crate) mod acl; +#[cfg(target_os = "macos")] +pub use acl::warn_on_legacy_config_paths; mod bloom; pub(crate) mod context; mod dataplane; diff --git a/src/upper/hosts.rs b/src/upper/hosts.rs index 7959deb..3ba6f56 100644 --- a/src/upper/hosts.rs +++ b/src/upper/hosts.rs @@ -17,7 +17,15 @@ use std::time::SystemTime; use tracing::{debug, info, warn}; /// Default path for the FIPS hosts file. -#[cfg(unix)] +/// +/// On macOS the install layout (see `packaging/macos/`) ships config under +/// `/usr/local/etc/fips/`, not `/etc/fips/`; the default follows the +/// platform's packaging so aliases edited by the operator are actually +/// loaded. Linux and other Unix keep the historic `/etc/fips/` location; +/// Windows uses `%ProgramData%`. +#[cfg(target_os = "macos")] +pub const DEFAULT_HOSTS_PATH: &str = "/usr/local/etc/fips/hosts"; +#[cfg(all(unix, not(target_os = "macos")))] pub const DEFAULT_HOSTS_PATH: &str = "/etc/fips/hosts"; #[cfg(windows)] pub const DEFAULT_HOSTS_PATH: &str = r"C:\ProgramData\fips\hosts"; @@ -311,6 +319,25 @@ mod tests { use super::*; use crate::Identity; + // --- default path tests --- + + // Guard against the macOS path regression: the install layout + // (`packaging/macos/`) ships config under `/usr/local/etc/fips/`, so the + // default hosts path must follow it, or host-file aliases are silently + // unloaded on macOS (see the `NotFound` no-op in `load_hosts_file`). + #[cfg(target_os = "macos")] + #[test] + fn test_default_hosts_path_follows_macos_packaging_layout() { + assert_eq!(DEFAULT_HOSTS_PATH, "/usr/local/etc/fips/hosts"); + } + + // Non-macOS Unix/Linux keeps the historic `/etc/fips/` location. + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn test_default_hosts_path_keeps_etc_fips_layout() { + assert_eq!(DEFAULT_HOSTS_PATH, "/etc/fips/hosts"); + } + // --- validate_hostname tests --- #[test]