forked from coracle/caravel
Add custom domain support
This commit is contained in:
@@ -58,6 +58,67 @@ To build the image yourself instead of pulling it:
|
|||||||
docker build -t caravel .
|
docker build -t caravel .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Zooid and TLS
|
||||||
|
|
||||||
|
Zooid (the relay engine) should run on its own server with [Caddy](https://caddyserver.com/) as the TLS-terminating reverse proxy. Keeping Zooid separate lets it scale independently and makes custom relay domains work without touching the Caravel host.
|
||||||
|
|
||||||
|
**Why Caddy?** Caravel supports tenant-facing custom domains, which require per-domain TLS certificates that are provisioned automatically. Caddy's [on-demand TLS](https://caddyserver.com/docs/automatic-https#on-demand-tls) handles this: it calls a Caravel endpoint before issuing each certificate, so only known domains get one.
|
||||||
|
|
||||||
|
#### Zooid server setup
|
||||||
|
|
||||||
|
On the Zooid server, create a `Caddyfile`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
on_demand_tls {
|
||||||
|
ask http://<caravel-host>:2892/domains/check
|
||||||
|
interval 2m
|
||||||
|
burst 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:443 {
|
||||||
|
tls {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:3334
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<caravel-host>` with the hostname or IP of the Caravel server. The `/domains/check` endpoint returns `200` for any subdomain of `RELAY_DOMAIN` and for any tenant custom domain that has been verified, and `404` otherwise — Caddy will only obtain a certificate if it gets a `200`.
|
||||||
|
|
||||||
|
Run Caddy and Zooid together, for example with Docker Compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
image: caddy:2
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
zooid:
|
||||||
|
image: gitea.coracle.social/coracle/zooid
|
||||||
|
environment:
|
||||||
|
API_HOST: api.zooid.example.com
|
||||||
|
API_WHITELIST: <hex-pubkey-matching-ROBOT_SECRET>
|
||||||
|
volumes:
|
||||||
|
- zooid_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
zooid_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
Point your wildcard DNS record (`*.relay_domain`) at this server's IP. Custom domains are pointed there by tenants via a CNAME to their relay's canonical subdomain; Caravel verifies the CNAME in the background and notifies Zooid once confirmed.
|
||||||
|
|
||||||
|
Set `ZOOID_API_URL` in Caravel's environment to the same value as zooid's `API_HOST` value, prefixed with the protocol, e.g. `https://api.zooid.example.com`.
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE relay ADD COLUMN custom_domain TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE relay ADD COLUMN custom_domain_verified INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -31,6 +31,7 @@ use crate::env;
|
|||||||
use crate::models::{Relay, Tenant};
|
use crate::models::{Relay, Tenant};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
use crate::robot::Robot;
|
use crate::robot::Robot;
|
||||||
|
use crate::routes::domains::check_domain;
|
||||||
use crate::routes::identity::get_identity;
|
use crate::routes::identity::get_identity;
|
||||||
use crate::routes::invoices::{
|
use crate::routes::invoices::{
|
||||||
ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices,
|
ensure_invoice_bolt11, ensure_invoice_checkout, get_invoice, list_invoice_items, list_invoices,
|
||||||
@@ -91,6 +92,7 @@ impl Api {
|
|||||||
.route("/relays/:id/activity", get(list_relay_activity))
|
.route("/relays/:id/activity", get(list_relay_activity))
|
||||||
.route("/relays/:id/deactivate", post(deactivate_relay))
|
.route("/relays/:id/deactivate", post(deactivate_relay))
|
||||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||||
|
.route("/domains/check", get(check_domain))
|
||||||
.route("/invoices", get(list_invoices))
|
.route("/invoices", get(list_invoices))
|
||||||
.route("/invoices/:id", get(get_invoice))
|
.route("/invoices/:id", get(get_invoice))
|
||||||
.route("/invoices/:id/reconcile", post(reconcile_invoice))
|
.route("/invoices/:id/reconcile", post(reconcile_invoice))
|
||||||
|
|||||||
+29
-3
@@ -154,8 +154,8 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
info_name, info_icon, info_description,
|
info_name, info_icon, info_description,
|
||||||
policy_public_join, policy_strip_signatures,
|
policy_public_join, policy_strip_signatures,
|
||||||
groups_enabled, management_enabled, blossom_enabled,
|
groups_enabled, management_enabled, blossom_enabled,
|
||||||
livekit_enabled, push_enabled, created_at
|
livekit_enabled, push_enabled, custom_domain, created_at
|
||||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&relay.id)
|
.bind(&relay.id)
|
||||||
.bind(&relay.tenant_pubkey)
|
.bind(&relay.tenant_pubkey)
|
||||||
@@ -172,6 +172,7 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
.bind(relay.blossom_enabled)
|
.bind(relay.blossom_enabled)
|
||||||
.bind(relay.livekit_enabled)
|
.bind(relay.livekit_enabled)
|
||||||
.bind(relay.push_enabled)
|
.bind(relay.push_enabled)
|
||||||
|
.bind(&relay.custom_domain)
|
||||||
.bind(created_at)
|
.bind(created_at)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -188,13 +189,20 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
|||||||
|
|
||||||
pub async fn update_relay(relay: &Relay) -> Result<()> {
|
pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
let activity = with_tx(async |tx| {
|
||||||
|
// Reset custom_domain_verified only when the domain actually changes. The
|
||||||
|
// CASE compares the row's existing custom_domain (SQLite evaluates SET
|
||||||
|
// expressions against the original row) against the incoming one, so an
|
||||||
|
// update that round-trips the same domain — e.g. a feature toggle — leaves
|
||||||
|
// a verification set by the background poller untouched.
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE relay
|
"UPDATE relay
|
||||||
SET tenant_pubkey = ?, subdomain = ?, plan_id = ?, status = ?, sync_error = ?, synced = 0,
|
SET tenant_pubkey = ?, subdomain = ?, plan_id = ?, status = ?, sync_error = ?, synced = 0,
|
||||||
info_name = ?, info_icon = ?, info_description = ?,
|
info_name = ?, info_icon = ?, info_description = ?,
|
||||||
policy_public_join = ?, policy_strip_signatures = ?,
|
policy_public_join = ?, policy_strip_signatures = ?,
|
||||||
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
groups_enabled = ?, management_enabled = ?, blossom_enabled = ?,
|
||||||
livekit_enabled = ?, push_enabled = ?
|
livekit_enabled = ?, push_enabled = ?,
|
||||||
|
custom_domain = ?,
|
||||||
|
custom_domain_verified = CASE WHEN custom_domain = ? THEN custom_domain_verified ELSE 0 END
|
||||||
WHERE id = ?",
|
WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&relay.tenant_pubkey)
|
.bind(&relay.tenant_pubkey)
|
||||||
@@ -212,6 +220,8 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
|||||||
.bind(relay.blossom_enabled)
|
.bind(relay.blossom_enabled)
|
||||||
.bind(relay.livekit_enabled)
|
.bind(relay.livekit_enabled)
|
||||||
.bind(relay.push_enabled)
|
.bind(relay.push_enabled)
|
||||||
|
.bind(&relay.custom_domain)
|
||||||
|
.bind(&relay.custom_domain)
|
||||||
.bind(&relay.id)
|
.bind(&relay.id)
|
||||||
.execute(&mut **tx)
|
.execute(&mut **tx)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -259,6 +269,22 @@ pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn verify_relay_custom_domain(relay_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE relay SET custom_domain_verified = 1, synced = 0 WHERE id = ?")
|
||||||
|
.bind(relay_id)
|
||||||
|
.execute(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unverify_relay_custom_domain(relay_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE relay SET custom_domain_verified = 0, synced = 0 WHERE id = ?")
|
||||||
|
.bind(relay_id)
|
||||||
|
.execute(pool())
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
|
pub async fn complete_relay_sync(relay: &Relay) -> Result<()> {
|
||||||
let activity = with_tx(async |tx| {
|
let activity = with_tx(async |tx| {
|
||||||
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
sqlx::query("UPDATE relay SET synced = 1, sync_error = '' WHERE id = ?")
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
pub static CUSTOM_DOMAIN_RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$").unwrap());
|
||||||
|
|
||||||
|
/// Returns true if `domain` either has a CNAME pointing to `expected` or
|
||||||
|
/// resolves to the same IPv4 addresses. The A-record fallback accepts
|
||||||
|
/// Cloudflare CNAME flattening and ALIAS/ANAME records, which external
|
||||||
|
/// resolvers see as A records rather than CNAMEs.
|
||||||
|
pub async fn domain_points_to(domain: &str, expected: &str) -> Result<bool> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let cnames = doh_query(&client, domain, "CNAME").await?;
|
||||||
|
let expected_norm = expected.trim_end_matches('.');
|
||||||
|
for r in &cnames {
|
||||||
|
if r.record_type == 5
|
||||||
|
&& r.data
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.eq_ignore_ascii_case(expected_norm)
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let domain_ips = doh_query(&client, domain, "A").await?;
|
||||||
|
let expected_ips = doh_query(&client, expected, "A").await?;
|
||||||
|
|
||||||
|
let domain_set: Vec<&str> = domain_ips
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.record_type == 1)
|
||||||
|
.map(|r| r.data.as_str())
|
||||||
|
.collect();
|
||||||
|
let expected_set: Vec<&str> = expected_ips
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.record_type == 1)
|
||||||
|
.map(|r| r.data.as_str())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if domain_set.is_empty() || expected_set.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(domain_set.iter().any(|ip| expected_set.contains(ip)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DohResponse {
|
||||||
|
#[serde(rename = "Answer")]
|
||||||
|
answer: Option<Vec<DohRecord>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct DohRecord {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
record_type: u16,
|
||||||
|
data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
|
||||||
|
|
||||||
|
async fn doh_query(client: &reqwest::Client, name: &str, qtype: &str) -> Result<Vec<DohRecord>> {
|
||||||
|
let resp: DohResponse = client
|
||||||
|
.get(DOH_URL)
|
||||||
|
.query(&[("name", name), ("type", qtype)])
|
||||||
|
.header("Accept", "application/dns-json")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(resp.answer.unwrap_or_default())
|
||||||
|
}
|
||||||
+113
-1
@@ -8,6 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::command;
|
use crate::command;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::domains;
|
||||||
use crate::env;
|
use crate::env;
|
||||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||||
use crate::query;
|
use crate::query;
|
||||||
@@ -16,6 +17,111 @@ const RELAY_SYNC_RETRY_BASE_DELAY_SECS: u64 = 30;
|
|||||||
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
const RELAY_SYNC_RETRY_MAX_DELAY_SECS: u64 = 15 * 60;
|
||||||
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
const RELAY_SYNC_RETRY_MAX_ATTEMPTS: usize = 6;
|
||||||
|
|
||||||
|
const DOMAIN_VERIFY_INTERVAL_SECS: u64 = 30;
|
||||||
|
const DOMAIN_REVERIFY_INTERVAL_SECS: u64 = 3 * 60 * 60;
|
||||||
|
|
||||||
|
/// Poll for relays with an unverified custom domain and attempt DNS verification.
|
||||||
|
/// Runs for the life of the process; each cycle waits for all checks to finish
|
||||||
|
/// before sleeping, so cycles never overlap.
|
||||||
|
pub async fn start_domain_verification() {
|
||||||
|
loop {
|
||||||
|
if let Err(e) = verify_pending_custom_domains().await {
|
||||||
|
tracing::error!(error = %e, "domain verification poll failed");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(DOMAIN_VERIFY_INTERVAL_SECS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_pending_custom_domains() -> Result<()> {
|
||||||
|
let relays = query::list_relays_with_unverified_custom_domain().await?;
|
||||||
|
for relay in relays {
|
||||||
|
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
|
||||||
|
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!(
|
||||||
|
relay = %relay.id,
|
||||||
|
domain = %relay.custom_domain,
|
||||||
|
"custom domain verified",
|
||||||
|
);
|
||||||
|
if let Err(e) = command::verify_relay_custom_domain(&relay.id).await {
|
||||||
|
tracing::error!(relay = %relay.id, error = %e, "failed to mark domain verified");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Fetch the updated relay and sync so Zooid learns the new host.
|
||||||
|
match query::get_relay(&relay.id).await {
|
||||||
|
Ok(Some(updated)) => sync_relay(&updated).await,
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::debug!(
|
||||||
|
relay = %relay.id,
|
||||||
|
domain = %relay.custom_domain,
|
||||||
|
target = %canonical,
|
||||||
|
"custom domain not yet pointing to relay",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(relay = %relay.id, error = %e, "DNS check failed for custom domain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll verified custom domains every 3 hours and un-verify any whose DNS no
|
||||||
|
/// longer points at the relay. Triggers a Zooid sync so the relay reverts to
|
||||||
|
/// its canonical subdomain as its host.
|
||||||
|
pub async fn start_domain_reverification() {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(DOMAIN_REVERIFY_INTERVAL_SECS)).await;
|
||||||
|
if let Err(e) = check_verified_custom_domains().await {
|
||||||
|
tracing::error!(error = %e, "verified domain check poll failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_verified_custom_domains() -> Result<()> {
|
||||||
|
let relays = query::list_relays_with_verified_custom_domain().await?;
|
||||||
|
for relay in relays {
|
||||||
|
let canonical = format!("{}.{}", relay.subdomain, env::get().relay_domain);
|
||||||
|
match domains::domain_points_to(&relay.custom_domain, &canonical).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::debug!(
|
||||||
|
relay = %relay.id,
|
||||||
|
domain = %relay.custom_domain,
|
||||||
|
"verified custom domain still points to relay",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
tracing::warn!(
|
||||||
|
relay = %relay.id,
|
||||||
|
domain = %relay.custom_domain,
|
||||||
|
"verified custom domain no longer points to relay; removing verification",
|
||||||
|
);
|
||||||
|
if let Err(e) = command::unverify_relay_custom_domain(&relay.id).await {
|
||||||
|
tracing::error!(relay = %relay.id, error = %e, "failed to un-verify custom domain");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match query::get_relay(&relay.id).await {
|
||||||
|
Ok(Some(updated)) => sync_relay(&updated).await,
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(relay = %relay.id, error = %e, "failed to fetch relay after domain un-verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(relay = %relay.id, error = %e, "DNS check failed for verified custom domain");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the reactor for the life of the process: reconcile any relays left
|
/// Run the reactor for the life of the process: reconcile any relays left
|
||||||
/// unsynced from a previous run, then sync each relay as its activity arrives.
|
/// unsynced from a previous run, then sync each relay as its activity arrives.
|
||||||
pub async fn start() {
|
pub async fn start() {
|
||||||
@@ -175,8 +281,14 @@ async fn try_sync_relay(relay: &Relay) -> Result<()> {
|
|||||||
.await?
|
.await?
|
||||||
.is_none();
|
.is_none();
|
||||||
|
|
||||||
|
let host = if relay.custom_domain_verified == 1 && !relay.custom_domain.is_empty() {
|
||||||
|
relay.custom_domain.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}.{}", relay.subdomain, env::get().relay_domain)
|
||||||
|
};
|
||||||
|
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
|
"host": host,
|
||||||
"schema": relay.id,
|
"schema": relay.id,
|
||||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod billing;
|
|||||||
pub mod bitcoin;
|
pub mod bitcoin;
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod domains;
|
||||||
pub mod env;
|
pub mod env;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod billing;
|
|||||||
mod bitcoin;
|
mod bitcoin;
|
||||||
mod command;
|
mod command;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod domains;
|
||||||
mod env;
|
mod env;
|
||||||
mod infra;
|
mod infra;
|
||||||
mod models;
|
mod models;
|
||||||
@@ -56,6 +57,12 @@ async fn main() -> Result<()> {
|
|||||||
tokio::spawn(async {
|
tokio::spawn(async {
|
||||||
infra::start().await;
|
infra::start().await;
|
||||||
});
|
});
|
||||||
|
tokio::spawn(async {
|
||||||
|
infra::start_domain_verification().await;
|
||||||
|
});
|
||||||
|
tokio::spawn(async {
|
||||||
|
infra::start_domain_reverification().await;
|
||||||
|
});
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
billing.start().await;
|
billing.start().await;
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ pub struct Relay {
|
|||||||
pub blossom_enabled: i64,
|
pub blossom_enabled: i64,
|
||||||
pub livekit_enabled: i64,
|
pub livekit_enabled: i64,
|
||||||
pub push_enabled: i64,
|
pub push_enabled: i64,
|
||||||
|
pub custom_domain: String,
|
||||||
|
pub custom_domain_verified: i64,
|
||||||
pub synced: i64,
|
pub synced: i64,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,8 @@ impl Default for Relay {
|
|||||||
blossom_enabled: 0,
|
blossom_enabled: 0,
|
||||||
livekit_enabled: 0,
|
livekit_enabled: 0,
|
||||||
push_enabled: 1,
|
push_enabled: 1,
|
||||||
|
custom_domain: String::new(),
|
||||||
|
custom_domain_verified: 0,
|
||||||
synced: 0,
|
synced: 0,
|
||||||
created_at: 0,
|
created_at: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ pub async fn list_relays() -> Result<Vec<Relay>> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_relays_with_unverified_custom_domain() -> Result<Vec<Relay>> {
|
||||||
|
Ok(sqlx::query_as::<_, Relay>(&select_relay(
|
||||||
|
"WHERE custom_domain != '' AND custom_domain_verified = 0",
|
||||||
|
))
|
||||||
|
.fetch_all(pool())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_relays_with_verified_custom_domain() -> Result<Vec<Relay>> {
|
||||||
|
Ok(sqlx::query_as::<_, Relay>(&select_relay(
|
||||||
|
"WHERE custom_domain != '' AND custom_domain_verified = 1",
|
||||||
|
))
|
||||||
|
.fetch_all(pool())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
pub async fn list_relays_pending_sync() -> Result<Vec<Relay>> {
|
||||||
Ok(
|
Ok(
|
||||||
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
|
sqlx::query_as::<_, Relay>(&select_relay("WHERE synced = 0 OR TRIM(sync_error) != ''"))
|
||||||
@@ -106,6 +122,15 @@ pub async fn get_relay(id: &str) -> Result<Option<Relay>> {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_relay_by_verified_custom_domain(domain: &str) -> Result<Option<Relay>> {
|
||||||
|
Ok(sqlx::query_as::<_, Relay>(&select_relay(
|
||||||
|
"WHERE custom_domain = ? AND custom_domain_verified = 1",
|
||||||
|
))
|
||||||
|
.bind(domain)
|
||||||
|
.fetch_optional(pool())
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The relay's plan immediately before `before`, read from the most recent
|
/// The relay's plan immediately before `before`, read from the most recent
|
||||||
/// relay-activity snapshot with `created_at < before`. Billing uses this as
|
/// relay-activity snapshot with `created_at < before`. Billing uses this as
|
||||||
/// the `old` side of a plan-change delta.
|
/// the `old` side of a plan-change delta.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::{extract::Query, http::StatusCode, response::IntoResponse};
|
||||||
|
|
||||||
|
use crate::env;
|
||||||
|
use crate::query;
|
||||||
|
|
||||||
|
/// Caddy on-demand TLS "ask" endpoint. Returns 200 if Caddy should provision a
|
||||||
|
/// cert for the domain, 404 if not. No authentication: Caddy calls this
|
||||||
|
/// internally before issuing a certificate, passing the domain as a `?domain=`
|
||||||
|
/// query parameter.
|
||||||
|
pub async fn check_domain(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
|
||||||
|
let Some(domain) = params.get("domain") else {
|
||||||
|
return StatusCode::BAD_REQUEST;
|
||||||
|
};
|
||||||
|
|
||||||
|
let domain = domain.trim_end_matches('.');
|
||||||
|
let relay_domain = &env::get().relay_domain;
|
||||||
|
|
||||||
|
if domain == relay_domain.as_str() || domain.ends_with(&format!(".{relay_domain}")) {
|
||||||
|
return StatusCode::OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
match query::get_relay_by_verified_custom_domain(domain).await {
|
||||||
|
Ok(Some(_)) => StatusCode::OK,
|
||||||
|
_ => StatusCode::NOT_FOUND,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod domains;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod invoices;
|
pub mod invoices;
|
||||||
pub mod plans;
|
pub mod plans;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use regex::Regex;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::{Api, AuthedPubkey};
|
use crate::api::{Api, AuthedPubkey};
|
||||||
|
use crate::domains;
|
||||||
|
use crate::env;
|
||||||
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
use crate::models::{RELAY_STATUS_ACTIVE, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||||
use crate::web::{
|
use crate::web::{
|
||||||
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
|
ApiError, ApiResult, bad_request, created, internal, map_unique_error, ok, parse_bool_default,
|
||||||
@@ -77,6 +79,8 @@ pub struct CreateRelayRequest {
|
|||||||
pub blossom_enabled: i64,
|
pub blossom_enabled: i64,
|
||||||
pub livekit_enabled: i64,
|
pub livekit_enabled: i64,
|
||||||
pub push_enabled: i64,
|
pub push_enabled: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_relay(
|
pub async fn create_relay(
|
||||||
@@ -107,6 +111,7 @@ pub async fn create_relay(
|
|||||||
blossom_enabled: payload.blossom_enabled,
|
blossom_enabled: payload.blossom_enabled,
|
||||||
livekit_enabled: payload.livekit_enabled,
|
livekit_enabled: payload.livekit_enabled,
|
||||||
push_enabled: payload.push_enabled,
|
push_enabled: payload.push_enabled,
|
||||||
|
custom_domain: normalize_custom_domain(&payload.custom_domain)?,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,6 +138,7 @@ pub struct UpdateRelayRequest {
|
|||||||
pub blossom_enabled: Option<i64>,
|
pub blossom_enabled: Option<i64>,
|
||||||
pub livekit_enabled: Option<i64>,
|
pub livekit_enabled: Option<i64>,
|
||||||
pub push_enabled: Option<i64>,
|
pub push_enabled: Option<i64>,
|
||||||
|
pub custom_domain: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_relay(
|
pub async fn update_relay(
|
||||||
@@ -184,6 +190,17 @@ pub async fn update_relay(
|
|||||||
if let Some(v) = payload.push_enabled {
|
if let Some(v) = payload.push_enabled {
|
||||||
relay.push_enabled = v;
|
relay.push_enabled = v;
|
||||||
}
|
}
|
||||||
|
// Changing the custom domain invalidates any prior verification, so clear the
|
||||||
|
// flag and let the background poller re-verify the new domain. An update that
|
||||||
|
// round-trips the existing domain (e.g. a feature toggle) leaves it untouched;
|
||||||
|
// the matching reset in `command::update_relay` keeps the persisted row right.
|
||||||
|
if let Some(v) = payload.custom_domain {
|
||||||
|
let normalized = normalize_custom_domain(&v)?;
|
||||||
|
if normalized != relay.custom_domain {
|
||||||
|
relay.custom_domain = normalized;
|
||||||
|
relay.custom_domain_verified = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let relay = prepare_relay(relay)?;
|
let relay = prepare_relay(relay)?;
|
||||||
|
|
||||||
@@ -306,6 +323,32 @@ fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
|||||||
Ok(relay)
|
Ok(relay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize and validate a tenant custom domain. An empty string is allowed and
|
||||||
|
/// clears the domain. Rejects malformed hostnames and any domain under the
|
||||||
|
/// platform's relay domain, which belong in the subdomain field instead.
|
||||||
|
fn normalize_custom_domain(domain: &str) -> Result<String, ApiError> {
|
||||||
|
let domain = domain.trim().to_lowercase();
|
||||||
|
|
||||||
|
if !domain.is_empty() {
|
||||||
|
if !domains::CUSTOM_DOMAIN_RE.is_match(&domain) {
|
||||||
|
return Err(unprocessable(
|
||||||
|
"invalid-domain",
|
||||||
|
"domain must be a valid hostname (e.g. relay.example.com)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay_domain = &env::get().relay_domain;
|
||||||
|
if domain == *relay_domain || domain.ends_with(&format!(".{relay_domain}")) {
|
||||||
|
return Err(unprocessable(
|
||||||
|
"reserved-domain",
|
||||||
|
"use the subdomain field for domains under the platform's relay domain",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(domain)
|
||||||
|
}
|
||||||
|
|
||||||
/// Translate a duplicate-subdomain write into a 422; anything else is a 500.
|
/// Translate a duplicate-subdomain write into a 422; anything else is a 500.
|
||||||
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
||||||
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { Show, createSignal } from "solid-js"
|
import { Show, createSignal } from "solid-js"
|
||||||
import type { Relay, PlanId } from "@/lib/api"
|
import type { Relay, PlanId } from "@/lib/api"
|
||||||
|
import { RELAY_DOMAIN } from "@/lib/subdomain"
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog"
|
import ConfirmDialog from "@/components/ConfirmDialog"
|
||||||
|
import CustomDomainModal from "@/components/relay/CustomDomainModal"
|
||||||
import Field from "@/components/Field"
|
import Field from "@/components/Field"
|
||||||
import PricingTable from "@/components/PricingTable"
|
import PricingTable from "@/components/PricingTable"
|
||||||
import ToggleButton from "@/components/ToggleButton"
|
import ToggleButton from "@/components/ToggleButton"
|
||||||
import ToggleField from "@/components/ToggleField"
|
import ToggleField from "@/components/ToggleField"
|
||||||
import RelayCardHeader from "@/components/relay/RelayCardHeader"
|
import RelayCardHeader, { StatusBadge } from "@/components/relay/RelayCardHeader"
|
||||||
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
|
import PlanGatedToggle from "@/components/relay/PlanGatedToggle"
|
||||||
import { setToastMessage } from "@/lib/state"
|
import { setToastMessage } from "@/lib/state"
|
||||||
import { useProfileMetadata } from "@/lib/hooks"
|
import { useProfileMetadata } from "@/lib/hooks"
|
||||||
|
import useMinLoading from "@/lib/useMinLoading"
|
||||||
import { flagToBool } from "@/lib/relayFlags"
|
import { flagToBool } from "@/lib/relayFlags"
|
||||||
import { plans } from "@/lib/state"
|
import { plans } from "@/lib/state"
|
||||||
|
import useCustomDomain from "@/lib/useCustomDomain"
|
||||||
|
|
||||||
function DetailSection(props: { title: string; children: any }) {
|
function DetailSection(props: { title: string; children: any }) {
|
||||||
return (
|
return (
|
||||||
@@ -53,12 +57,17 @@ type RelayDetailCardProps = {
|
|||||||
onUpdatePlan?: (planId: PlanId) => Promise<void>
|
onUpdatePlan?: (planId: PlanId) => Promise<void>
|
||||||
enforcePlanLimits?: boolean
|
enforcePlanLimits?: boolean
|
||||||
showPlanActions?: boolean
|
showPlanActions?: boolean
|
||||||
|
mutateRelay?: (relay: Relay) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RelayDetailCard(props: RelayDetailCardProps) {
|
export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||||
const r = () => props.relay
|
const r = () => props.relay
|
||||||
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
|
const [planId, setPlanId] = createSignal<PlanId>(props.relay.plan_id)
|
||||||
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
|
const [pendingAction, setPendingAction] = createSignal<"deactivate" | "reactivate" | null>(null)
|
||||||
|
const [customDomainModalOpen, setCustomDomainModalOpen] = createSignal(false)
|
||||||
|
const { saving: cdSaving, verifying: cdVerifying, error: cdError, saveDomain, verifyDomain } =
|
||||||
|
useCustomDomain(() => props.relay.id, props.mutateRelay ?? (() => {}))
|
||||||
|
const cdVerifyingVisible = useMinLoading(cdVerifying)
|
||||||
|
|
||||||
// Resolve the owning tenant's profile so the Tenant field can show a name and
|
// Resolve the owning tenant's profile so the Tenant field can show a name and
|
||||||
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
|
// avatar instead of a raw pubkey. Only relevant in admin (showTenant) views.
|
||||||
@@ -134,6 +143,70 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
|
|||||||
reactivating={props.reactivating}
|
reactivating={props.reactivating}
|
||||||
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
|
onRequestDeactivate={props.onDeactivate ? () => openActionDialog("deactivate") : undefined}
|
||||||
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
|
onRequestReactivate={props.onReactivate ? () => openActionDialog("reactivate") : undefined}
|
||||||
|
onRequestManageCustomDomain={() => setCustomDomainModalOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr class="border-gray-200" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wider mb-6">Custom Domain</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Show
|
||||||
|
when={r().custom_domain}
|
||||||
|
fallback={<span class="text-gray-400 text-sm">Not configured</span>}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-gray-900">{r().custom_domain}</span>
|
||||||
|
<Show when={r().custom_domain_verified === 1}>
|
||||||
|
<StatusBadge status="verified" />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={() => setCustomDomainModalOpen(true)}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={r().custom_domain && r().custom_domain_verified !== 1}>
|
||||||
|
<div class="rounded-lg border border-yellow-200 bg-yellow-50 px-4 py-3 space-y-3">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<span class="mt-0.5 text-yellow-600 text-sm shrink-0">⚠</span>
|
||||||
|
<p class="text-sm font-medium text-yellow-800">
|
||||||
|
Not yet verified — add this DNS record, then verify:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded border border-yellow-200 bg-white px-3 py-2 font-mono text-xs text-gray-700 break-all">
|
||||||
|
{r().custom_domain} CNAME {r().subdomain}.{RELAY_DOMAIN}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-yellow-700">
|
||||||
|
For apex domains (e.g. example.com), use an ALIAS or ANAME record instead.
|
||||||
|
</p>
|
||||||
|
<Show when={cdError()}>
|
||||||
|
<p class="text-sm text-red-600">{cdError()}</p>
|
||||||
|
</Show>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={verifyDomain}
|
||||||
|
disabled={cdVerifyingVisible()}
|
||||||
|
class="inline-flex items-center rounded-lg bg-yellow-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-yellow-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cdVerifyingVisible() ? "Verifying…" : "Verify DNS record"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomDomainModal
|
||||||
|
open={customDomainModalOpen()}
|
||||||
|
onClose={() => setCustomDomainModalOpen(false)}
|
||||||
|
relay={r}
|
||||||
|
saving={cdSaving}
|
||||||
|
error={cdError}
|
||||||
|
onSave={saveDomain}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<hr class="border-gray-200" />
|
<hr class="border-gray-200" />
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { Show, createEffect, createSignal } from "solid-js"
|
||||||
|
import Modal from "@/components/Modal"
|
||||||
|
import type { Relay } from "@/lib/api"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
relay: () => Relay | undefined
|
||||||
|
saving: () => boolean
|
||||||
|
error: () => string | undefined
|
||||||
|
onSave: (domain: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomDomainModal(props: Props) {
|
||||||
|
const [input, setInput] = createSignal("")
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
setInput(props.relay()?.custom_domain ?? "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const current = () => props.relay()?.custom_domain ?? ""
|
||||||
|
const inputTrimmed = () => input().trim()
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
await props.onSave(inputTrimmed())
|
||||||
|
if (!props.error()) props.onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={props.open}
|
||||||
|
onClose={props.onClose}
|
||||||
|
wrapperClass="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
panelClass="w-full max-w-lg bg-white rounded-2xl shadow-xl p-6 space-y-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Custom domain</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={props.onClose}
|
||||||
|
class="text-gray-400 hover:text-gray-600 text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Custom domain</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input()}
|
||||||
|
onInput={(e) => setInput(e.currentTarget.value)}
|
||||||
|
placeholder="relay.example.com"
|
||||||
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
spellcheck={false}
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Must be a domain you control, e.g. <span class="font-mono">relay.example.com</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Show when={props.error()}>
|
||||||
|
<p class="text-sm text-red-600">{props.error()}</p>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={props.saving() || !inputTrimmed() || inputTrimmed() === current()}
|
||||||
|
class="inline-flex items-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{props.saving() ? "Saving…" : "Save domain"}
|
||||||
|
</button>
|
||||||
|
<Show when={current()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onSave("")}
|
||||||
|
disabled={props.saving()}
|
||||||
|
class="inline-flex items-center rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{props.saving() ? "Removing…" : "Remove custom domain"}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { RELAY_DOMAIN } from "@/lib/subdomain"
|
|||||||
const STATUS_STYLES: Record<string, string> = {
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
active: "bg-green-50 text-green-700 border-green-200",
|
active: "bg-green-50 text-green-700 border-green-200",
|
||||||
inactive: "bg-gray-100 text-gray-500 border-gray-200",
|
inactive: "bg-gray-100 text-gray-500 border-gray-200",
|
||||||
|
verified: "bg-green-50 text-green-700 border-green-200",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusBadge(props: { status: string }) {
|
export function StatusBadge(props: { status: string }) {
|
||||||
@@ -47,6 +48,7 @@ type RelayCardHeaderProps = {
|
|||||||
reactivating?: boolean
|
reactivating?: boolean
|
||||||
onRequestDeactivate?: () => void
|
onRequestDeactivate?: () => void
|
||||||
onRequestReactivate?: () => void
|
onRequestReactivate?: () => void
|
||||||
|
onRequestManageCustomDomain?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RelayCardHeader(props: RelayCardHeaderProps) {
|
export default function RelayCardHeader(props: RelayCardHeaderProps) {
|
||||||
@@ -127,7 +129,7 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={props.editHref && (props.onRequestDeactivate || props.onRequestReactivate)}>
|
<Show when={props.editHref || props.onRequestDeactivate || props.onRequestReactivate || props.onRequestManageCustomDomain}>
|
||||||
<div class="relative shrink-0" ref={menuContainerRef}>
|
<div class="relative shrink-0" ref={menuContainerRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -145,13 +147,27 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
|
|||||||
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
|
"opacity-0 scale-95 pointer-events-none": !menuOpen(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<A
|
<Show when={props.editHref}>
|
||||||
href={props.editHref!}
|
<A
|
||||||
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
href={props.editHref!}
|
||||||
onClick={() => setMenuOpen(false)}
|
class="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
>
|
onClick={() => setMenuOpen(false)}
|
||||||
Edit Details
|
>
|
||||||
</A>
|
Edit Details
|
||||||
|
</A>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.onRequestManageCustomDomain}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="block w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
props.onRequestManageCustomDomain?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage custom domain
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<Show when={r().status === "active" && props.onRequestDeactivate}>
|
<Show when={r().status === "active" && props.onRequestDeactivate}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type Relay = {
|
|||||||
status: string
|
status: string
|
||||||
sync_error: string
|
sync_error: string
|
||||||
synced: number
|
synced: number
|
||||||
|
custom_domain: string
|
||||||
|
custom_domain_verified: number
|
||||||
info_name: string
|
info_name: string
|
||||||
info_icon: string
|
info_icon: string
|
||||||
info_description: string
|
info_description: string
|
||||||
@@ -76,6 +78,7 @@ export type CreateRelayInput = {
|
|||||||
blossom_enabled?: number
|
blossom_enabled?: number
|
||||||
livekit_enabled?: number
|
livekit_enabled?: number
|
||||||
push_enabled?: number
|
push_enabled?: number
|
||||||
|
custom_domain?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateRelayInput = {
|
export type UpdateRelayInput = {
|
||||||
@@ -91,6 +94,7 @@ export type UpdateRelayInput = {
|
|||||||
blossom_enabled?: number
|
blossom_enabled?: number
|
||||||
livekit_enabled?: number
|
livekit_enabled?: number
|
||||||
push_enabled?: number
|
push_enabled?: number
|
||||||
|
custom_domain?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { Relay } from "@/lib/api"
|
||||||
|
import { getRelay, updateRelay } from "@/lib/api"
|
||||||
|
|
||||||
|
export default function useCustomDomain(relayId: () => string, mutate: (relay: Relay) => void) {
|
||||||
|
const [saving, setSaving] = createSignal(false)
|
||||||
|
const [verifying, setVerifying] = createSignal(false)
|
||||||
|
const [error, setError] = createSignal<string>()
|
||||||
|
|
||||||
|
async function saveDomain(domain: string) {
|
||||||
|
setSaving(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const updated = await updateRelay(relayId(), { custom_domain: domain })
|
||||||
|
mutate(updated)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to save domain")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification runs in a background poller on the backend; reload the relay so
|
||||||
|
// the UI picks up the latest custom_domain_verified state.
|
||||||
|
async function verifyDomain() {
|
||||||
|
setVerifying(true)
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const updated = await getRelay(relayId())
|
||||||
|
mutate(updated)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Verification failed")
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { saving, verifying, error, saveDomain, verifyDomain }
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export default function AdminRelayDetail() {
|
|||||||
reactivating={busy()}
|
reactivating={busy()}
|
||||||
enforcePlanLimits={false}
|
enforcePlanLimits={false}
|
||||||
showPlanActions={false}
|
showPlanActions={false}
|
||||||
|
mutateRelay={mutate}
|
||||||
{...toggles}
|
{...toggles}
|
||||||
/>
|
/>
|
||||||
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
|
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function RelayDetail() {
|
|||||||
deactivating={busy()}
|
deactivating={busy()}
|
||||||
reactivating={busy()}
|
reactivating={busy()}
|
||||||
onUpdatePlan={handleUpdatePlan}
|
onUpdatePlan={handleUpdatePlan}
|
||||||
|
mutateRelay={mutate}
|
||||||
{...toggles}
|
{...toggles}
|
||||||
/>
|
/>
|
||||||
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
|
<ActivityFeed activity={activity() ?? []} loading={activity.loading} />
|
||||||
|
|||||||
Reference in New Issue
Block a user