Use C:\ProgramData\fips for Windows config, keys and peer ACLs

On Windows the config directory, the key directory and the peer ACL
defaults disagreed. SYSTEM_CONFIG_DIR and the ACL defaults resolved to
/etc/fips on the current drive, the search path never probed the
directory the service installer writes to, and fipsctl keygen wrote to
the per-user %APPDATA%\fips. A service started before a reboot found
none of the installed files and ran on defaults, and a peers.deny placed
beside the hosts file in C:\ProgramData\fips was never read, so the ACL
failed open.

Windows now uses C:\ProgramData\fips for all of them, the directory the
hosts file and the service installer already use, and the search path
probes it before the per-user config. A key left in %APPDATA%\fips is
still picked up when the new directory has none, by the daemon and by
fipsctl address, with a note to move it. The daemon warns when it ran on
a config found only in %APPDATA%\fips, which the service never reads.
fipsctl keygen points at a key left in %APPDATA%\fips, and
--install-service names the file the service reads.

A peers.allow or peers.deny left at the old /etc/fips location would
otherwise be dropped on upgrade, and a dropped deny list fails open. For
one release the daemon keeps reading each file from /etc/fips, resolved
against the current drive exactly as before, when it is not at the new
location, and warns naming both paths and asking for it to be moved.
When both exist the new file is read and the warning says the old one
is ignored. The choice is made per file and re-checked on every ACL
reload, so moving, adding or removing a file takes effect without a
restart, including a copy that keeps the old file's modification time.
The fallback is meant to be removed in a later release.

Linux, macOS and FreeBSD paths are unchanged.

The Windows ZIP's README told users to put fips.yaml beside fips.exe or
in %APPDATA%\fips, neither of which the service reads, and the reference
docs gave %APPDATA%\fips as fipsctl keygen's default. The README now
says the service reads C:\ProgramData\fips\fips.yaml and keeps the key,
hosts and peer ACL files beside it, and describes the per-user files a
foreground run also reads. The reference docs list C:\ProgramData\fips
as the Windows system config directory and keygen default.
This commit is contained in:
Johnathan Corgan
2026-09-24 23:12:45 +00:00
parent c99e6d3908
commit ce19d5bd23
9 changed files with 725 additions and 66 deletions
+4 -3
View File
@@ -66,14 +66,15 @@ highest-priority value wins.
| Priority | Path | Purpose |
| -------- | ---- | ------- |
| 1 | `/usr/local/etc/fips/fips.yaml` (macOS, FreeBSD), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults |
| 2 | `~/.config/fips/fips.yaml` | User preferences |
| 1 | `/usr/local/etc/fips/fips.yaml` (macOS, FreeBSD), `C:\ProgramData\fips\fips.yaml` (Windows), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults |
| 2 | `~/.config/fips/fips.yaml` (`%APPDATA%\fips\fips.yaml` on Windows) | User preferences |
| 3 | `~/.fips.yaml` | Legacy user config |
| 4 | `./fips.yaml` | Deployment-specific overrides |
On macOS and FreeBSD both system directories are probed: `/etc/fips`
first, then `/usr/local/etc/fips`, so the packaged file wins over a
leftover `/etc/fips` copy from an earlier install.
leftover `/etc/fips` copy from an earlier install. Windows likewise
probes `\etc\fips` on the current drive, then `C:\ProgramData\fips`.
Adjacent to the highest-priority config file the daemon reads (or
writes, on first start) the identity files:
+1 -1
View File
@@ -90,7 +90,7 @@ daemon.
| Flag | Argument | Default | Description |
| ---- | -------- | ------- | ----------- |
| `-d`, `--dir` | `DIR` | `/usr/local/etc/fips` (macOS, FreeBSD), `/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. |
| `-d`, `--dir` | `DIR` | `/usr/local/etc/fips` (macOS, FreeBSD), `/etc/fips` (other Unix), `C:\ProgramData\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. |
+7 -4
View File
@@ -13,21 +13,24 @@ locations, lowest to highest priority:
| Priority | Path | Purpose |
|----------|------|---------|
| 1 (lowest) | `/usr/local/etc/fips/fips.yaml` (macOS, FreeBSD), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults |
| 2 | `~/.config/fips/fips.yaml` | User preferences |
| 1 (lowest) | `/usr/local/etc/fips/fips.yaml` (macOS, FreeBSD), `C:\ProgramData\fips\fips.yaml` (Windows), `/etc/fips/fips.yaml` (other Unix) | System-wide defaults |
| 2 | `~/.config/fips/fips.yaml` (`%APPDATA%\fips\fips.yaml` on Windows) | User preferences |
| 3 | `~/.fips.yaml` | Legacy user config |
| 4 (highest) | `./fips.yaml` | Deployment-specific overrides |
All found files are loaded and merged in priority order. Values from higher
priority files override those from lower priority files. This allows a system
administrator to set site-wide defaults in the priority 1 path above,
`/usr/local/etc/fips/fips.yaml` on macOS and FreeBSD and
`/usr/local/etc/fips/fips.yaml` on macOS and FreeBSD,
`C:\ProgramData\fips\fips.yaml` on Windows and
`/etc/fips/fips.yaml` on other Unix systems, while individual
deployments override specific values in `./fips.yaml`.
On macOS and FreeBSD both directories are probed: `/etc/fips` first,
then `/usr/local/etc/fips`, so the packaged file wins over a leftover
`/etc/fips` copy from an earlier install.
`/etc/fips` copy from an earlier install. Windows likewise probes
`\etc\fips` on the current drive, then `C:\ProgramData\fips`, which is
the only directory the Windows service reads.
### CLI Option
+13 -2
View File
@@ -100,8 +100,19 @@ Control Socket:
fipsctl and fipstop connect to this port automatically.
Configuration:
Edit fips.yaml before starting. Place it in the same directory
as fips.exe, or in %APPDATA%\fips\, or set FIPS_CONFIG.
The service reads C:\ProgramData\fips\fips.yaml, where
install-service.ps1 puts it, and keeps fips.key, hosts,
peers.allow and peers.deny beside it. Edit fips.yaml there
before starting the service.
A foreground run takes -c <file>, or reads
C:\ProgramData\fips\fips.yaml and then, as per-user overrides
the service does not read, %APPDATA%\fips\fips.yaml,
%USERPROFILE%\.fips.yaml and .\fips.yaml. The key file sits
beside the last config loaded.
fipsctl keygen writes to C:\ProgramData\fips by default and
needs an elevated prompt.
"@ | Out-File -FilePath "$StagingDir\README.txt" -Encoding UTF8
# Create ZIP
+12 -4
View File
@@ -130,6 +130,12 @@ async fn run_daemon(
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
fips::node::warn_on_legacy_config_paths();
// Windows moved its config, key and ACL files from %APPDATA%\fips and
// /etc/fips to C:\ProgramData\fips; flag a config left behind. The peer
// ACL reloader reports ACL files left at the old location.
#[cfg(windows)]
fips::config::warn_legacy(&loaded_paths);
// Identity provisioning: config nsec > key file > generate ephemeral
let mut resolved = match resolve_identity(&config, &loaded_paths) {
Ok(r) => r,
@@ -414,10 +420,12 @@ mod service {
println!("Service '{}' installed successfully.", SERVICE_NAME);
println!("Start it with: sc start {}", SERVICE_NAME);
println!();
println!("Configuration: place fips.yaml in one of:");
println!(" - Current directory");
println!(" - %APPDATA%\\fips\\fips.yaml");
println!(" - Set FIPS_CONFIG environment variable");
let dir = std::path::Path::new(fips::config::SYSTEM_CONFIG_DIR);
println!(
"Configuration: the service reads {}",
dir.join("fips.yaml").display()
);
println!(" keep fips.key, hosts, peers.allow and peers.deny beside it.");
Ok(())
}
+57 -10
View File
@@ -353,16 +353,7 @@ fn print_response(value: &serde_json::Value) {
/// 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(fips::config::SYSTEM_CONFIG_DIR)
}
#[cfg(windows)]
{
dirs::config_dir()
.map(|d| d.join("fips"))
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData\\fips"))
}
PathBuf::from(fips::config::SYSTEM_CONFIG_DIR)
}
/// Check if `address` is an IPv6 literal in `fd00::/8` (FIPS mesh ULA range).
@@ -442,10 +433,42 @@ fn mesh_address(identity: Option<&str>, key: Option<&Path>) -> Result<Ipv6Addr,
match (identity, key) {
(Some(peer), _) => address_from_npub(&resolve_peer(peer)),
(None, Some(path)) => address_from_file(path),
#[cfg(windows)]
(None, None) => own_address(&default_key_dir()),
#[cfg(not(windows))]
(None, None) => address_from_key_dir(&default_key_dir()),
}
}
/// Derive this node's own mesh address on Windows, where the default key
/// directory moved from the per-user `%APPDATA%\fips`.
///
/// A `fips.key` or `fips.pub` in `dir` always answers first. Only when
/// neither does is a key left at the previous default used, with a note
/// saying so, which is the same rule the daemon applies at startup. A lookup
/// there that could not be made is reported rather than read as an absence.
#[cfg(windows)]
fn own_address(dir: &Path) -> Result<Ipv6Addr, String> {
let own = match address_from_key_dir(dir) {
Ok(addr) => return Ok(addr),
Err(e) => e,
};
match fips::config::legacy_key_fallback(&dir.join("fips.key"), dir, &fips::config::legacy_dir())
{
Ok(Some(key)) => {
eprintln!(
"note: no key in {}; using {}, the previous default — move it to {}",
dir.display(),
key.display(),
dir.display()
);
address_from_file(&key)
}
Ok(None) => Err(own),
Err(e) => Err(format!("{own}\n{e}")),
}
}
/// Derive a mesh address from a bech32 npub.
fn address_from_npub(npub: &str) -> Result<Ipv6Addr, String> {
let peer = PeerIdentity::from_npub(npub).map_err(|e| format!("invalid npub: {e}"))?;
@@ -525,6 +548,23 @@ fn main() {
);
}
// The Windows default key directory moved from %APPDATA%\fips to
// C:\ProgramData\fips; point at a key left at the old one.
#[cfg(windows)]
{
let legacy = fips::config::legacy_dir().join("fips.key");
if legacy.exists() && !key_path.exists() {
eprintln!(
"note: {} exists but the default key directory",
legacy.display()
);
eprintln!(
" is now {}; that key is no longer used by default.",
dir.display()
);
}
}
// symlink_metadata rather than exists: a dangling symlink at the key
// path reports exists() == false and would slip past the guard.
if key_path.symlink_metadata().is_ok() && !force {
@@ -1652,6 +1692,13 @@ mod tests {
assert_eq!(default_key_dir(), PathBuf::from("/etc/fips"));
}
// Windows keeps keys beside the service's config in C:\ProgramData\fips.
#[cfg(windows)]
#[test]
fn test_default_key_dir_follows_windows_layout() {
assert_eq!(default_key_dir(), PathBuf::from(r"C:\ProgramData\fips"));
}
/// Build a key file for `identity` in `dir` and return its path.
fn write_identity_key(dir: &Path, identity: &Identity) -> PathBuf {
let mut keypair = identity.keypair();
+157 -18
View File
@@ -51,13 +51,16 @@ pub use transport::{
const CONFIG_FILENAME: &str = "fips.yaml";
/// System-wide config directory, following the platform's packaging layout
/// (`/usr/local/etc/fips` on macOS and FreeBSD, `/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.
/// (`/usr/local/etc/fips` on macOS and FreeBSD, `C:\ProgramData\fips` on
/// Windows, where the service installer puts the config and the hosts file
/// already lived, `/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(any(target_os = "macos", target_os = "freebsd"))]
pub const SYSTEM_CONFIG_DIR: &str = "/usr/local/etc/fips";
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
#[cfg(windows)]
pub const SYSTEM_CONFIG_DIR: &str = r"C:\ProgramData\fips";
#[cfg(not(any(target_os = "macos", target_os = "freebsd", windows)))]
pub const SYSTEM_CONFIG_DIR: &str = "/etc/fips";
/// Default key filename, placed alongside the config file.
@@ -99,9 +102,75 @@ pub fn key_file_path(config_path: &Path) -> PathBuf {
///
/// 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.
/// platforms without needing a `cfg` of its own. On Windows the previous
/// default was the per-user `%APPDATA%\fips` instead; [`legacy_dir`] gives
/// the directory that applies on the running platform.
const LEGACY_SYSTEM_CONFIG_DIR: &str = "/etc/fips";
/// The previous default config directory, with the per-user config directory
/// passed in rather than looked up, so the Windows rule can be tested on any
/// platform.
///
/// `user` is the per-user config directory (`%APPDATA%` on Windows). Without
/// one there is nothing per-user to fall back to, and the historic system
/// directory stands in.
#[cfg(any(windows, test))]
fn legacy_from(user: Option<&Path>) -> PathBuf {
match user {
Some(dir) => dir.join("fips"),
None => PathBuf::from(LEGACY_SYSTEM_CONFIG_DIR),
}
}
/// The directory an identity key lived in before the current default.
///
/// On Windows that is the per-user `%APPDATA%\fips`, which `fipsctl keygen`
/// wrote to before the move to [`SYSTEM_CONFIG_DIR`]. Everywhere else it is
/// the historic `/etc/fips`.
pub fn legacy_dir() -> PathBuf {
#[cfg(windows)]
{
legacy_from(dirs::config_dir().as_deref())
}
#[cfg(not(windows))]
{
PathBuf::from(LEGACY_SYSTEM_CONFIG_DIR)
}
}
/// The per-user config that loaded in place of the system one, if any.
///
/// Returns `legacy/fips.yaml` when it is among `loaded` and
/// `system/fips.yaml` is not: the node is running on a config the Windows
/// service never reads. When both loaded, the per-user file is an ordinary
/// override merged over the system one, and nothing is reported.
#[cfg(any(windows, test))]
fn stranded_config(loaded: &[PathBuf], system: &Path, legacy: &Path) -> Option<PathBuf> {
let old = legacy.join(CONFIG_FILENAME);
let new = system.join(CONFIG_FILENAME);
(loaded.contains(&old) && !loaded.contains(&new)).then_some(old)
}
/// Warn about a config loaded from the location Windows used before
/// [`SYSTEM_CONFIG_DIR`] became `C:\ProgramData\fips`.
///
/// `loaded` is the list of config files the daemon loaded, in order. ACL
/// files left at their old location are reported, and still enforced for
/// now, by the peer ACL reloader.
#[cfg(windows)]
pub fn warn_legacy(loaded: &[PathBuf]) {
let system = Path::new(SYSTEM_CONFIG_DIR);
if let Some(stranded) = stranded_config(loaded, system, &legacy_dir()) {
tracing::warn!(
legacy = %stranded.display(),
current = %system.join(CONFIG_FILENAME).display(),
"Config loaded from %APPDATA%\\fips but not from C:\\ProgramData\\fips; \
the service reads only C:\\ProgramData\\fips — move fips.yaml and fips.key \
there to run this node as the service"
);
}
}
/// Find an identity key stranded at the legacy system config directory.
///
/// Adding a second system config directory to the search path moves the
@@ -123,7 +192,7 @@ const LEGACY_SYSTEM_CONFIG_DIR: &str = "/etc/fips";
///
/// Returns an error when either location cannot be examined, which the caller
/// aborts on: a lookup that failed is not evidence that no key is there.
fn legacy_key_fallback(
pub fn legacy_key_fallback(
key_path: &Path,
system_dir: &Path,
legacy_dir: &Path,
@@ -597,11 +666,9 @@ pub fn resolve_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),
)? {
if let Some(legacy) =
legacy_key_fallback(&key_path, Path::new(SYSTEM_CONFIG_DIR), &legacy_dir())?
{
// Guarded for the same reason as the current-path read above.
let nsec = Zeroizing::new(read_key_file(&legacy)?);
let identity = Identity::from_secret_str(&nsec)?;
@@ -967,12 +1034,13 @@ impl Config {
// keep working after an upgrade.
paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
// macOS and FreeBSD packaging install config under /usr/local/etc/fips;
// 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(any(target_os = "macos", target_os = "freebsd"))]
// macOS and FreeBSD packaging install config under /usr/local/etc/fips,
// and the Windows service installer under C:\ProgramData\fips; 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(any(target_os = "macos", target_os = "freebsd", windows))]
paths.push(PathBuf::from(SYSTEM_CONFIG_DIR).join(CONFIG_FILENAME));
// User config directory
@@ -1761,6 +1829,77 @@ node:
);
}
// --- the Windows move from %APPDATA%\fips to C:\ProgramData\fips ---
//
// The helpers take every directory as an argument, so the Windows rules
// run here on any platform against synthetic or temporary paths.
#[test]
fn stranded_config_names_a_config_loaded_only_from_the_old_windows_dir() {
let system = Path::new("/sys-cfg/fips");
let legacy = Path::new("/user-cfg/fips");
let old = legacy.join(CONFIG_FILENAME);
let new = system.join(CONFIG_FILENAME);
assert_eq!(
stranded_config(std::slice::from_ref(&old), system, legacy),
Some(old.clone()),
"a config loaded only from the old directory must be reported"
);
assert_eq!(
stranded_config(&[new, old], system, legacy),
None,
"a per-user config merged over the system one is an override, not stranded"
);
assert_eq!(stranded_config(&[], system, legacy), None);
assert_eq!(
stranded_config(&[PathBuf::from("./fips.yaml")], system, legacy),
None
);
}
#[test]
fn windows_key_fallback_finds_a_key_in_the_old_appdata_dir() {
let root = TempDir::new().unwrap();
let appdata = root.path().join("appdata");
let system = root.path().join("system");
fs::create_dir_all(appdata.join("fips")).unwrap();
fs::create_dir_all(&system).unwrap();
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
let legacy_key = appdata.join("fips").join(KEY_FILENAME);
fs::write(&legacy_key, format!("{nsec}\n")).unwrap();
assert_eq!(
legacy_key_fallback(
&system.join(KEY_FILENAME),
&system,
&legacy_from(Some(&appdata))
)
.unwrap(),
Some(legacy_key),
"a key left in the per-user directory must be found, not regenerated"
);
assert_eq!(legacy_from(None), PathBuf::from("/etc/fips"));
}
#[cfg(windows)]
#[test]
fn search_paths_probe_programdata_before_the_user_config_dir() {
let paths = Config::search_paths();
let system = PathBuf::from(r"C:\ProgramData\fips").join(CONFIG_FILENAME);
let at = |want: &Path| paths.iter().position(|p| p == want);
let system_at = at(&system).expect("C:\\ProgramData\\fips\\fips.yaml is probed");
if let Some(user) = dirs::config_dir() {
let user_at = at(&user.join("fips").join(CONFIG_FILENAME))
.expect("the per-user config is probed");
assert!(
system_at < user_at,
"the per-user config overrides the system one"
);
}
}
#[test]
fn test_to_yaml() {
let mut config = Config::new();
+472 -12
View File
@@ -27,21 +27,26 @@ use tracing::{debug, error, info, warn};
/// On macOS (`packaging/macos/`) and FreeBSD (`packaging/freebsd/`) the
/// install layout 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(not(any(target_os = "macos", target_os = "freebsd")))]
/// reads the file the operator was told to edit. Windows uses
/// `C:\ProgramData\fips\`, beside the config and hosts file the service
/// installer sets up. Linux and other Unix keep the historic `/etc/fips/`
/// location.
#[cfg(not(any(target_os = "macos", target_os = "freebsd", windows)))]
pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/etc/fips/peers.allow";
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/usr/local/etc/fips/peers.allow";
#[cfg(windows)]
pub const DEFAULT_PEERS_ALLOW_PATH: &str = r"C:\ProgramData\fips\peers.allow";
/// Default path for the peer deny list.
///
/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the `/usr/local/etc/fips/`
/// rationale.
#[cfg(not(any(target_os = "macos", target_os = "freebsd")))]
/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the per-platform rationale.
#[cfg(not(any(target_os = "macos", target_os = "freebsd", windows)))]
pub const DEFAULT_PEERS_DENY_PATH: &str = "/etc/fips/peers.deny";
#[cfg(any(target_os = "macos", target_os = "freebsd"))]
pub const DEFAULT_PEERS_DENY_PATH: &str = "/usr/local/etc/fips/peers.deny";
#[cfg(windows)]
pub const DEFAULT_PEERS_DENY_PATH: &str = r"C:\ProgramData\fips\peers.deny";
/// Warn about config files stranded at the pre-move default location.
///
@@ -70,6 +75,145 @@ pub fn warn_on_legacy_config_paths() {
}
}
/// Which of an ACL file's two locations the reloader reads.
///
/// Windows read `peers.allow` and `peers.deny` from `\etc\fips` on the current
/// drive before they moved to `C:\ProgramData\fips`. Ignoring a deny list
/// left at the old location would fail open, so for one release a file found
/// only there is still enforced, with a warning to move it. This fallback, with
/// [`default_legacy_paths`], is meant to be removed in a later release.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AclPathChoice {
/// Read the current default; nothing is at the legacy location.
Current,
/// Read the legacy location: the file is there and not at the current
/// default.
Legacy,
/// Read the current default; a file also left at the legacy location is
/// ignored.
LegacyIgnored,
}
/// Choose where to read one ACL file from: its current default path, or the
/// location an earlier release read it from.
///
/// `exists` is passed in so the rule can be tested on any platform. The
/// current path wins whenever it exists; the legacy one is read only when it
/// is the sole file present.
fn select_acl_path(current: &Path, legacy: &Path, exists: impl Fn(&Path) -> bool) -> AclPathChoice {
match (exists(current), exists(legacy)) {
(true, true) => AclPathChoice::LegacyIgnored,
(false, true) => AclPathChoice::Legacy,
(_, false) => AclPathChoice::Current,
}
}
/// Whether a path exists, counting one that cannot be checked as present.
///
/// A current file that is there but inaccessible then stays selected, and
/// the loader reports it as unreadable, rather than an older legacy file
/// being enforced in its place without a word.
fn path_present(path: &Path) -> bool {
path.try_exists().unwrap_or(true)
}
/// Locations an earlier release read the ACL files from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LegacyAclPaths {
pub(crate) allow: PathBuf,
pub(crate) deny: PathBuf,
}
/// The legacy ACL locations honoured on this platform.
///
/// On Windows that is `/etc/fips`, the exact path the `/etc/fips/peers.*`
/// defaults named before the move to `C:\ProgramData\fips`. It is kept
/// drive-relative on purpose: Windows resolves it against the current
/// drive, as it did then, so a run started from another drive still finds
/// the file an earlier release read there. Other platforms have
/// none; see [`AclPathChoice`] for why the fallback exists and that it is
/// meant to be removed in a later release.
#[cfg(windows)]
fn default_legacy_paths() -> Option<LegacyAclPaths> {
Some(LegacyAclPaths {
allow: PathBuf::from("/etc/fips/peers.allow"),
deny: PathBuf::from("/etc/fips/peers.deny"),
})
}
/// The legacy ACL locations honoured on this platform: none.
#[cfg(not(windows))]
fn default_legacy_paths() -> Option<LegacyAclPaths> {
None
}
/// One ACL file's current default and legacy location, with the choice the
/// last selection made between them.
struct AclFallback {
current: PathBuf,
legacy: PathBuf,
/// `None` until the first selection, so the startup choice is logged.
choice: Option<AclPathChoice>,
}
impl AclFallback {
/// Pair a current default path with its legacy location.
fn new(current: PathBuf, legacy: PathBuf) -> Self {
Self {
current,
legacy,
choice: None,
}
}
/// Select the location to read, logging the choice when it changes.
fn select(&mut self) -> &Path {
let choice = select_acl_path(&self.current, &self.legacy, path_present);
if self.choice != Some(choice) {
match choice {
AclPathChoice::Legacy => warn!(
legacy = %self.legacy.display(),
current = %self.current.display(),
"Peer ACL file found only at its legacy path; enforcing it from there \
for now — move it to the current path, the legacy path will stop \
being read in a later release"
),
AclPathChoice::LegacyIgnored => warn!(
legacy = %self.legacy.display(),
current = %self.current.display(),
"Peer ACL file found at both its legacy and current paths; the legacy \
file is ignored — remove it"
),
AclPathChoice::Current if self.choice.is_some() => info!(
legacy = %self.legacy.display(),
current = %self.current.display(),
"Peer ACL file no longer at its legacy path; reading the current path"
),
AclPathChoice::Current => {}
}
self.choice = Some(choice);
}
match choice {
AclPathChoice::Legacy => &self.legacy,
AclPathChoice::Current | AclPathChoice::LegacyIgnored => &self.current,
}
}
}
/// Point `path` at the location `fallback` selects, if there is a fallback,
/// and report whether it moved.
fn reselect(fallback: &mut Option<AclFallback>, path: &mut PathBuf) -> bool {
let Some(fallback) = fallback else {
return false;
};
let selected = fallback.select();
if selected == path.as_path() {
return false;
}
*path = selected.to_path_buf();
true
}
/// Result of evaluating a peer against the ACL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerAclDecision {
@@ -390,8 +534,14 @@ pub struct PeerAclReloader {
/// Reader-facing effective ACL snapshot.
acl: arc_swap::ArcSwap<PeerAcl>,
hosts: HostMapReloader,
/// The allow and deny files read on the last load: the current defaults,
/// or a legacy location a fallback below selected instead.
allow_path: PathBuf,
deny_path: PathBuf,
/// Legacy locations re-checked on every reload, so the choice follows
/// the files as the operator moves them. `None` off Windows.
allow_fallback: Option<AclFallback>,
deny_fallback: Option<AclFallback>,
last_allow_mtime: Option<SystemTime>,
last_deny_mtime: Option<SystemTime>,
/// Set while a reload input is unreadable. Forces the next reload
@@ -407,12 +557,7 @@ impl PeerAclReloader {
/// Create a reloader using the standard ACL file locations.
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_alias_sources(
PathBuf::from(DEFAULT_PEERS_ALLOW_PATH),
PathBuf::from(DEFAULT_PEERS_DENY_PATH),
HostMap::new(),
PathBuf::from(DEFAULT_HOSTS_PATH),
)
Self::with_default_paths(HostMap::new(), PathBuf::from(DEFAULT_HOSTS_PATH))
}
/// Create a reloader for explicit ACL file paths.
@@ -426,13 +571,49 @@ impl PeerAclReloader {
)
}
/// Create the node's reloader: the platform's default ACL paths, plus
/// the legacy locations still honoured there (Windows only).
pub(crate) fn with_default_paths(base_hosts: HostMap, hosts_path: PathBuf) -> Self {
Self::with_legacy_sources(
PathBuf::from(DEFAULT_PEERS_ALLOW_PATH),
PathBuf::from(DEFAULT_PEERS_DENY_PATH),
default_legacy_paths(),
base_hosts,
hosts_path,
)
}
/// Create a reloader with explicit ACL paths and alias sources.
#[cfg(test)]
pub(crate) fn with_alias_sources(
allow_path: PathBuf,
deny_path: PathBuf,
base_hosts: HostMap,
hosts_path: PathBuf,
) -> Self {
Self::with_legacy_sources(allow_path, deny_path, None, base_hosts, hosts_path)
}
/// Create a reloader with explicit ACL paths and alias sources that
/// reads each ACL file from its `legacy` location while the file is
/// absent from its current one.
pub(crate) fn with_legacy_sources(
mut allow_path: PathBuf,
mut deny_path: PathBuf,
legacy: Option<LegacyAclPaths>,
base_hosts: HostMap,
hosts_path: PathBuf,
) -> Self {
let (mut allow_fallback, mut deny_fallback) = match legacy {
Some(legacy) => (
Some(AclFallback::new(allow_path.clone(), legacy.allow)),
Some(AclFallback::new(deny_path.clone(), legacy.deny)),
),
None => (None, None),
};
reselect(&mut allow_fallback, &mut allow_path);
reselect(&mut deny_fallback, &mut deny_path);
let last_allow_mtime = file_mtime(&allow_path);
let last_deny_mtime = file_mtime(&deny_path);
let hosts = HostMapReloader::new(base_hosts, hosts_path);
@@ -459,6 +640,8 @@ impl PeerAclReloader {
hosts,
allow_path,
deny_path,
allow_fallback,
deny_fallback,
last_allow_mtime,
last_deny_mtime,
retry_pending,
@@ -512,6 +695,10 @@ impl Reloadable for PeerAclReloader {
type Snapshot = PeerAcl;
async fn reload(&mut self) -> bool {
// A switch between a legacy and a current location forces the load:
// a copied file can carry the same mtime as the one it replaces.
let allow_moved = reselect(&mut self.allow_fallback, &mut self.allow_path);
let deny_moved = reselect(&mut self.deny_fallback, &mut self.deny_path);
let allow_mtime = file_mtime(&self.allow_path);
let deny_mtime = file_mtime(&self.deny_path);
let hosts_changed = match self.hosts.try_check_reload() {
@@ -527,6 +714,8 @@ impl Reloadable for PeerAclReloader {
&& deny_mtime == self.last_deny_mtime
&& !hosts_changed
&& !self.retry_pending
&& !allow_moved
&& !deny_moved
{
return false;
}
@@ -707,6 +896,277 @@ mod tests {
assert_eq!(DEFAULT_PEERS_DENY_PATH, "/etc/fips/peers.deny");
}
// Windows keeps config, hosts and the ACL files in C:\ProgramData\fips,
// where the service installer puts them.
#[cfg(windows)]
#[test]
fn test_default_acl_paths_follow_windows_layout() {
assert_eq!(DEFAULT_PEERS_ALLOW_PATH, r"C:\ProgramData\fips\peers.allow");
assert_eq!(DEFAULT_PEERS_DENY_PATH, r"C:\ProgramData\fips\peers.deny");
}
// The hosts file, both ACL files and the config all belong in one
// directory on every platform. A default that sits anywhere else is read
// from a directory the operator was never told about, and a missing deny
// list fails open.
#[test]
fn default_hosts_and_acl_paths_sit_in_the_system_config_dir() {
let system = Some(Path::new(crate::config::SYSTEM_CONFIG_DIR));
for path in [
DEFAULT_HOSTS_PATH,
DEFAULT_PEERS_ALLOW_PATH,
DEFAULT_PEERS_DENY_PATH,
] {
assert_eq!(
Path::new(path).parent(),
system,
"{path} is outside the system config directory"
);
}
}
// Before the move to C:\ProgramData\fips, Windows resolved the
// `/etc/fips/peers.*` defaults against the root of the current drive, so
// that is where an existing deny list sits after an upgrade. The legacy
// path stays drive-relative so a run from another drive finds it too.
#[cfg(windows)]
#[test]
fn windows_honours_acl_files_at_the_old_drive_relative_location() {
assert_eq!(
default_legacy_paths(),
Some(LegacyAclPaths {
allow: PathBuf::from("/etc/fips/peers.allow"),
deny: PathBuf::from("/etc/fips/peers.deny"),
})
);
}
// Only Windows moved its ACL files in a way that is still honoured; the
// other platforms read exactly the one default path they always did.
#[cfg(not(windows))]
#[test]
fn non_windows_platforms_have_no_legacy_acl_location() {
assert_eq!(default_legacy_paths(), None);
}
/// Existence check over a fixed set of paths, for the selection tests.
fn exists_among<'a>(present: &'a [&'a Path]) -> impl Fn(&Path) -> bool + 'a {
move |p| present.contains(&p)
}
#[test]
fn acl_path_selection_reads_the_legacy_file_when_only_it_exists() {
let current = Path::new(r"C:\ProgramData\fips\peers.deny");
let legacy = Path::new("/etc/fips/peers.deny");
assert_eq!(
select_acl_path(current, legacy, exists_among(&[legacy])),
AclPathChoice::Legacy
);
}
#[test]
fn acl_path_selection_prefers_the_current_file_and_flags_the_legacy_one_when_both_exist() {
let current = Path::new(r"C:\ProgramData\fips\peers.deny");
let legacy = Path::new("/etc/fips/peers.deny");
assert_eq!(
select_acl_path(current, legacy, exists_among(&[current, legacy])),
AclPathChoice::LegacyIgnored
);
}
#[test]
fn acl_path_selection_reads_the_current_path_when_nothing_is_at_the_legacy_one() {
let current = Path::new(r"C:\ProgramData\fips\peers.deny");
let legacy = Path::new("/etc/fips/peers.deny");
assert_eq!(
select_acl_path(current, legacy, exists_among(&[current])),
AclPathChoice::Current
);
// Absent from both: the current path is read, and its absence is the
// ordinary "no deny list" it has always been.
assert_eq!(
select_acl_path(current, legacy, exists_among(&[])),
AclPathChoice::Current
);
}
/// Current and legacy ACL paths under a temporary root, as
/// `(allow, deny, legacy)`, with both directories created.
fn legacy_layout(root: &Path) -> (PathBuf, PathBuf, LegacyAclPaths) {
let new = root.join("new");
let old = root.join("old");
std::fs::create_dir_all(&new).unwrap();
std::fs::create_dir_all(&old).unwrap();
let legacy = LegacyAclPaths {
allow: old.join("peers.allow"),
deny: old.join("peers.deny"),
};
(new.join("peers.allow"), new.join("peers.deny"), legacy)
}
fn legacy_reloader(
root: &Path,
allow: &Path,
deny: &Path,
legacy: &LegacyAclPaths,
) -> PeerAclReloader {
PeerAclReloader::with_legacy_sources(
allow.to_path_buf(),
deny.to_path_buf(),
Some(legacy.clone()),
HostMap::new(),
root.join("hosts"),
)
}
#[test]
fn a_deny_list_left_only_at_the_legacy_location_is_still_enforced() {
let dir = tempfile::tempdir().unwrap();
let (allow, deny, legacy) = legacy_layout(dir.path());
let denied = test_npub();
let allowed = test_npub();
// The two files are chosen independently: the allow list is at its
// current path, the deny list only at the legacy one.
write_file(&allow, &format!("{allowed}\n"));
write_file(&legacy.deny, &format!("{denied}\n"));
let reloader = legacy_reloader(dir.path(), &allow, &deny, &legacy);
assert_eq!(
reloader.acl().check(&test_peer(&denied)),
PeerAclDecision::DenyList
);
assert_eq!(
reloader.acl().check(&test_peer(&allowed)),
PeerAclDecision::AllowList
);
let status = reloader.status();
assert_eq!(status.deny_file, legacy.deny.display().to_string());
assert_eq!(status.allow_file, allow.display().to_string());
}
#[test]
fn a_current_acl_file_wins_over_one_left_at_the_legacy_location() {
let dir = tempfile::tempdir().unwrap();
let (allow, deny, legacy) = legacy_layout(dir.path());
let old_denied = test_npub();
let new_denied = test_npub();
write_file(&legacy.deny, &format!("{old_denied}\n"));
write_file(&deny, &format!("{new_denied}\n"));
let reloader = legacy_reloader(dir.path(), &allow, &deny, &legacy);
assert_eq!(
reloader.acl().check(&test_peer(&new_denied)),
PeerAclDecision::DenyList
);
assert_eq!(
reloader.acl().check(&test_peer(&old_denied)),
PeerAclDecision::DefaultAllow
);
assert_eq!(reloader.status().deny_file, deny.display().to_string());
}
#[tokio::test]
async fn acl_reload_re_evaluates_the_legacy_fallback_as_files_come_and_go() {
let dir = tempfile::tempdir().unwrap();
let (allow, deny, legacy) = legacy_layout(dir.path());
let old_denied = test_npub();
let new_denied = test_npub();
write_file(&legacy.deny, &format!("{old_denied}\n"));
let mut reloader = legacy_reloader(dir.path(), &allow, &deny, &legacy);
assert_eq!(
reloader.acl().check(&test_peer(&old_denied)),
PeerAclDecision::DenyList
);
assert!(!reloader.reload().await, "nothing changed on disk");
// The operator puts a deny list at the current path: it takes over.
std::thread::sleep(std::time::Duration::from_millis(5));
write_file(&deny, &format!("{new_denied}\n"));
assert!(reloader.reload().await);
assert_eq!(
reloader.acl().check(&test_peer(&new_denied)),
PeerAclDecision::DenyList
);
assert_eq!(
reloader.acl().check(&test_peer(&old_denied)),
PeerAclDecision::DefaultAllow
);
assert_eq!(reloader.status().deny_file, deny.display().to_string());
// The current file goes away again: the legacy one is back in force.
std::fs::remove_file(&deny).unwrap();
assert!(reloader.reload().await);
assert_eq!(
reloader.acl().check(&test_peer(&old_denied)),
PeerAclDecision::DenyList
);
assert_eq!(
reloader.status().deny_file,
legacy.deny.display().to_string()
);
}
// A copy keeps its source's modification time on Windows, so a switch
// between the two locations can leave the tracked mtime unchanged. The
// switch itself must force the reload.
#[tokio::test]
async fn acl_reload_follows_a_switch_of_location_even_when_the_mtimes_match() {
let dir = tempfile::tempdir().unwrap();
let (allow, deny, legacy) = legacy_layout(dir.path());
let old_denied = test_npub();
let new_denied = test_npub();
write_file(&legacy.deny, &format!("{old_denied}\n"));
let mut reloader = legacy_reloader(dir.path(), &allow, &deny, &legacy);
assert_eq!(
reloader.acl().check(&test_peer(&old_denied)),
PeerAclDecision::DenyList
);
write_file(&deny, &format!("{new_denied}\n"));
let old_mtime = std::fs::metadata(&legacy.deny).unwrap().modified().unwrap();
std::fs::File::options()
.write(true)
.open(&deny)
.unwrap()
.set_modified(old_mtime)
.unwrap();
assert_eq!(file_mtime(&deny), file_mtime(&legacy.deny));
assert!(reloader.reload().await);
assert_eq!(
reloader.acl().check(&test_peer(&new_denied)),
PeerAclDecision::DenyList
);
}
// A current file whose existence cannot be checked must not hand the
// decision to an older legacy file: it stays selected, and the loader
// reports it as unreadable. Root bypasses the directory's mode bits, so
// the test skips there rather than passing vacuously.
#[cfg(unix)]
#[test]
fn an_acl_path_that_cannot_be_checked_counts_as_present() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let sealed = dir.path().join("sealed");
std::fs::create_dir(&sealed).unwrap();
let file = sealed.join("peers.deny");
write_file(&file, "");
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o000)).unwrap();
let checkable = file.try_exists().is_ok();
let present = path_present(&file);
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
if checkable {
eprintln!("skipping: the effective uid can search a mode-000 directory");
return;
}
assert!(present);
}
#[test]
fn test_acl_decision_allowed_and_display() {
assert!(PeerAclDecision::AllowList.allowed());
+2 -12
View File
@@ -777,12 +777,7 @@ impl Node {
let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
let host_map =
reloadable::HostMapReloadable::new(base_host_map.clone(), hosts_path.clone());
let peer_acl = acl::PeerAclReloader::with_alias_sources(
std::path::PathBuf::from(acl::DEFAULT_PEERS_ALLOW_PATH),
std::path::PathBuf::from(acl::DEFAULT_PEERS_DENY_PATH),
base_host_map,
hosts_path,
);
let peer_acl = acl::PeerAclReloader::with_default_paths(base_host_map, hosts_path);
#[cfg(unix)]
let (decrypt_fallback_tx, decrypt_fallback_rx) =
@@ -942,12 +937,7 @@ impl Node {
let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH);
let host_map =
reloadable::HostMapReloadable::new(base_host_map.clone(), hosts_path.clone());
let peer_acl = acl::PeerAclReloader::with_alias_sources(
std::path::PathBuf::from(acl::DEFAULT_PEERS_ALLOW_PATH),
std::path::PathBuf::from(acl::DEFAULT_PEERS_DENY_PATH),
base_host_map,
hosts_path,
);
let peer_acl = acl::PeerAclReloader::with_default_paths(base_host_map, hosts_path);
#[cfg(unix)]
let (decrypt_fallback_tx, decrypt_fallback_rx) =