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

@ -317,7 +317,10 @@ fn htpasswd_read() -> Result<Vec<String>> {
/// Write lines back atomically (`<path>.tmp` then rename), with a trailing
/// newline.
fn htpasswd_write(lines: &[String]) -> Result<()> {
let path = htpasswd_path();
htpasswd_write_at(&htpasswd_path(), lines)
}
fn htpasswd_write_at(path: &std::path::Path, lines: &[String]) -> Result<()> {
let tmp = path.with_extension("htpasswd.tmp");
let content = if lines.is_empty() {
String::new()
@ -326,9 +329,16 @@ fn htpasswd_write(lines: &[String]) -> Result<()> {
s.push('\n');
s
};
std::fs::write(&tmp, &content)
let mut f = std::fs::File::create(&tmp)
.with_context(|| format!("create htpasswd tmp {}", tmp.display()))?;
std::io::Write::write_all(&mut f, content.as_bytes())
.and_then(|()| f.sync_all())
.with_context(|| format!("write htpasswd tmp {}", tmp.display()))?;
std::fs::rename(&tmp, &path)
// Mode set on the TMP file for the same reason as in `write()`: the
// rename carries it, and nginx reads this file as a different user.
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644))
.with_context(|| format!("chmod htpasswd tmp {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?;
Ok(())
}
@ -396,6 +406,27 @@ mod tests {
use super::*;
use crate::lifecycle::MANAGER_NAME;
/// The htpasswd file is published `0644` whatever the umask, holds the
/// lines newline-terminated, and leaves no temp file beside it.
#[test]
fn htpasswd_is_written_0644_with_no_temp_left() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("htpasswd");
std::fs::write(&path, "stale\n").expect("seed");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
htpasswd_write_at(&path, &["a:x".to_owned(), "b:y".to_owned()]).expect("write");
let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode, 0o644);
assert_eq!(std::fs::read_to_string(&path).expect("read"), "a:x\nb:y\n");
let entries: Vec<_> = std::fs::read_dir(dir.path())
.expect("readdir")
.map(|e| e.expect("entry").file_name())
.collect();
assert_eq!(entries, [std::ffi::OsString::from("htpasswd")]);
}
// ── legacy mode (frontend_dir = None) ──────────────────────────────
#[test]