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

@ -268,12 +268,7 @@ impl Broker {
/// broker. Used by the scheduler to skip re-delivery of the same
/// scheduled prompt without blocking distinct schedules whose
/// bodies differ.
pub fn has_pending_with_body(
&self,
recipient: &str,
sender: &str,
body: &str,
) -> Result<bool> {
pub fn has_pending_with_body(&self, recipient: &str, sender: &str, body: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM messages
@ -391,7 +386,13 @@ impl Broker {
)?;
let rows: Vec<(i64, String, String, String, Option<i64>)> = stmt
.query_map(params![recipient, max_i], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?))
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
})?
.collect::<rusqlite::Result<_>>()?;
drop(stmt);
@ -673,7 +674,11 @@ impl Broker {
/// Reminder rollup stats for an agent over a time window. Returns
/// counts of scheduled, delivered, and pending reminders created
/// in the last `since_secs` seconds (0 = all reminders).
pub fn reminder_rollup_for(&self, agent: &str, since_secs: u64) -> Result<hive_sh4re::ReminderStats> {
pub fn reminder_rollup_for(
&self,
agent: &str,
since_secs: u64,
) -> Result<hive_sh4re::ReminderStats> {
let conn = self.conn.lock().unwrap();
let cutoff_time = if since_secs > 0 {
let now = std::time::SystemTime::now()
@ -740,9 +745,7 @@ impl Broker {
|| canceller == hive_sh4re::OPERATOR_RECIPIENT
|| canceller == hive_sh4re::MANAGER_AGENT;
if !authorised {
anyhow::bail!(
"reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')"
);
anyhow::bail!("reminder {id}: '{canceller}' not allowed to cancel (owner = '{owner}')");
}
let n = conn.execute(
"DELETE FROM reminders WHERE id = ?1 AND sent_at IS NULL",
@ -862,7 +865,9 @@ impl Broker {
}
drop(conn);
// Emit per-row Sent events (only for rows that succeeded).
for (((id, agent, body), result), msg_id) in items.iter().zip(results.iter()).zip(msg_ids.iter()) {
for (((id, agent, body), result), msg_id) in
items.iter().zip(results.iter()).zip(msg_ids.iter())
{
if result.is_ok() {
let _ = self.events.send(MessageEvent::Sent {
id: *msg_id,
@ -1029,10 +1034,7 @@ mod tests {
assert_eq!(broker.requeue_inflight("b").unwrap(), 1);
let d2 = pop_one(broker, "b").expect("popped again");
assert_eq!(d2.message.body, "hi");
assert!(
d2.redelivered,
"second pop should be tagged redelivered"
);
assert!(d2.redelivered, "second pop should be tagged redelivered");
assert_eq!(broker.ack_turn("b").unwrap(), 1);
}
@ -1275,4 +1277,3 @@ mod tests {
assert!(pop_one(broker, "bob").is_none());
}
}