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

@ -472,9 +472,15 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
let s = v.to_string_lossy().to_ascii_lowercase();
matches!(s.as_str(), "1" | "true" | "yes")
}),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL").ok().filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME").ok().filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME").ok().filter(|s| !s.is_empty()),
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
.ok()
.filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|s| !s.is_empty()),
swarm_name: std::env::var("HYPERHIVE_SWARM_NAME")
.ok()
.filter(|s| !s.is_empty()),
peer_hives: parse_peer_hives(),
})
}
@ -1751,9 +1757,7 @@ async fn get_build_log_full(
) -> Response {
match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => axum::Json(log).into_response(),
Ok(None) => {
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
}
}
@ -1870,10 +1874,7 @@ async fn get_build_log_stream(
/// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log.
async fn get_build_log_raw(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
async fn get_build_log_raw(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => {
let mut text = log.stdout;
@ -1897,9 +1898,7 @@ async fn get_build_log_raw(
)
.into_response()
}
Ok(None) => {
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
}
}
@ -2522,7 +2521,10 @@ async fn get_tool_groups(State(_state): State<AppState>) -> axum::Json<ToolGroup
.map(|g| g.as_str())
.collect();
let assignments = crate::tool_groups::read();
axum::Json(ToolGroupsSnapshot { groups, assignments })
axum::Json(ToolGroupsSnapshot {
groups,
assignments,
})
}
#[derive(Deserialize)]