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

@ -16,8 +16,8 @@ use matrix_sdk::{
OwnedEventId, OwnedRoomId, OwnedUserId, RoomOrAliasId,
api::client::receipt::create_receipt::v3::ReceiptType,
events::{
receipt::ReceiptThread,
reaction::ReactionEventContent,
receipt::ReceiptThread,
relation::Annotation,
room::message::{MessageType, RoomMessageEventContent},
},
@ -64,17 +64,20 @@ async fn resolve_room(
client: &Client,
reference: &str,
) -> Result<matrix_sdk::Room, DaemonResponse> {
let parsed: &RoomOrAliasId = reference.try_into().map_err(|e| {
DaemonResponse::error(format!("invalid room reference {reference}: {e}"))
})?;
let parsed: &RoomOrAliasId = reference
.try_into()
.map_err(|e| DaemonResponse::error(format!("invalid room reference {reference}: {e}")))?;
let room_id: OwnedRoomId = if parsed.is_room_id() {
OwnedRoomId::try_from(reference)
.map_err(|e| DaemonResponse::error(format!("invalid room_id: {e}")))?
} else {
client
.resolve_room_alias(parsed.as_str().try_into().map_err(|e| {
DaemonResponse::error(format!("invalid alias: {e}"))
})?)
.resolve_room_alias(
parsed
.as_str()
.try_into()
.map_err(|e| DaemonResponse::error(format!("invalid alias: {e}")))?,
)
.await
.map(|r| r.room_id)
.map_err(|e| DaemonResponse::error(format!("resolve_room_alias {reference}: {e}")))?
@ -93,16 +96,14 @@ async fn resolve_room(
fn extract_body(event: &matrix_sdk::ruma::events::AnyTimelineEvent) -> String {
use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent};
match event {
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => {
ev.as_original().map_or_else(String::new, |orig| {
match &orig.content.msgtype {
MessageType::Text(t) => t.body.clone(),
MessageType::Notice(n) => n.body.clone(),
MessageType::Emote(e) => format!("* {}", e.body),
_ => String::new(),
}
})
}
AnyTimelineEvent::MessageLike(AnyMessageLikeEvent::RoomMessage(ev)) => ev
.as_original()
.map_or_else(String::new, |orig| match &orig.content.msgtype {
MessageType::Text(t) => t.body.clone(),
MessageType::Notice(n) => n.body.clone(),
MessageType::Emote(e) => format!("* {}", e.body),
_ => String::new(),
}),
_ => String::new(),
}
}
@ -128,14 +129,13 @@ pub async fn send_dm(client: &Client, user_id: &str, body: &str) -> DaemonRespon
Err(e) => return DaemonResponse::error(format!("invalid user_id {user_id}: {e}")),
};
// Find existing DM or create one.
let room = client
.joined_rooms()
.into_iter()
.find(|r| {
// is_direct() is async; check direct_targets() instead which
// reads from cached state.
r.direct_targets().iter().any(|t| t.as_str() == uid.as_str())
});
let room = client.joined_rooms().into_iter().find(|r| {
// is_direct() is async; check direct_targets() instead which
// reads from cached state.
r.direct_targets()
.iter()
.any(|t| t.as_str() == uid.as_str())
});
let room = match room {
Some(r) => r,
None => match client.create_dm(&uid).await {
@ -268,16 +268,18 @@ pub async fn list_room_members(client: &Client, room_ref: &str) -> DaemonRespons
}
pub async fn read_room(client: &Client, room_ref: &str, limit: Option<usize>) -> DaemonResponse {
use matrix_sdk::ruma::api::client::message::get_message_events;
use matrix_sdk::ruma::api::Direction;
use matrix_sdk::ruma::api::client::message::get_message_events;
let room = match resolve_room(client, room_ref).await {
Ok(r) => r,
Err(e) => return e,
};
let limit = limit.unwrap_or(50).min(200);
let mut req = get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64).unwrap_or(matrix_sdk::ruma::UInt::from(50u32));
let mut req =
get_message_events::v3::Request::new(room.room_id().to_owned(), Direction::Backward);
req.limit = matrix_sdk::ruma::UInt::try_from(limit as u64)
.unwrap_or(matrix_sdk::ruma::UInt::from(50u32));
let resp = match client.send(req).await {
Ok(r) => r,
Err(e) => return DaemonResponse::error(format!("get_message_events: {e}")),