Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 152 additions & 13 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ use std::sync::Arc;

use anyhow::Result;
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
use buzz_core::tenant::{relay_url_authority, TenantContext};
use buzz_db::{Db, DbConfig};
use buzz_core::tenant::{normalize_host, relay_url_authority, TenantContext};
use buzz_db::{AddHostAliasResult, Db, DbConfig};
use buzz_pubsub::{EventTopic, PubSubManager};
use clap::{Parser, Subcommand};
use nostr::{EventBuilder, Keys, Kind, Tag};
Expand Down Expand Up @@ -93,6 +93,43 @@ enum Command {
#[arg(long)]
relay_key: Option<String>,
},
/// Manage this deployment's community (the one resolved from RELAY_URL).
Community {
#[command(subcommand)]
command: CommunityCommand,
},
}

#[derive(Subcommand)]
enum CommunityCommand {
/// Manage host aliases — additional hostnames that resolve to this
/// deployment's community without changing its canonical
/// `communities.host` (e.g. an in-cluster Service DNS name that has no
/// Host-header route to the community's external host).
Alias {
#[command(subcommand)]
command: AliasCommand,
},
}

#[derive(Subcommand)]
enum AliasCommand {
/// Add a host alias mapping to this deployment's community.
///
/// Fails if the host is already some community's primary host, or
/// already aliased to a different community.
Add {
/// Additional hostname that should resolve to this community, e.g.
/// `sprout-relay.sprout.svc.cluster.local:3000`.
host: String,
},
/// Remove a host alias from this deployment's community.
Remove {
/// Alias hostname to remove.
host: String,
},
/// List host aliases for this deployment's community.
List,
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -152,6 +189,24 @@ async fn run(cli: Cli) -> Result<i32> {
reconcile_channels(relay_key).await?;
Ok(0)
}
Command::Community {
command:
CommunityCommand::Alias {
command: AliasCommand::Add { host },
},
} => cmd_alias_add(host).await,
Command::Community {
command:
CommunityCommand::Alias {
command: AliasCommand::Remove { host },
},
} => cmd_alias_remove(host).await,
Command::Community {
command:
CommunityCommand::Alias {
command: AliasCommand::List,
},
} => cmd_alias_list().await,
}
}

Expand Down Expand Up @@ -286,6 +341,83 @@ async fn cmd_list_members() -> Result<i32> {
Ok(0)
}

async fn cmd_alias_add(host_arg: String) -> Result<i32> {
let host = normalize_host(&host_arg);
if host.is_empty() {
eprintln!("error: host must not be empty");
return Ok(1);
}

let db = connect_db().await?;
let tenant = resolve_admin_tenant(&db).await?;

match db
.add_community_host_alias(&host, tenant.community())
.await?
{
AddHostAliasResult::Added(alias) => {
println!(
"added alias {} -> community {}",
alias.host,
tenant.community()
);
Ok(0)
}
AddHostAliasResult::AlreadyAliased(existing) if existing == tenant.community() => {
println!("alias already exists: {host} (no change)");
Ok(0)
}
AddHostAliasResult::AlreadyAliased(other) => {
eprintln!("error: host '{host}' is already aliased to a different community ({other})");
Ok(2)
}
AddHostAliasResult::PrimaryHostCollision => {
eprintln!("error: host '{host}' is already a community's primary host");
Ok(3)
}
}
}

async fn cmd_alias_remove(host_arg: String) -> Result<i32> {
let host = normalize_host(&host_arg);
let db = connect_db().await?;
let tenant = resolve_admin_tenant(&db).await?;

if db
.remove_community_host_alias(&host, tenant.community())
.await?
{
println!("removed alias {host}");
Ok(0)
} else {
eprintln!("error: no alias '{host}' found for this community");
Ok(2)
}
}

async fn cmd_alias_list() -> Result<i32> {
let db = connect_db().await?;
let tenant = resolve_admin_tenant(&db).await?;
let aliases = db.list_community_host_aliases(tenant.community()).await?;

if aliases.is_empty() {
println!("(no host aliases)");
return Ok(0);
}

println!("{:<48} created_at", "host");
println!("{}", "-".repeat(70));
for alias in &aliases {
println!(
"{:<48} {}",
alias.host,
alias.created_at.format("%Y-%m-%dT%H:%M:%SZ")
);
}

Ok(0)
}

/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
fn validate_role(role: &str) -> std::result::Result<(), String> {
match role {
Expand Down Expand Up @@ -419,7 +551,7 @@ async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {

async fn connect_db() -> Result<Db> {
let db_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1
let db = Db::new(&DbConfig {
database_url: db_url,
..DbConfig::default()
Expand All @@ -432,10 +564,14 @@ async fn connect_db() -> Result<Db> {
///
/// `buzz-admin` runs inside the relay container (`compose exec relay
/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same
/// single community against the durable `communities` host map. This is
/// deliberately NOT a default tenant: an unmapped host fails closed with an
/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is
/// single-community per invocation — there is no cross-community sweep.
/// single community the relay's own `HostResolver` would (`communities.host`,
/// falling back to `community_host_aliases`) — including when `RELAY_URL`
/// itself is only reachable as an alias, e.g. running `buzz-admin` from an
/// in-cluster host that has no primary `communities.host` row of its own.
/// This is deliberately NOT a default tenant: an unmapped host fails closed
/// with an error, mirroring the relay's own `bind_community` row-zero seam.
/// The CLI is single-community per invocation — there is no cross-community
/// sweep.
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
Expand All @@ -447,14 +583,17 @@ async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
// — and `wss://relay.example:8443` would resolve `relay.example`. Sharing
// the helper keeps buzz-admin byte-identical to the community startup seeds.
let host = relay_url_authority(&relay_url);
let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| {
anyhow::anyhow!(
"RELAY_URL host '{host}' is not mapped to a community.\n\
let record = db
.lookup_community_by_host_or_alias(&host)
.await?
.ok_or_else(|| {
anyhow::anyhow!(
"RELAY_URL host '{host}' is not mapped to a community.\n\
buzz-admin operates on the configured relay's community; ensure the \
relay has started and seeded its community (or set RELAY_URL to a \
mapped host)."
)
})?;
mapped host or alias)."
)
})?;
Ok(TenantContext::resolved(record.id, record.host))
}

Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-db/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ pub enum DbError {
#[error("invalid data: {0}")]
InvalidData(String),

/// The requested host is already reserved as a `community_host_aliases`
/// row for a different community, so it cannot become a primary
/// `communities.host` too.
#[error("host is already reserved as a community host alias: {0}")]
HostAliasCollision(String),

/// A stored timestamp value could not be interpreted.
#[error("invalid timestamp: {0}")]
InvalidTimestamp(i64),
Expand Down
Loading