Add provisioner

This commit is contained in:
Jon Staab
2026-02-25 13:27:45 -08:00
parent 42abde9dcd
commit 051747e5c3
10 changed files with 250 additions and 10 deletions
+3
View File
@@ -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"
+3 -1
View File
@@ -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 doesnt 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
@@ -0,0 +1 @@
ALTER TABLE relays ADD COLUMN icon TEXT NOT NULL DEFAULT "";
+25 -2
View File
@@ -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<Vec<String>>,
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");
}
});
}
+1 -1
View File
@@ -11,5 +11,5 @@ pub fn verify_nip98(auth_header: &str, url: &str, method: &str) -> Result<Public
let method = HttpMethod::from_str(&method.to_uppercase()).map_err(|e| anyhow!(e))?;
let now = Timestamp::now();
nip98::verify_auth_header(auth_header, &url, method, now).map_err(|e| anyhow!(e))
nip98::verify_auth_header(auth_header, &url, method, now, None).map_err(|e| anyhow!(e))
}
+11
View File
@@ -6,6 +6,9 @@ pub struct Config {
pub host: String,
pub port: u16,
pub admin_pubkeys: Vec<String>,
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::<Vec<_>>();
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,
}
}
}
+8
View File
@@ -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()
+3
View File
@@ -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,
+178
View File
@@ -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<Self> {
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<String> {
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<String>,
}
#[derive(Debug, Serialize)]
struct ZooidBlossom {
enabled: bool,
}
#[derive(Debug, Serialize)]
struct ZooidRoles {
member: ZooidRole,
}
#[derive(Debug, Serialize)]
struct ZooidRole {
pubkeys: Vec<String>,
can_invite: bool,
can_manage: bool,
}
fn generate_secret_hex() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
+17 -6
View File
@@ -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<Option<Relay>> {
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<Vec<Relay>> {
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<Vec<Relay>> {
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?;