Add custom domain support
Docker / build-and-push-image (push) Successful in 1m41s

This commit is contained in:
Jon Staab
2026-06-11 14:28:12 -07:00
parent bd3217f43d
commit 90f5a55269
20 changed files with 629 additions and 13 deletions
+28
View File
@@ -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
View File
@@ -1,3 +1,4 @@
pub mod domains;
pub mod identity;
pub mod invoices;
pub mod plans;
+43
View File
@@ -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")) {