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

@ -274,14 +274,8 @@ impl BuildLogs {
match row {
None => Ok(None),
Some((stdout, stderr, finished_at, status)) => {
let stdout_append = stdout
.get(stdout_cursor..)
.unwrap_or("")
.to_string();
let stderr_append = stderr
.get(stderr_cursor..)
.unwrap_or("")
.to_string();
let stdout_append = stdout.get(stdout_cursor..).unwrap_or("").to_string();
let stderr_append = stderr.get(stderr_cursor..).unwrap_or("").to_string();
Ok(Some(BuildLogProgress {
stdout_append,
stderr_append,
@ -296,11 +290,7 @@ impl BuildLogs {
/// Headers only (no stdout/stderr blobs) — keeps `/api/state`
/// payloads light. Limit is hard-clamped to 50 to bound worst-case
/// payload regardless of caller input.
pub fn list_recent_for_agent(
&self,
agent: &str,
limit: usize,
) -> Result<Vec<BuildLogHeader>> {
pub fn list_recent_for_agent(&self, agent: &str, limit: usize) -> Result<Vec<BuildLogHeader>> {
let limit = limit.min(50);
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
@ -310,7 +300,10 @@ impl BuildLogs {
ORDER BY started_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![agent, i64::try_from(limit).unwrap_or(50)], row_to_header)?;
let rows = stmt.query_map(
params![agent, i64::try_from(limit).unwrap_or(50)],
row_to_header,
)?;
let mut out = Vec::new();
for r in rows {
out.push(r?);
@ -470,7 +463,9 @@ mod tests {
#[test]
fn start_appends_finish_flow() {
let (_d, db) = tmpdb();
let id = db.start("alice", "prebuild", "nix build foo").expect("start");
let id = db
.start("alice", "prebuild", "nix build foo")
.expect("start");
db.append_stdout(id, "building '/nix/store/abc.drv'");
db.append_stderr(id, "error: line 12");
db.append_stderr(id, " at /nix/store/.../module.nix:5");
@ -509,8 +504,7 @@ mod tests {
// but list_recent already orders by `started_at DESC` then
// sqlite's natural insertion-order tiebreak. We rely only on
// both IDs being present + correct count + agent isolation.
let ids: std::collections::HashSet<i64> =
alice_rows.iter().map(|h| h.id).collect();
let ids: std::collections::HashSet<i64> = alice_rows.iter().map(|h| h.id).collect();
assert!(ids.contains(&id_a1));
assert!(ids.contains(&id_a2));