This commit is contained in:
@@ -31,6 +31,7 @@ use crate::env;
|
||||
use crate::models::{Relay, Tenant};
|
||||
use crate::query;
|
||||
use crate::robot::Robot;
|
||||
use crate::routes::domains::check_domain;
|
||||
use crate::routes::identity::get_identity;
|
||||
use crate::routes::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/deactivate", post(deactivate_relay))
|
||||
.route("/relays/:id/reactivate", post(reactivate_relay))
|
||||
.route("/domains/check", get(check_domain))
|
||||
.route("/invoices", get(list_invoices))
|
||||
.route("/invoices/:id", get(get_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,
|
||||
policy_public_join, policy_strip_signatures,
|
||||
groups_enabled, management_enabled, blossom_enabled,
|
||||
livekit_enabled, push_enabled, created_at
|
||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
livekit_enabled, push_enabled, custom_domain, created_at
|
||||
) VALUES (?, ?, ?, ?, 'active', 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&relay.id)
|
||||
.bind(&relay.tenant_pubkey)
|
||||
@@ -172,6 +172,7 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.bind(&relay.custom_domain)
|
||||
.bind(created_at)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -188,13 +189,20 @@ pub async fn create_relay(relay: &Relay) -> Result<()> {
|
||||
|
||||
pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
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(
|
||||
"UPDATE relay
|
||||
SET tenant_pubkey = ?, subdomain = ?, plan_id = ?, status = ?, sync_error = ?, synced = 0,
|
||||
info_name = ?, info_icon = ?, info_description = ?,
|
||||
policy_public_join = ?, policy_strip_signatures = ?,
|
||||
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 = ?",
|
||||
)
|
||||
.bind(&relay.tenant_pubkey)
|
||||
@@ -212,6 +220,8 @@ pub async fn update_relay(relay: &Relay) -> Result<()> {
|
||||
.bind(relay.blossom_enabled)
|
||||
.bind(relay.livekit_enabled)
|
||||
.bind(relay.push_enabled)
|
||||
.bind(&relay.custom_domain)
|
||||
.bind(&relay.custom_domain)
|
||||
.bind(&relay.id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
@@ -259,6 +269,22 @@ pub async fn fail_relay_sync(relay: &Relay, sync_error: String) -> Result<()> {
|
||||
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<()> {
|
||||
let activity = with_tx(async |tx| {
|
||||
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::db;
|
||||
use crate::domains;
|
||||
use crate::env;
|
||||
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE, Relay};
|
||||
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_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
|
||||
/// unsynced from a previous run, then sync each relay as its activity arrives.
|
||||
pub async fn start() {
|
||||
@@ -175,8 +281,14 @@ async fn try_sync_relay(relay: &Relay) -> Result<()> {
|
||||
.await?
|
||||
.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!({
|
||||
"host": format!("{}.{}", relay.subdomain, env::get().relay_domain),
|
||||
"host": host,
|
||||
"schema": relay.id,
|
||||
"inactive": relay.status == RELAY_STATUS_INACTIVE
|
||||
|| relay.status == RELAY_STATUS_DELINQUENT,
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod billing;
|
||||
pub mod bitcoin;
|
||||
pub mod command;
|
||||
pub mod db;
|
||||
pub mod domains;
|
||||
pub mod env;
|
||||
pub mod infra;
|
||||
pub mod models;
|
||||
|
||||
@@ -3,6 +3,7 @@ mod billing;
|
||||
mod bitcoin;
|
||||
mod command;
|
||||
mod db;
|
||||
mod domains;
|
||||
mod env;
|
||||
mod infra;
|
||||
mod models;
|
||||
@@ -56,6 +57,12 @@ async fn main() -> Result<()> {
|
||||
tokio::spawn(async {
|
||||
infra::start().await;
|
||||
});
|
||||
tokio::spawn(async {
|
||||
infra::start_domain_verification().await;
|
||||
});
|
||||
tokio::spawn(async {
|
||||
infra::start_domain_reverification().await;
|
||||
});
|
||||
|
||||
tokio::spawn(async move {
|
||||
billing.start().await;
|
||||
|
||||
@@ -90,6 +90,8 @@ pub struct Relay {
|
||||
pub blossom_enabled: i64,
|
||||
pub livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
pub custom_domain: String,
|
||||
pub custom_domain_verified: i64,
|
||||
pub synced: i64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
@@ -113,6 +115,8 @@ impl Default for Relay {
|
||||
blossom_enabled: 0,
|
||||
livekit_enabled: 0,
|
||||
push_enabled: 1,
|
||||
custom_domain: String::new(),
|
||||
custom_domain_verified: 0,
|
||||
synced: 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>> {
|
||||
Ok(
|
||||
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?)
|
||||
}
|
||||
|
||||
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
|
||||
/// relay-activity snapshot with `created_at < before`. Billing uses this as
|
||||
/// 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 invoices;
|
||||
pub mod plans;
|
||||
|
||||
@@ -9,6 +9,8 @@ use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
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::web::{
|
||||
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 livekit_enabled: i64,
|
||||
pub push_enabled: i64,
|
||||
#[serde(default)]
|
||||
pub custom_domain: String,
|
||||
}
|
||||
|
||||
pub async fn create_relay(
|
||||
@@ -107,6 +111,7 @@ pub async fn create_relay(
|
||||
blossom_enabled: payload.blossom_enabled,
|
||||
livekit_enabled: payload.livekit_enabled,
|
||||
push_enabled: payload.push_enabled,
|
||||
custom_domain: normalize_custom_domain(&payload.custom_domain)?,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -133,6 +138,7 @@ pub struct UpdateRelayRequest {
|
||||
pub blossom_enabled: Option<i64>,
|
||||
pub livekit_enabled: Option<i64>,
|
||||
pub push_enabled: Option<i64>,
|
||||
pub custom_domain: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_relay(
|
||||
@@ -184,6 +190,17 @@ pub async fn update_relay(
|
||||
if let Some(v) = payload.push_enabled {
|
||||
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)?;
|
||||
|
||||
@@ -306,6 +323,32 @@ fn prepare_relay(mut relay: Relay) -> Result<Relay, ApiError> {
|
||||
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.
|
||||
fn map_relay_write_error(e: anyhow::Error) -> ApiError {
|
||||
if matches!(map_unique_error(&e), Some("subdomain-exists")) {
|
||||
|
||||
Reference in New Issue
Block a user