use std::sync::Arc;
use std::time::Duration;
use boatramp_core::time::now_unix;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use boatramp_core::deploy::DeployStore;
use boatramp_core::domain_verify::{
check_ownership, CheckResult, DomainProbe, VerificationMethod, VerifyError,
};
use serde::Deserialize;
use crate::deploy_error_response;
pub struct ServerDomainProbe {
http: reqwest::Client,
allow_private: bool,
}
impl ServerDomainProbe {
pub fn new(allow_private: bool) -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("boatramp-domain-verify")
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap_or_default();
Self {
http,
allow_private,
}
}
async fn pinned_client_for(&self, url: &str) -> Result<reqwest::Client, VerifyError> {
if self.allow_private {
return Ok(self.http.clone());
}
let parsed = reqwest::Url::parse(url)
.map_err(|e| VerifyError::Probe(format!("bad challenge url: {e}")))?;
let host = parsed
.host_str()
.ok_or_else(|| VerifyError::Probe("challenge url has no host".into()))?
.to_string();
let port = parsed.port_or_known_default().unwrap_or(80);
let mut pinned = None;
match tokio::net::lookup_host((host.as_str(), port)).await {
Ok(addrs) => {
for addr in addrs {
if !boatramp_core::access::is_global_ip(addr.ip()) {
return Err(VerifyError::Probe(format!(
"challenge host {host} resolves to a non-global address \
({}) — refused",
addr.ip()
)));
}
pinned.get_or_insert(addr);
}
}
Err(e) => return Err(VerifyError::Probe(format!("resolving {host}: {e}"))),
}
let addr = pinned
.ok_or_else(|| VerifyError::Probe(format!("challenge host {host} did not resolve")))?;
reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("boatramp-domain-verify")
.resolve(&host, addr)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| VerifyError::Probe(e.to_string()))
}
}
impl Default for ServerDomainProbe {
fn default() -> Self {
Self::new(false)
}
}
#[async_trait::async_trait]
impl DomainProbe for ServerDomainProbe {
async fn lookup_txt(&self, name: &str) -> Result<Vec<String>, VerifyError> {
#[cfg(feature = "domain-verify-dns")]
{
resolve_txt(name).await
}
#[cfg(not(feature = "domain-verify-dns"))]
{
let _ = name;
Err(VerifyError::Unsupported(VerificationMethod::Dns))
}
}
async fn fetch_http(&self, url: &str) -> Result<String, VerifyError> {
const MAX_HOPS: usize = 5;
let mut current = url.to_string();
for _ in 0..=MAX_HOPS {
let client = self.pinned_client_for(¤t).await?;
let resp = client
.get(¤t)
.send()
.await
.map_err(|e| VerifyError::Probe(e.to_string()))?;
if resp.status().is_redirection() {
let location = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
VerifyError::Probe(format!("redirect {} without a Location", resp.status()))
})?;
let next = reqwest::Url::parse(¤t)
.and_then(|base| base.join(location))
.map_err(|e| VerifyError::Probe(format!("bad redirect Location: {e}")))?;
if !matches!(next.scheme(), "http" | "https") {
return Err(VerifyError::Probe(format!(
"refusing non-HTTP redirect to {next}"
)));
}
current = next.into();
continue;
}
let resp = resp
.error_for_status()
.map_err(|e| VerifyError::Probe(e.to_string()))?;
let body = resp
.text()
.await
.map_err(|e| VerifyError::Probe(e.to_string()))?;
return Ok(body.chars().take(4096).collect());
}
Err(VerifyError::Probe(format!(
"too many redirects (more than {MAX_HOPS})"
)))
}
}
#[cfg(feature = "domain-verify-dns")]
async fn resolve_txt(name: &str) -> Result<Vec<String>, VerifyError> {
use hickory_resolver::error::ResolveErrorKind;
use hickory_resolver::TokioAsyncResolver;
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
Ok(resolver) => resolver,
Err(_) => TokioAsyncResolver::tokio(
hickory_resolver::config::ResolverConfig::default(),
hickory_resolver::config::ResolverOpts::default(),
),
};
let lookup = match resolver.txt_lookup(name).await {
Ok(lookup) => lookup,
Err(err) if matches!(err.kind(), ResolveErrorKind::NoRecordsFound { .. }) => {
return Ok(Vec::new());
}
Err(err) => return Err(VerifyError::Probe(err.to_string())),
};
let values = lookup
.iter()
.map(|txt| {
txt.txt_data()
.iter()
.map(|chunk| String::from_utf8_lossy(chunk).into_owned())
.collect::<String>()
})
.collect();
Ok(values)
}
#[derive(Debug, Deserialize)]
pub(crate) struct StartQuery {
method: Option<String>,
}
pub(crate) async fn get_domain_verification(
State(deploy): State<DeployStore>,
Path((site, host)): Path<(String, String)>,
) -> Response {
match deploy
.get_domain_verification(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(Some(v)) => Json(v).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
"no verification challenge; start one with `domain add`\n",
)
.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(crate) async fn list_domain_verifications(
State(deploy): State<DeployStore>,
Path(site): Path<String>,
) -> Response {
match deploy
.list_domain_verifications(&boatramp_core::site::SiteName::new(site.as_str()))
.await
{
Ok(list) => Json(list).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(crate) async fn start_domain_verification(
State(deploy): State<DeployStore>,
Path((site, host)): Path<(String, String)>,
Query(query): Query<StartQuery>,
) -> Response {
let method = match query.method.as_deref() {
None => VerificationMethod::Http,
Some(raw) => match raw.parse::<VerificationMethod>() {
Ok(method) => method,
Err(err) => return (StatusCode::BAD_REQUEST, format!("{err}\n")).into_response(),
},
};
match deploy
.start_domain_verification(
&boatramp_core::site::SiteName::new(site.as_str()),
&host,
method,
now_unix(),
)
.await
{
Ok(v) => Json(v).into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(crate) async fn attach_domain_unverified(
State(deploy): State<DeployStore>,
Path((site, host)): Path<(String, String)>,
) -> Response {
let method = if host.starts_with("*.") {
VerificationMethod::Dns
} else {
VerificationMethod::Http
};
if let Err(err) = deploy
.start_domain_verification(
&boatramp_core::site::SiteName::new(site.as_str()),
&host,
method,
now_unix(),
)
.await
{
return deploy_error_response(err);
}
if let Err(err) = deploy
.mark_domain_verified(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
return deploy_error_response(err);
}
match deploy
.attach_verified_domain(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(_) => (
StatusCode::OK,
format!("attached {host} to site {site} without verification (admin override)\n"),
)
.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(crate) async fn remove_domain_verification(
State(deploy): State<DeployStore>,
Path((site, host)): Path<(String, String)>,
) -> Response {
match deploy
.remove_domain_verification(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(err) => deploy_error_response(err),
}
}
pub(crate) async fn check_domain_verification(
State(deploy): State<DeployStore>,
Extension(probe): Extension<Arc<dyn DomainProbe>>,
Path((site, host)): Path<(String, String)>,
) -> Response {
let verification = match deploy
.get_domain_verification(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(Some(v)) => v,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
"no verification challenge; start one with `domain add`\n",
)
.into_response()
}
Err(err) => return deploy_error_response(err),
};
match check_ownership(probe.as_ref(), &verification).await {
Ok(true) => {
let verified = match deploy
.mark_domain_verified(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(v) => v,
Err(err) => return deploy_error_response(err),
};
let attached = match deploy
.attach_verified_domain(&boatramp_core::site::SiteName::new(site.as_str()), &host)
.await
{
Ok(_) => true,
Err(err) => return deploy_error_response(err),
};
Json(CheckResult {
verification: verified,
passed: true,
attached,
detail: None,
})
.into_response()
}
Ok(false) => Json(CheckResult {
verification,
passed: false,
attached: false,
detail: Some("challenge token not found at the expected location yet".into()),
})
.into_response(),
Err(VerifyError::Unsupported(method)) => (
StatusCode::NOT_IMPLEMENTED,
format!(
"verification method `{method}` is not supported by this build; \
use `--method http` or rebuild with the `domain-verify-dns` feature\n"
),
)
.into_response(),
Err(err) => (StatusCode::BAD_GATEWAY, format!("{err}\n")).into_response(),
}
}
const RECONCILE_HINT_SECS: u32 = 60;
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub async fn verification_pending_page(deploy: &DeployStore, host: &str) -> Response {
let host_norm = host.trim_end_matches('.').to_ascii_lowercase();
let pending = deploy
.list_all_domain_verifications()
.await
.ok()
.and_then(|all| {
all.into_iter()
.find(|(_, v)| !v.verified && v.host == host_norm)
});
let (token, http_path) = match &pending {
Some((_, v)) => (v.token.clone(), v.http_challenge_path()),
None => (
"run `boatramp domain add` to start".to_string(),
"/.well-known/boatramp-domain-verification/<token>".to_string(),
),
};
let html = include_str!("verification_pending.html")
.replace("{{HOST}}", &html_escape(&host_norm))
.replace(
"{{VERIFY_DNS_NAME}}",
&html_escape(&format!("_boatramp-verify.{host_norm}")),
)
.replace("{{VERIFY_TOKEN}}", &html_escape(&token))
.replace("{{VERIFY_HTTP_PATH}}", &html_escape(&http_path))
.replace("{{RECONCILE_SECONDS}}", &RECONCILE_HINT_SECS.to_string())
.replace("{{DNS_PROVIDER}}", "cloudflare")
.replace(
"{{DOCS_URL}}",
"https://docs.boatramp.dev/how-to/custom-domain.html",
);
(
StatusCode::MISDIRECTED_REQUEST,
[
(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"),
(
axum::http::header::CONTENT_SECURITY_POLICY,
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'",
),
(axum::http::header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
],
html,
)
.into_response()
}
async fn reconcile_domain_verifications(
deploy: &DeployStore,
probe: &dyn DomainProbe,
) -> Result<usize, boatramp_core::error::DeployError> {
const MAX_PROBES_PER_TICK: usize = 256;
let now = now_unix();
let mut attached = 0usize;
let mut probes = 0usize;
for (site, v) in deploy.list_all_domain_verifications().await? {
if v.verified || v.is_expired(now) {
continue;
}
if probes >= MAX_PROBES_PER_TICK {
tracing::debug!(
probed = probes,
"domain-verify reconcile: probe budget reached; remaining hosts next tick"
);
break;
}
probes += 1;
if let Ok(true) = check_ownership(probe, &v).await {
if let Err(err) = deploy
.mark_domain_verified(&boatramp_core::site::SiteName::new(site.as_str()), &v.host)
.await
{
tracing::debug!(%site, host = %v.host, %err, "domain-verify reconcile: mark failed");
continue;
}
match deploy
.attach_verified_domain(&boatramp_core::site::SiteName::new(site.as_str()), &v.host)
.await
{
Ok(_) => {
attached += 1;
tracing::info!(%site, host = %v.host, "domain-verify reconcile: verified + attached");
}
Err(err) => {
tracing::debug!(%site, host = %v.host, %err, "domain-verify reconcile: attach failed");
}
}
}
}
Ok(attached)
}
pub fn spawn_domain_verify_reconcile(
deploy: DeployStore,
allow_private: bool,
is_leader: crate::CronLeaderGate,
tick: std::time::Duration,
) -> tokio::task::JoinHandle<()> {
let probe: Arc<dyn DomainProbe> = Arc::new(ServerDomainProbe::new(allow_private));
tokio::spawn(async move {
let mut interval = tokio::time::interval(tick);
interval.tick().await;
loop {
interval.tick().await;
if !is_leader() {
continue;
}
match reconcile_domain_verifications(&deploy, probe.as_ref()).await {
Ok(n) if n > 0 => {
tracing::info!(
attached = n,
"domain-verify reconcile: attached pending hosts"
);
}
Ok(_) => {}
Err(err) => tracing::warn!(%err, "domain-verify reconcile tick failed"),
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn http_probe_refuses_non_global_host() {
let probe = ServerDomainProbe::new(false);
let err = probe
.fetch_http("http://127.0.0.1:9/.well-known/boatramp-challenge")
.await
.expect_err("loopback must be refused");
let VerifyError::Probe(msg) = err else {
panic!("expected a Probe rejection, got {err:?}");
};
assert!(
msg.contains("non-global"),
"rejection should name the non-global refusal, got: {msg}"
);
}
#[tokio::test]
async fn http_probe_follows_a_redirect_to_the_token() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = [0u8; 1024];
while let Ok(n) = sock.read(&mut buf).await {
if n == 0 {
break;
}
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.contains("GET /redirect") {
"HTTP/1.1 302 Found\r\nLocation: /token\r\nContent-Length: 0\r\n\r\n"
.to_string()
} else {
let body = "verify-token-xyz";
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
};
if sock.write_all(resp.as_bytes()).await.is_err() {
break;
}
let _ = sock.flush().await;
}
});
}
});
let probe = ServerDomainProbe::new(true);
let body = probe
.fetch_http(&format!("http://127.0.0.1:{port}/redirect"))
.await
.expect("the redirect must be followed to the token");
assert_eq!(body, "verify-token-xyz");
}
struct NullStorage;
#[async_trait::async_trait]
impl boatramp_core::Storage for NullStorage {
async fn get(
&self,
_: &str,
) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn get_range(
&self,
_: &str,
_: u64,
_: Option<u64>,
) -> Result<boatramp_core::GetObject, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn put(
&self,
_: &str,
_: boatramp_core::ByteStream,
_: boatramp_core::PutMeta,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::unsupported("null"))
}
async fn head(
&self,
_: &str,
) -> Result<boatramp_core::ObjectMeta, boatramp_core::StorageError> {
Err(boatramp_core::StorageError::NotFound(String::new()))
}
async fn delete(&self, _: &str) -> Result<(), boatramp_core::StorageError> {
Ok(())
}
async fn list(
&self,
_: &str,
) -> Result<Vec<boatramp_core::ObjectMeta>, boatramp_core::StorageError> {
Ok(Vec::new())
}
}
struct TokenProbe {
token: String,
}
#[async_trait::async_trait]
impl DomainProbe for TokenProbe {
async fn lookup_txt(&self, _: &str) -> Result<Vec<String>, VerifyError> {
Ok(vec![self.token.clone()])
}
async fn fetch_http(&self, _: &str) -> Result<String, VerifyError> {
Ok(self.token.clone())
}
}
#[tokio::test]
async fn reconcile_attaches_a_now_passing_pending_host() {
use boatramp_core::deploy::DeployStore;
use boatramp_core::domain_verify::VerificationMethod;
use boatramp_core::kv::MemoryKv;
let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
let v = deploy
.start_domain_verification(
&boatramp_core::site::SiteName::new("www"),
"example.com",
VerificationMethod::Http,
now_unix(),
)
.await
.unwrap();
assert!(!v.verified);
assert!(deploy
.resolve_site_by_host("example.com")
.await
.unwrap()
.is_none());
let probe = TokenProbe {
token: v.token.clone(),
};
assert_eq!(
reconcile_domain_verifications(&deploy, &probe)
.await
.unwrap(),
1
);
assert_eq!(
deploy
.resolve_site_by_host("example.com")
.await
.unwrap()
.as_deref(),
Some("www")
);
assert_eq!(
reconcile_domain_verifications(&deploy, &probe)
.await
.unwrap(),
0
);
}
}