use crate::config::SelfHostedConfig;
use crate::progress::Progress;
use anyhow::{anyhow, bail, Result};
use std::process::Command;
const ACM_REGION: &str = "us-east-1";
pub fn add(
domain: &str,
cfg: SelfHostedConfig,
progress: &dyn Progress,
) -> Result<SelfHostedConfig> {
if !cfg.is_full() {
bail!("`dove domain add` needs the full tier — run `dove provision full` first");
}
let dist_id = cfg.distribution_id.clone().ok_or_else(|| {
anyhow!("no CloudFront distribution in the config — run `dove provision full` first")
})?;
let profile = cfg.profile.clone();
progress.step("requesting certificate");
let cert_arn = request_cert(profile.as_deref(), domain);
if cert_arn.is_ok() {
progress.done("requesting certificate");
}
let cert_arn = cert_arn?;
let (name, value) = wait_for_validation_record(profile.as_deref(), &cert_arn)?;
progress.field("①", "add this DNS record to validate the certificate");
progress.field("type", "CNAME");
progress.field("name", &name);
progress.field("value", &value);
progress.step("waiting for DNS validation");
let validated = wait_cert_issued(profile.as_deref(), &cert_arn, 45);
if validated.is_ok() {
progress.done("waiting for DNS validation");
}
validated?;
progress.step("attaching domain to CloudFront");
let dist_domain = super::cloudfront::add_alias(profile.as_deref(), &dist_id, domain, &cert_arn);
if dist_domain.is_ok() {
progress.done("attaching domain to CloudFront");
}
let dist_domain = dist_domain?;
progress.field("②", "point your subdomain at CloudFront");
progress.field("type", "CNAME");
progress.field("name", domain);
progress.field("value", &dist_domain);
let mut cfg = cfg;
cfg.gate_url = Some(format!("https://{domain}"));
Ok(cfg)
}
fn request_cert(profile: Option<&str>, domain: &str) -> Result<String> {
let out = aws(
profile,
&[
"acm",
"request-certificate",
"--domain-name",
domain,
"--validation-method",
"DNS",
"--region",
ACM_REGION,
"--output",
"json",
],
)?;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
v["CertificateArn"]
.as_str()
.map(str::to_string)
.ok_or_else(|| anyhow!("no CertificateArn in response"))
}
fn wait_for_validation_record(profile: Option<&str>, arn: &str) -> Result<(String, String)> {
for _ in 0..20 {
let out = aws(
profile,
&[
"acm",
"describe-certificate",
"--certificate-arn",
arn,
"--region",
ACM_REGION,
"--output",
"json",
],
)?;
if out.status.success() {
if let Some(rr) = parse_validation_record(&out.stdout) {
return Ok(rr);
}
}
std::thread::sleep(std::time::Duration::from_secs(3));
}
bail!("the certificate's DNS validation record didn't appear — try again")
}
fn wait_cert_issued(profile: Option<&str>, arn: &str, attempts: u32) -> Result<()> {
for _ in 0..attempts {
let out = aws(
profile,
&[
"acm",
"describe-certificate",
"--certificate-arn",
arn,
"--region",
ACM_REGION,
"--output",
"json",
],
)?;
if out.status.success() && parse_cert_status(&out.stdout).as_deref() == Some("ISSUED") {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(20));
}
bail!("the certificate wasn't validated in time — add record ① and re-run `dove domain add`")
}
pub fn parse_validation_record(json: &[u8]) -> Option<(String, String)> {
let v: serde_json::Value = serde_json::from_slice(json).ok()?;
let rr = &v["Certificate"]["DomainValidationOptions"][0]["ResourceRecord"];
Some((
rr["Name"].as_str()?.to_string(),
rr["Value"].as_str()?.to_string(),
))
}
pub fn parse_cert_status(json: &[u8]) -> Option<String> {
let v: serde_json::Value = serde_json::from_slice(json).ok()?;
v["Certificate"]["Status"].as_str().map(str::to_string)
}
fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
let mut cmd = Command::new("aws");
if let Some(p) = profile {
cmd.args(["--profile", p]);
}
cmd.args(args)
.output()
.map_err(|e| anyhow!("running aws {}: {e}", args.join(" ")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_acm_validation_and_status() {
let json = br#"{"Certificate":{"Status":"PENDING_VALIDATION","DomainValidationOptions":[{"ResourceRecord":{"Name":"_x.share.example.com.","Type":"CNAME","Value":"_y.acm-validations.aws."}}]}}"#;
assert_eq!(
parse_cert_status(json).as_deref(),
Some("PENDING_VALIDATION")
);
let (n, val) = parse_validation_record(json).unwrap();
assert_eq!(n, "_x.share.example.com.");
assert_eq!(val, "_y.acm-validations.aws.");
}
}