feat(gateway): hivectl gateway user management + fix htpasswdFile assertion

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.
This commit is contained in:
atlas 2026-06-01 23:00:38 +02:00
commit 4bff450343
61 changed files with 1084 additions and 547 deletions

View file

@ -60,10 +60,7 @@ fn tasks_dir() -> PathBuf {
} else {
// Pre-split fallback: derive harness/ as a sibling of state/.
let state = crate::paths::state_dir();
state
.parent()
.map(|p| p.join("harness"))
.unwrap_or(state)
state.parent().map(|p| p.join("harness")).unwrap_or(state)
};
base.join("bash-tasks")
}
@ -136,9 +133,8 @@ impl TaskFile {
/// Write a task file atomically (tmp + rename).
fn write_task(task: &TaskFile) -> std::io::Result<()> {
let json = serde_json::to_string_pretty(task).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
let json = serde_json::to_string_pretty(task)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let dest = task_json(&task.id);
let tmp = dest.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
@ -229,7 +225,7 @@ async fn run_loop(socket: PathBuf) {
let claimed: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
loop {
poll_once(&socket, &claimed);
poll_once(&socket, &claimed).await;
tokio::time::sleep(POLL_INTERVAL).await;
}
}
@ -237,14 +233,20 @@ async fn run_loop(socket: PathBuf) {
/// On boot, find any task files in `running` state and flip them to
/// `interrupted`, then fire a wake so the agent unblocks.
async fn mark_interrupted(socket: &Path) {
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return };
let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let Some(mut task) = read_task(&id) else { continue };
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
let Some(mut task) = read_task(&id) else {
continue;
};
if task.status != TaskStatus::Running {
continue;
}
@ -258,14 +260,18 @@ async fn mark_interrupted(socket: &Path) {
}
}
fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
let Ok(rd) = std::fs::read_dir(tasks_dir()) else { return };
async fn poll_once(socket: &Path, claimed: &Arc<Mutex<HashSet<String>>>) {
let Ok(rd) = std::fs::read_dir(tasks_dir()) else {
return;
};
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
{
let guard = claimed.lock().unwrap();
if guard.contains(&id) {
@ -322,7 +328,11 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);
task.status = if timed_out { TaskStatus::TimedOut } else { TaskStatus::Done };
task.status = if timed_out {
TaskStatus::TimedOut
} else {
TaskStatus::Done
};
task.completed_at = Some(crate::serve_common::now_unix());
task.exit_code = exit_code;
task.stdout_tail = stdout_tail.clone().filter(|s| !s.is_empty());
@ -344,7 +354,12 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
/// Run `sh -c cmd`, streaming output to files. Returns `(exit_code, timed_out)`.
/// On timeout the child process is explicitly killed before returning.
async fn exec_cmd(cmd: &str, out_path: &Path, err_path: &Path, timeout: Duration) -> Result<(i32, bool)> {
async fn exec_cmd(
cmd: &str,
out_path: &Path,
err_path: &Path,
timeout: Duration,
) -> Result<(i32, bool)> {
use tokio::process::Command;
let mut child = Command::new("sh")
.arg("-c")
@ -396,7 +411,9 @@ where
let _ = tokio::io::copy(&mut reader, &mut f).await;
let _ = f.flush().await;
}
Err(e) => tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed"),
Err(e) => {
tracing::warn!(path = %path.display(), error = ?e, "bash_runner: open output file failed")
}
}
}
@ -415,12 +432,7 @@ fn tail_file(path: &Path, max_bytes: usize) -> Option<String> {
// Wake delivery
// ---------------------------------------------------------------------------
async fn send_wake(
socket: &Path,
id: &str,
summary: &str,
output: Option<(&str, &str)>,
) {
async fn send_wake(socket: &Path, id: &str, summary: &str, output: Option<(&str, &str)>) {
let mut body = format!("bash task `{id}` finished: {summary}");
if let Some((stdout, stderr)) = output {
if !stdout.is_empty() {