Watch
0
0
Fork
You've already forked hyperhive
0

write secret and key files atomically with their final mode

Each of these wrote its file in place (or set the mode after the
write), so a write cut short by a kill or a full disk left a partial
file behind, and some secrets existed for a moment at umask mode:

- swarm-authelia/swarm-grafana/hive-forge/swarm-bao generators: a
  partial file passed the `-s`/`-e` guard and was kept forever. They now
  capture the value first and land it with `atomic_write_secret`.
- swarm-controller webhook secret: a truncated file failed the 64-hex
  check and the secret was regenerated, out of step with the forge
  hooks. It is now written 0600 to a sibling temp, fsynced, renamed.
- hive-c0re htpasswd: the temp is now fsynced and set 0644 before the
  rename, like the sibling agents.conf writer, instead of relying on
  c0re's umask.
- hivectl wg init: the private key is created 0400 in a temp and
  renamed, instead of written at umask mode and chmod'ed after.

Closes #5068
This commit is contained in:
atlas 2026-10-09 15:53:39 +02:00 • committed by mara
commit 01b8387ab8
7 changed files with 156 additions and 16 deletions

View file

@ -120,12 +120,39 @@ fn load_or_generate_at(path: &std::path::Path) -> Result<String> {
let secret = generate_hex_secret()?;
std::fs::create_dir_all(path.parent().unwrap_or(path))
.with_context(|| format!("create dir for {}", path.display()))?;
std::fs::write(path, format!("{secret}\n"))
write_secret_0600(path, format!("{secret}\n").as_bytes())
.with_context(|| format!("write webhook secret to {}", path.display()))?;
tracing::info!(path = %path.display(), "webhook secret generated and persisted");
Ok(secret)
}
/// Write `contents` to a sibling temp file opened `0600` and rename it over
/// `path`, so the secret is never readable by anyone else and a crash never
/// leaves half of it at `path`. Errors carry paths, never `contents`.
fn write_secret_0600(path: &std::path::Path, contents: &[u8]) -> Result<()> {
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut tmp_name = path
.file_name()
.with_context(|| format!("{} has no file name", path.display()))?
.to_owned();
tmp_name.push(".tmp");
let tmp = path.with_file_name(tmp_name);
// A temp file left by a crashed earlier write would fail `create_new`.
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)
.with_context(|| format!("create {}", tmp.display()))?;
f.write_all(contents)
.and_then(|()| f.sync_all())
.with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename onto {}", path.display()))
}
/// Read 32 random bytes from `/dev/urandom` and hex-encode them.
///
/// `pub(super)` rather than private: `forge::Client::ensure_agent_user`
@ -600,6 +627,30 @@ mod tests {
);
}
/// A generated secret lands at `0600` whatever the umask, holds exactly
/// what was returned, and leaves no temp file beside it.
#[test]
fn a_generated_secret_is_0600_with_no_temp_left() {
use std::os::unix::fs::PermissionsExt as _;
let dir = std::env::temp_dir().join(format!("swarm-hook-mode-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("webhook-secret");
let secret = load_or_generate_at(&path).expect("generates");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
let content = std::fs::read_to_string(&path).expect("read");
let entries: Vec<_> = std::fs::read_dir(&dir)
.expect("readdir")
.map(|e| e.expect("entry").file_name())
.collect();
std::fs::remove_dir_all(&dir).ok();
assert_eq!(mode, 0o600);
assert_eq!(content, format!("{secret}\n"));
assert_eq!(entries, [std::ffi::OsString::from("webhook-secret")]);
}
/// A path that exists but cannot be read is an error, and nothing is
/// written over it. A directory stands in for the unreadable file because
/// its read fails the same way for root and non-root test runners.