From 051747e5c3f84ce22ad411a6e1c0891dca5d9305 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Wed, 25 Feb 2026 13:27:45 -0800 Subject: [PATCH] Add provisioner --- backend/Cargo.toml | 3 + backend/README.md | 4 +- backend/migrations/0002_add_relay_icon.sql | 1 + backend/src/api.rs | 27 +++- backend/src/auth.rs | 2 +- backend/src/config.rs | 11 ++ backend/src/main.rs | 8 + backend/src/models.rs | 3 + backend/src/provisioning.rs | 178 +++++++++++++++++++++ backend/src/repo.rs | 23 ++- 10 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/0002_add_relay_icon.sql create mode 100644 backend/src/provisioning.rs diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f8b8bea..ef4f81d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -16,3 +16,6 @@ nostr-sdk = "0.39" uuid = { version = "1.7", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } tower-http = { version = "0.5", features = ["cors"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } +rand = "0.8" +hex = "0.4" diff --git a/backend/README.md b/backend/README.md index 55a23ca..963355c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -32,6 +32,9 @@ Environment variables: | `DATABASE_URL` | SQLite database URL | `sqlite://data/hosting.db` | | `HOST` | Bind host | `127.0.0.1` | | `PORT` | Bind port | `3000` | +| `ZOOID_API_URL` | Zooid API base URL | `http://127.0.0.1:8032` | +| `PLATFORM_SECRET` | Platform Nostr secret key for NIP-98 auth | _required_ | +| `RELAY_DOMAIN` | Relay base domain for subdomains | `spaces.coracle.social` | The database directory is created automatically if it doesn’t exist. @@ -92,5 +95,4 @@ Admin routes (all require NIP-98 auth; pubkey must be in `HOSTING_ADMIN_PUBKEYS` ## Next Steps -- Add relay provisioning worker (zooid API calls) - Add invoice generation and billing jobs diff --git a/backend/migrations/0002_add_relay_icon.sql b/backend/migrations/0002_add_relay_icon.sql new file mode 100644 index 0000000..33a0fbc --- /dev/null +++ b/backend/migrations/0002_add_relay_icon.sql @@ -0,0 +1 @@ +ALTER TABLE relays ADD COLUMN icon TEXT NOT NULL DEFAULT ""; diff --git a/backend/src/api.rs b/backend/src/api.rs index 0662c1e..03cc94b 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -13,12 +13,14 @@ use uuid::Uuid; use crate::auth::verify_nip98; use crate::models::{NewRelay, NewTenant, Relay, UpdateRelay}; +use crate::provisioning::Provisioner; use crate::repo::Repo; #[derive(Clone)] pub struct AppState { pub repo: Repo, pub admin_pubkeys: Arc>, + pub provisioner: Provisioner, } pub fn router(state: AppState) -> Router { @@ -145,6 +147,7 @@ async fn list_tenant_relays( struct CreateRelayRequest { name: String, subdomain: String, + icon: String, description: String, plan: String, } @@ -177,6 +180,7 @@ async fn create_tenant_relay( name: payload.name, subdomain: payload.subdomain.clone(), schema: payload.subdomain.replace('-', "_"), + icon: payload.icon, description: payload.description, plan: payload.plan, status: "pending".to_string(), @@ -186,7 +190,7 @@ async fn create_tenant_relay( return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: "failed to create relay".into() })).into_response(); } - spawn_provisioning_worker(relay.clone()); + spawn_provisioning_worker(state.repo.clone(), state.provisioner.clone(), relay.clone()); (StatusCode::CREATED, Json(relay)).into_response() } @@ -215,6 +219,7 @@ async fn get_tenant_relay( struct UpdateRelayRequest { name: String, subdomain: String, + icon: String, description: String, plan: String, } @@ -247,6 +252,7 @@ async fn update_tenant_relay( name: payload.name, subdomain: payload.subdomain.clone(), schema: payload.subdomain.replace('-', "_"), + icon: payload.icon, description: payload.description, plan: payload.plan, status: existing.status, @@ -286,6 +292,7 @@ async fn deactivate_tenant_relay( name: existing.name, subdomain: existing.subdomain.clone(), schema: existing.subdomain.replace('-', "_"), + icon: existing.icon, description: existing.description, plan: existing.plan, status: "deactivated".to_string(), @@ -489,6 +496,7 @@ async fn admin_update_relay( name: payload.name, subdomain: payload.subdomain.clone(), schema: payload.subdomain.replace('-', "_"), + icon: payload.icon, description: payload.description, plan: payload.plan, status: existing.status, @@ -528,6 +536,7 @@ async fn admin_deactivate_relay( name: existing.name, subdomain: existing.subdomain.clone(), schema: existing.subdomain.replace('-', "_"), + icon: existing.icon, description: existing.description, plan: existing.plan, status: "deactivated".to_string(), @@ -540,8 +549,22 @@ async fn admin_deactivate_relay( (StatusCode::OK, Json(updated)).into_response() } -fn spawn_provisioning_worker(relay: NewRelay) { +fn spawn_provisioning_worker(repo: Repo, provisioner: Provisioner, relay: NewRelay) { tokio::spawn(async move { tracing::info!(relay_id = relay.id, "provisioning worker started"); + if let Err(err) = provisioner.provision_relay(&relay).await { + tracing::error!(relay_id = relay.id, error = %err, "provisioning failed"); + if let Err(err) = repo + .update_relay_status(&relay.id, "provisioning_failed") + .await + { + tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status"); + } + return; + } + + if let Err(err) = repo.update_relay_status(&relay.id, "active").await { + tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status"); + } }); } diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 34fe303..72b3f38 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -11,5 +11,5 @@ pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result, + pub zooid_api_url: String, + pub platform_secret: String, + pub relay_domain: String, } impl Config { @@ -23,12 +26,20 @@ impl Config { .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()) .collect::>(); + let zooid_api_url = + env::var("ZOOID_API_URL").unwrap_or_else(|_| "http://127.0.0.1:8032".to_string()); + let platform_secret = env::var("PLATFORM_SECRET").unwrap_or_default(); + let relay_domain = + env::var("RELAY_DOMAIN").unwrap_or_else(|_| "spaces.coracle.social".to_string()); Self { database_url, host, port, admin_pubkeys, + zooid_api_url, + platform_secret, + relay_domain, } } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 74d92fb..22fa38c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,6 +3,7 @@ mod auth; mod config; mod db; mod models; +mod provisioning; mod repo; use std::net::SocketAddr; @@ -15,6 +16,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use crate::config::Config; use crate::db::init_pool; +use crate::provisioning::Provisioner; use crate::repo::Repo; #[tokio::main] @@ -29,9 +31,15 @@ async fn main() -> Result<()> { let pool = init_pool(&config.database_url).await?; let repo = Repo::new(pool); + let provisioner = Provisioner::new( + config.zooid_api_url.clone(), + config.relay_domain.clone(), + config.platform_secret.clone(), + )?; let state = api::AppState { repo, admin_pubkeys: std::sync::Arc::new(config.admin_pubkeys.clone()), + provisioner, }; let app = Router::new() diff --git a/backend/src/models.rs b/backend/src/models.rs index 5b2323a..18f885b 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -19,6 +19,7 @@ pub struct Relay { pub name: String, pub subdomain: String, pub schema: String, + pub icon: String, pub description: String, pub plan: String, pub status: String, @@ -31,6 +32,7 @@ pub struct NewRelay { pub name: String, pub subdomain: String, pub schema: String, + pub icon: String, pub description: String, pub plan: String, pub status: String, @@ -42,6 +44,7 @@ pub struct UpdateRelay { pub name: String, pub subdomain: String, pub schema: String, + pub icon: String, pub description: String, pub plan: String, pub status: String, diff --git a/backend/src/provisioning.rs b/backend/src/provisioning.rs new file mode 100644 index 0000000..93f4114 --- /dev/null +++ b/backend/src/provisioning.rs @@ -0,0 +1,178 @@ +use anyhow::{anyhow, Result}; +use rand::rngs::OsRng; +use rand::RngCore; +use reqwest::Client; +use serde::Serialize; + +use nostr_sdk::nostr::nips::nip98::{HttpData, HttpMethod}; +use nostr_sdk::nostr::types::url::Url; +use nostr_sdk::nostr::Keys; + +use crate::models::NewRelay; + +#[derive(Clone)] +pub struct Provisioner { + base_url: String, + relay_domain: String, + admin_keys: Keys, + client: Client, +} + +impl Provisioner { + pub fn new(base_url: String, relay_domain: String, admin_secret: String) -> Result { + if admin_secret.trim().is_empty() { + return Err(anyhow!("PLATFORM_SECRET is required")); + } + + let admin_keys = Keys::parse(admin_secret)?; + let client = Client::new(); + + Ok(Self { + base_url, + relay_domain, + admin_keys, + client, + }) + } + + pub async fn provision_relay(&self, relay: &NewRelay) -> Result<()> { + let host = format!("{}.{}", relay.subdomain, self.relay_domain); + let secret = generate_secret_hex(); + + let payload = ZooidConfig::new(relay, host, secret); + let url = format!("{}/relay/{}", self.base_url.trim_end_matches('/'), relay.id); + let auth = self.build_auth_header(&url, HttpMethod::POST).await?; + + let res = self + .client + .post(&url) + .header(reqwest::header::AUTHORIZATION, auth) + .json(&payload) + .send() + .await?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + return Err(anyhow!("zooid provisioning failed: {} {}", status, body)); + } + + Ok(()) + } + + async fn build_auth_header(&self, url: &str, method: HttpMethod) -> Result { + let url = Url::parse(url)?; + let data = HttpData::new(url, method); + let header = data.to_authorization(&self.admin_keys).await?; + Ok(header) + } +} + +#[derive(Debug, Serialize)] +struct ZooidConfig { + host: String, + schema: String, + secret: String, + info: ZooidInfo, + policy: ZooidPolicy, + groups: ZooidGroups, + push: ZooidPush, + management: ZooidManagement, + blossom: ZooidBlossom, + roles: ZooidRoles, +} + +impl ZooidConfig { + fn new(relay: &NewRelay, host: String, secret: String) -> Self { + let blossom_enabled = relay.plan != "free"; + + Self { + host, + schema: relay.schema.clone(), + secret, + info: ZooidInfo { + name: relay.name.clone(), + icon: relay.icon.clone(), + pubkey: relay.tenant.clone(), + description: relay.description.clone(), + }, + policy: ZooidPolicy { + public_join: false, + strip_signatures: false, + }, + groups: ZooidGroups { + enabled: true, + auto_join: true, + }, + push: ZooidPush { enabled: true }, + management: ZooidManagement { + enabled: true, + methods: Vec::new(), + }, + blossom: ZooidBlossom { + enabled: blossom_enabled, + }, + roles: ZooidRoles { + member: ZooidRole { + pubkeys: Vec::new(), + can_invite: true, + can_manage: false, + }, + }, + } + } +} + +#[derive(Debug, Serialize)] +struct ZooidInfo { + name: String, + icon: String, + pubkey: String, + description: String, +} + +#[derive(Debug, Serialize)] +struct ZooidPolicy { + public_join: bool, + strip_signatures: bool, +} + +#[derive(Debug, Serialize)] +struct ZooidGroups { + enabled: bool, + auto_join: bool, +} + +#[derive(Debug, Serialize)] +struct ZooidPush { + enabled: bool, +} + +#[derive(Debug, Serialize)] +struct ZooidManagement { + enabled: bool, + methods: Vec, +} + +#[derive(Debug, Serialize)] +struct ZooidBlossom { + enabled: bool, +} + +#[derive(Debug, Serialize)] +struct ZooidRoles { + member: ZooidRole, +} + +#[derive(Debug, Serialize)] +struct ZooidRole { + pubkeys: Vec, + can_invite: bool, + can_manage: bool, +} + +fn generate_secret_hex() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} diff --git a/backend/src/repo.rs b/backend/src/repo.rs index 0ba12c9..b76fb12 100644 --- a/backend/src/repo.rs +++ b/backend/src/repo.rs @@ -67,14 +67,15 @@ impl Repo { pub async fn create_relay(&self, relay: &NewRelay) -> Result<()> { sqlx::query( - "INSERT INTO relays (id, tenant, name, subdomain, schema, description, plan, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&relay.id) .bind(&relay.tenant) .bind(&relay.name) .bind(&relay.subdomain) .bind(&relay.schema) + .bind(&relay.icon) .bind(&relay.description) .bind(&relay.plan) .bind(&relay.status) @@ -85,12 +86,13 @@ impl Repo { pub async fn update_relay(&self, relay: &UpdateRelay) -> Result<()> { sqlx::query( - "UPDATE relays SET name = ?, subdomain = ?, schema = ?, description = ?, plan = ?, status = ? + "UPDATE relays SET name = ?, subdomain = ?, schema = ?, icon = ?, description = ?, plan = ?, status = ? WHERE id = ?", ) .bind(&relay.name) .bind(&relay.subdomain) .bind(&relay.schema) + .bind(&relay.icon) .bind(&relay.description) .bind(&relay.plan) .bind(&relay.status) @@ -100,9 +102,18 @@ impl Repo { Ok(()) } + pub async fn update_relay_status(&self, id: &str, status: &str) -> Result<()> { + sqlx::query("UPDATE relays SET status = ? WHERE id = ?") + .bind(status) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + pub async fn get_relay(&self, id: &str) -> Result> { let relay = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE id = ?", + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) @@ -112,7 +123,7 @@ impl Repo { pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result> { let relays = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays WHERE tenant = ? ORDER BY name", + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE tenant = ? ORDER BY name", ) .bind(tenant) .fetch_all(&self.pool) @@ -122,7 +133,7 @@ impl Repo { pub async fn list_relays(&self) -> Result> { let relays = sqlx::query_as::<_, Relay>( - "SELECT id, tenant, name, subdomain, schema, description, plan, status FROM relays ORDER BY name", + "SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays ORDER BY name", ) .fetch_all(&self.pool) .await?;