Add provisioner
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE relays ADD COLUMN icon TEXT NOT NULL DEFAULT "";
|
||||
+25
-2
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
Reference in New Issue
Block a user