dove-core 0.1.1

The shared library behind dove — client-side-encrypted, expiring file sharing from a cloud you own.
Documentation
//! `dove domain add <domain>` — put a custom subdomain in front of the gate. The
//! CloudFront distribution already exists (from `dove provision full`); this
//! requests a DNS-validated ACM cert (us-east-1, CloudFront's rule) and attaches
//! the domain as an alias. DNS that isn't in Route53 (e.g. Cloudflare) means two
//! records the operator adds by hand; those are reported through `Progress`
//! (`field`) as soon as they're known, so the caller can display them well
//! before the (up to ~15 minute) wait for the certificate to validate.
//!
//! Moved from the `dove` CLI's `src/domain.rs` — the AWS orchestration is
//! unchanged. Two headings (`① Add this DNS record…` / `② Point your
//! subdomain…`) had no direct `Progress` equivalent; they're reported as
//! `field("①", …)` / `field("②", …)` — see the module doc above.

use crate::config::SelfHostedConfig;
use crate::progress::Progress;
use anyhow::{anyhow, bail, Result};
use std::process::Command;

const ACM_REGION: &str = "us-east-1";

/// Attach a custom domain to the existing gate distribution. `cfg` is the
/// active self-hosted config (must already be full tier, from `dove provision
/// full`); returns the updated config with `gate_url` pointed at `domain`. The
/// caller (the CLI) persists it.
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();

    // 1. Request the ACM cert (us-east-1) and read its validation record.
    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);

    // 2. Wait for validation (the operator adds the record during this).
    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?;

    // 3. Attach the domain (alias + cert) to the existing gate distribution.
    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?;

    // 4. Point the subdomain at CloudFront.
    progress.field("", "point your subdomain at CloudFront");
    progress.field("type", "CNAME");
    progress.field("name", domain);
    progress.field("value", &dist_domain);

    // 5. New shares hand out https://<domain>/… from here on.
    let mut cfg = cfg;
    cfg.gate_url = Some(format!("https://{domain}"));
    Ok(cfg)
}

/// Request a DNS-validated ACM certificate in us-east-1; returns its ARN.
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"))
}

/// Poll describe-certificate until the DNS validation record is populated.
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")
}

/// Poll until the certificate is ISSUED (the operator adds the record meanwhile).
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`")
}

// ── pure helpers ──────────────────────────────────────────────────────────

/// The `(Name, Value)` of the cert's DNS validation CNAME, if present.
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.");
    }
}