Add `hivectl gateway {create-user,delete-user,list-users}` subcommands for
managing htpasswd files used by gateway Basic auth. Pure Rust bcrypt
(cost 12, $2y$ prefix nginx accepts). No external htpasswd binary required.
Also fix the NixOS module assertion: `cfg.auth ? htpasswdFile` is always
true in the module system (declared options always exist as keys); switch
to `nullOr path; default = null` + `!= null` check so the assertion
actually fires with a useful error when enable=true but no file is set.
Guard bind-mount and nginx config against null to prevent eval errors.
Update docs/gateway.md to show hivectl commands instead of raw htpasswd.
134 lines
4.8 KiB
Rust
134 lines
4.8 KiB
Rust
//! `/var/lib/hyperhive/agent-ports.json` writer — flat map of
|
|
//! agent name → TCP web port. Written alongside `agents.conf` on
|
|
//! every topology change. JSON shape, port derivation (FNV-1a hash),
|
|
//! atomicity, and manager exclusion: `docs/gateway.md::Agent port map`.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::lifecycle::{self, MANAGER_NAME};
|
|
|
|
const HOST_PORTS_PATH: &str = "/var/lib/hyperhive/agent-ports.json";
|
|
|
|
#[must_use]
|
|
pub fn host_ports_path() -> PathBuf {
|
|
PathBuf::from(HOST_PORTS_PATH)
|
|
}
|
|
|
|
/// Compute the agent-port map for the given logical agent names.
|
|
/// Sub-agents only — manager is filtered out at the call boundary
|
|
/// because the gateway doesn't surface per-agent routing for it.
|
|
///
|
|
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
|
/// without churn produces byte-identical output (helpful when the
|
|
/// operator inspects the file by hand or diffs deploys).
|
|
#[must_use]
|
|
pub fn build_map(names: &[String]) -> BTreeMap<String, u16> {
|
|
names
|
|
.iter()
|
|
.filter(|n| n.as_str() != MANAGER_NAME)
|
|
.map(|n| (n.clone(), lifecycle::agent_web_port(n)))
|
|
.collect()
|
|
}
|
|
|
|
/// Render the map as pretty-printed JSON. Pretty so a human peek at
|
|
/// `cat /var/lib/hyperhive/agent-ports.json` shows one row per agent
|
|
/// — keeps the file readable without a separate jq step.
|
|
fn render(map: &BTreeMap<String, u16>) -> String {
|
|
// BTreeMap → serde_json::to_string_pretty preserves key order,
|
|
// so the output is deterministic across calls with the same
|
|
// agent set.
|
|
serde_json::to_string_pretty(map).expect("BTreeMap<String, u16> is always serialisable")
|
|
}
|
|
|
|
/// Atomically write the JSON for `names` to
|
|
/// `/var/lib/hyperhive/agent-ports.json`. Writes via a sibling
|
|
/// `<path>.tmp` + rename so a crashing process never leaves a
|
|
/// partial file behind that the gateway worker would fail to parse.
|
|
///
|
|
/// Idempotent — if the rendered content matches what's already on
|
|
/// disk, the write + rename are skipped so the file's mtime stays
|
|
/// stable and inotify watchers in nginx (or any future watchers)
|
|
/// don't fire spurious reload events.
|
|
pub fn write(names: &[String]) -> Result<()> {
|
|
let map = build_map(names);
|
|
let body = render(&map);
|
|
let path = host_ports_path();
|
|
if std::fs::read_to_string(&path).ok().as_deref() == Some(&body) {
|
|
return Ok(());
|
|
}
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
|
}
|
|
let tmp = path.with_extension("json.tmp");
|
|
std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
|
|
std::fs::rename(&tmp, &path).with_context(|| {
|
|
format!(
|
|
"rename {} -> {} (atomic publish)",
|
|
tmp.display(),
|
|
path.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn build_map_filters_manager() {
|
|
// Use MANAGER_NAME in the input so the assert below actually
|
|
// exercises the filter path — a literal `"root"` would pass
|
|
// trivially if the constant ever changed and the filter
|
|
// silently became a no-op.
|
|
let names: Vec<String> = ["iris", MANAGER_NAME, "argus"]
|
|
.iter()
|
|
.map(|s| (*s).to_owned())
|
|
.collect();
|
|
let map = build_map(&names);
|
|
assert!(!map.contains_key(MANAGER_NAME));
|
|
assert!(map.contains_key("iris"));
|
|
assert!(map.contains_key("argus"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_map_uses_agent_web_port() {
|
|
let names = vec!["iris".to_owned()];
|
|
let map = build_map(&names);
|
|
assert_eq!(map.get("iris"), Some(&lifecycle::agent_web_port("iris")));
|
|
}
|
|
|
|
#[test]
|
|
fn build_map_handles_empty_input() {
|
|
let map = build_map(&[]);
|
|
assert!(map.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_map_dedupes_via_btreemap_key_collision() {
|
|
// Duplicate inputs collapse via the map; the last value wins.
|
|
// No callsite passes dups today, but guarding the invariant
|
|
// here means a future bug doesn't surface as a corrupt JSON
|
|
// doc (two `"iris":` keys).
|
|
let names = vec!["iris".to_owned(), "iris".to_owned()];
|
|
let map = build_map(&names);
|
|
assert_eq!(map.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn render_is_pretty_and_sorted() {
|
|
let mut map = BTreeMap::new();
|
|
map.insert("zeta".to_owned(), 9000u16);
|
|
map.insert("alpha".to_owned(), 8000u16);
|
|
let body = render(&map);
|
|
// Pretty-print = newlines between keys + indentation.
|
|
assert!(body.contains('\n'));
|
|
// BTreeMap sorts → alpha before zeta in output.
|
|
let alpha_pos = body.find("alpha").expect("alpha in output");
|
|
let zeta_pos = body.find("zeta").expect("zeta in output");
|
|
assert!(alpha_pos < zeta_pos, "sorted order broken:\n{body}");
|
|
}
|
|
}
|