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

@ -129,9 +129,11 @@ pub(crate) async fn dispatch_shared(
) -> Option<hive_sh4re::Response> {
let broker = &coord.broker;
Some(match req {
hive_sh4re::Request::Send { to, body, in_reply_to } => {
handle_send(coord, agent, to, body, *in_reply_to)
}
hive_sh4re::Request::Send {
to,
body,
in_reply_to,
} => handle_send(coord, agent, to, body, *in_reply_to),
hive_sh4re::Request::Recv { wait_seconds, max } => {
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
match broker
@ -223,8 +225,8 @@ pub(crate) async fn dispatch_shared(
if let Err(message) = crate::limits::check_status_text(text) {
return Some(hive_sh4re::Response::Err { message });
}
let path = crate::coordinator::Coordinator::agent_notes_dir(agent)
.join("hyperhive-status");
let path =
crate::coordinator::Coordinator::agent_notes_dir(agent).join("hyperhive-status");
let result = if text.trim().is_empty() {
std::fs::remove_file(&path).or_else(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
@ -242,11 +244,9 @@ pub(crate) async fn dispatch_shared(
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
hive_sh4re::Response::Ok
}
Err(e) => {
hive_sh4re::Response::Err {
message: format!("set_status write failed: {e}"),
}
}
Err(e) => hive_sh4re::Response::Err {
message: format!("set_status write failed: {e}"),
},
}
}
hive_sh4re::Request::GetAgentMeta { name } => {
@ -294,7 +294,15 @@ pub(crate) async fn dispatch_shared(
message: format!("{e:#}"),
},
},
hive_sh4re::Request::GetHostJournal { unit, container, lines, priority, grep, since, until } => {
hive_sh4re::Request::GetHostJournal {
unit,
container,
lines,
priority,
grep,
since,
until,
} => {
dispatch_host_journal(agent, unit, container, lines, priority, grep, since, until).await
}
// Not a shared variant.
@ -331,7 +339,10 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
Err(message) => AgentResponse::Err { message },
}
}
AgentRequest::ReminderRollup { since_secs, agent: target } => {
AgentRequest::ReminderRollup {
since_secs,
agent: target,
} => {
let name = resolve_agent_state_target(agent, target.as_deref());
match name {
Ok(name) => match coord.broker.reminder_rollup_for(name, *since_secs) {
@ -547,10 +558,7 @@ pub(crate) fn store_remind(
) -> Result<(), String> {
let max = remind_max_pending();
if max > 0 {
let pending = coord
.broker
.count_pending_reminders_for(agent)
.unwrap_or(0);
let pending = coord.broker.count_pending_reminders_for(agent).unwrap_or(0);
if pending >= max {
return Err(format!(
"reminder rejected: agent `{agent}` already has {pending} pending \
@ -604,8 +612,9 @@ fn prepare_remind_storage(
};
let host_path = crate::reminder_scheduler::resolve_host_path(agent, &req_path)
.map_err(|reason| format!("auto-save path `{req_path}` rejected: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message)
.map_err(|reason| format!("auto-save of large reminder body to `{req_path}` failed: {reason}"))?;
crate::reminder_scheduler::write_payload(agent, &host_path, message).map_err(|reason| {
format!("auto-save of large reminder body to `{req_path}` failed: {reason}")
})?;
let hint = format!(
"[reminder body of {} bytes auto-saved to `{req_path}`; read with your filesystem tools]",
message.len()
@ -634,19 +643,26 @@ fn auto_reminder_path(agent: &str) -> String {
/// - `Some("<other>")` where other is not a child → requires the
/// `query_agent_state` capability; returns an error otherwise.
/// - `Some("*")` → always rejected (hive-wide scans are manager-only).
fn resolve_agent_state_target<'a>(caller: &'a str, target: Option<&'a str>) -> Result<&'a str, String> {
fn resolve_agent_state_target<'a>(
caller: &'a str,
target: Option<&'a str>,
) -> Result<&'a str, String> {
match target {
None => Ok(caller),
Some("*") => Err(
"hive-wide query (agent=\"*\") is not available on the agent socket; \
use the manager socket for swarm-wide scans".to_owned()
use the manager socket for swarm-wide scans"
.to_owned(),
),
Some(name) => {
if name == caller {
return Ok(caller);
}
// Direct children are visible to their parent without extra capability.
if crate::topology::children_of(caller).iter().any(|c| c == name) {
if crate::topology::children_of(caller)
.iter()
.any(|c| c == name)
{
return Ok(name);
}
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {