use clap::Subcommand;
use crate::client;
use crate::config::ProjectConfig;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("verification failed: {detail}\n\n{instructions}")]
VerificationFailed {
detail: String,
instructions: String,
},
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("{0}")]
Verify(String),
#[cfg(feature = "acme-dns")]
#[error(transparent)]
Provider(#[from] crate::acme_dns::Error),
#[cfg(feature = "acme-dns")]
#[error(transparent)]
Dns(#[from] boatramp_acme::DnsError),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, clap::Args)]
pub struct DomainArgs {
#[arg(long, env = "BOATRAMP_SERVER", global = true)]
server: Option<String>,
#[arg(long, env = "BOATRAMP_SITE", global = true)]
site: Option<String>,
#[command(subcommand)]
command: DomainCommand,
}
#[derive(Debug, Subcommand)]
enum DomainCommand {
Add {
host: String,
#[arg(long, default_value = "http")]
method: String,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
no_wait: bool,
#[arg(long)]
unverified: bool,
},
Verify {
host: String,
},
Rm {
host: String,
},
Ls,
}
pub async fn run(args: DomainArgs, config: &ProjectConfig) -> Result<()> {
let (server, site) = client::resolve_target(args.server, args.site, config)?;
let cp = client::ControlPlane::new(
server,
client::http_client(client::token(config).as_deref()),
client::resolve_project(config),
);
match args.command {
DomainCommand::Ls => ls(&cp, &site).await,
DomainCommand::Add {
host,
method,
provider,
no_wait,
unverified,
} => {
if unverified {
let msg = cp.attach_domain_unverified(&site, &host).await?;
print!("{msg}");
return Ok(());
}
if let Some(provider) = provider.as_deref() {
return add_via_provider(&cp, &site, &host, Some(provider)).await;
}
let verification = cp
.start_domain_verification(&site, &host, Some(&method))
.await?;
if verification.verified {
let result = cp.check_domain_verification(&site, &host).await?;
if result.attached {
println!("{host} is already verified and attached to {site}");
} else {
println!("{host} is already verified for {site}");
println!("run `boatramp domain verify {host}` to attach it");
}
return Ok(());
}
println!("started {} verification for {host}\n", verification.method);
println!("{}", verification.instructions());
let http_self_serve =
verification.method == boatramp_core::domain_verify::VerificationMethod::Http;
if no_wait || !http_self_serve {
println!("\nthen run `boatramp domain verify {host}`");
return Ok(());
}
println!("\nchecking whether {host} already resolves here…");
match cp.check_domain_verification(&site, &host).await {
Ok(result) if result.passed && result.attached => {
println!("✓ verified {host} and attached it to {site}");
}
Ok(result) if result.passed => {
println!("✓ verified {host}; run `boatramp domain verify {host}` to attach");
}
Ok(result) => {
let detail = result
.detail
.unwrap_or_else(|| "not reachable here yet".into());
println!("not verified yet ({detail})");
println!("complete the step above, then run `boatramp domain verify {host}`");
}
Err(_) => {
println!("not reachable here yet");
println!("complete the step above, then run `boatramp domain verify {host}`");
}
}
Ok(())
}
DomainCommand::Verify { host } => {
let result = cp.check_domain_verification(&site, &host).await?;
if result.passed {
if result.attached {
println!("verified {host} and attached it to {site}");
} else {
println!("verified {host}");
}
} else {
let detail = result
.detail
.unwrap_or_else(|| "challenge not satisfied yet".into());
return Err(Error::VerificationFailed {
detail,
instructions: result.verification.instructions(),
});
}
Ok(())
}
DomainCommand::Rm { host } => {
let mut site_config = cp.fetch_site_config(&site).await?;
let domains = &mut site_config.domains;
if domains.primary.as_deref() == Some(host.as_str()) {
domains.primary = None;
}
domains.aliases.retain(|alias| alias != &host);
domains.wildcards.retain(|wildcard| wildcard != &host);
cp.put_site_config(&site, &site_config).await?;
cp.remove_domain_verification(&site, &host).await?;
println!("detached {host} from {site}");
Ok(())
}
}
}
#[cfg(feature = "acme-dns")]
async fn add_via_provider(
cp: &crate::client::ControlPlane,
site: &str,
host: &str,
provider: Option<&str>,
) -> Result<()> {
use std::time::Duration;
use boatramp_acme::{DnsRecord, RecordKind};
use clap::ValueEnum;
use crate::acme_dns::{build_provider, DnsProviderKind};
let provider_name = provider
.ok_or_else(|| Error::Verify("missing `--provider <name>` (e.g. cloudflare)".into()))?;
let kind = DnsProviderKind::from_str(provider_name, true)
.map_err(|e| Error::Verify(format!("unknown --provider `{provider_name}`: {e}")))?;
let verification = cp
.start_domain_verification(site, host, Some("dns"))
.await?;
if verification.verified {
println!("{host} is already verified for {site}; run `domain verify {host}` to attach");
return Ok(());
}
let provider = build_provider(kind).await?;
let record = DnsRecord {
kind: RecordKind::Txt,
name: verification.dns_record_name(),
value: verification.token.clone(),
ttl: 60,
};
provider.upsert(&record).await?;
println!(
"published {} TXT for {host}; waiting for it to resolve...",
verification.dns_record_name()
);
const ATTEMPTS: usize = 10;
const EVERY_SECS: u64 = 5;
for attempt in 1..=ATTEMPTS {
let result = cp.check_domain_verification(site, host).await?;
if result.passed {
if result.attached {
println!("verified {host} and attached it to {site}");
} else {
println!("verified {host} (run `domain verify {host}` to attach)");
}
let _ = provider.delete(&record).await;
return Ok(());
}
if attempt < ATTEMPTS {
tokio::time::sleep(Duration::from_secs(EVERY_SECS)).await;
}
}
Err(Error::Verify(format!(
"published the challenge but it did not resolve within {}s — DNS may still \
be propagating; re-run `boatramp domain verify {host}` shortly",
ATTEMPTS as u64 * EVERY_SECS
)))
}
#[cfg(not(feature = "acme-dns"))]
async fn add_via_provider(
_cp: &crate::client::ControlPlane,
_site: &str,
_host: &str,
_provider: Option<&str>,
) -> Result<()> {
Err(Error::Verify(
"`--provider` requires a build with `--features acme-dns`".into(),
))
}
async fn ls(cp: &crate::client::ControlPlane, site: &str) -> Result<()> {
let site_config = cp.fetch_site_config(site).await?;
let domains = &site_config.domains;
let mut any = false;
if let Some(primary) = &domains.primary {
println!("{primary} (primary)");
any = true;
}
for alias in &domains.aliases {
println!("{alias}");
any = true;
}
for wildcard in &domains.wildcards {
println!("{wildcard} (wildcard)");
any = true;
}
if !any {
println!("no domains attached to {site}");
}
let attached: std::collections::BTreeSet<String> = domains
.exact_hosts()
.map(str::to_string)
.chain(domains.wildcards.iter().cloned())
.map(|h| boatramp_core::domain_verify::normalize_host(&h))
.collect();
let pending: Vec<_> = cp
.list_domain_verifications(site)
.await?
.into_iter()
.filter(|v| !attached.contains(&v.host))
.collect();
if !pending.is_empty() {
println!("\npending verification:");
for v in pending {
let state = if v.verified {
"verified — run `domain verify` to attach"
} else {
"unverified"
};
println!(" {} ({}, {state})", v.host, v.method);
}
}
Ok(())
}