diff --git a/README.md b/README.md index a03c9131..ac18dd98 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,6 @@ GATEWAY_CERT=${CERBOT_WORKDIR}/live/cert.pem GATEWAY_KEY=${CERBOT_WORKDIR}/live/key.pem # For certbot -CF_ZONE_ID=cc0a40... CF_API_TOKEN=g-DwMH... # ACME_URL=https://acme-v02.api.letsencrypt.org/directory ACME_URL=https://acme-staging-v02.api.letsencrypt.org/directory diff --git a/certbot/cli/src/main.rs b/certbot/cli/src/main.rs index 5822853f..b22d3246 100644 --- a/certbot/cli/src/main.rs +++ b/certbot/cli/src/main.rs @@ -62,8 +62,6 @@ struct Config { acme_url: String, /// Cloudflare API token cf_api_token: String, - /// Cloudflare zone ID - cf_zone_id: String, /// Auto set CAA record auto_set_caa: bool, /// List of domains to issue certificates for @@ -87,7 +85,6 @@ impl Default for Config { workdir: ".".into(), acme_url: "https://acme-staging-v02.api.letsencrypt.org/directory".into(), cf_api_token: "".into(), - cf_zone_id: "".into(), auto_set_caa: true, domains: vec!["example.com".into()], renew_interval: 3600, @@ -136,7 +133,6 @@ fn load_config(config: &PathBuf) -> Result { .key_file(workdir.key_path()) .auto_create_account(true) .cert_subject_alt_names(config.domains) - .cf_zone_id(config.cf_zone_id) .cf_api_token(config.cf_api_token) .renew_interval(renew_interval) .renew_timeout(renew_timeout) diff --git a/certbot/src/acme_client.rs b/certbot/src/acme_client.rs index 3650005e..d4ebcf51 100644 --- a/certbot/src/acme_client.rs +++ b/certbot/src/acme_client.rs @@ -321,14 +321,14 @@ impl AcmeClient { let Identifier::Dns(identifier) = &authz.identifier; let dns_value = order.key_authorization(challenge).dns_value(); - debug!("creating dns record for {}", identifier); + debug!("creating dns record for {identifier}"); let acme_domain = format!("_acme-challenge.{identifier}"); - debug!("removing existing dns record for {}", acme_domain); + debug!("removing existing TXT record for {acme_domain}"); self.dns01_client .remove_txt_records(&acme_domain) .await .context("failed to remove existing dns record")?; - debug!("creating dns record for {}", acme_domain); + debug!("creating TXT record for {acme_domain}"); let id = self .dns01_client .add_txt_record(&acme_domain, &dns_value) diff --git a/certbot/src/bot.rs b/certbot/src/bot.rs index 1906bdcd..5a9c775f 100644 --- a/certbot/src/bot.rs +++ b/certbot/src/bot.rs @@ -27,7 +27,6 @@ pub struct CertBotConfig { auto_set_caa: bool, credentials_file: PathBuf, auto_create_account: bool, - cf_zone_id: String, cf_api_token: String, cert_file: PathBuf, key_file: PathBuf, @@ -78,8 +77,16 @@ async fn create_new_account( impl CertBot { /// Build a new `CertBot` from a `CertBotConfig`. pub async fn build(config: CertBotConfig) -> Result { + let base_domain = config + .cert_subject_alt_names + .first() + .context("cert_subject_alt_names is empty")? + .trim() + .trim_start_matches("*.") + .trim_end_matches('.') + .to_string(); let dns01_client = - Dns01Client::new_cloudflare(config.cf_zone_id.clone(), config.cf_api_token.clone()); + Dns01Client::new_cloudflare(config.cf_api_token.clone(), base_domain).await?; let acme_client = match fs::read_to_string(&config.credentials_file) { Ok(credentials) => { if acme_matches(&credentials, &config.acme_url) { diff --git a/certbot/src/bot/tests.rs b/certbot/src/bot/tests.rs index 03e4ad9d..ce8b16f9 100644 --- a/certbot/src/bot/tests.rs +++ b/certbot/src/bot/tests.rs @@ -9,14 +9,12 @@ use instant_acme::LetsEncrypt; use super::*; async fn new_certbot() -> Result { - let cf_zone_id = std::env::var("CLOUDFLARE_ZONE_ID").expect("CLOUDFLARE_ZONE_ID not set"); let cf_api_token = std::env::var("CLOUDFLARE_API_TOKEN").expect("CLOUDFLARE_API_TOKEN not set"); let domains = vec![std::env::var("TEST_DOMAIN").expect("TEST_DOMAIN not set")]; let config = CertBotConfig::builder() .acme_url(LetsEncrypt::Staging.url()) .auto_create_account(true) .credentials_file("./test-workdir/credentials.json") - .cf_zone_id(cf_zone_id) .cf_api_token(cf_api_token) .cert_dir("./test-workdir/backup") .cert_file("./test-workdir/live/cert.pem") diff --git a/certbot/src/dns01_client.rs b/certbot/src/dns01_client.rs index 27b1bb6d..701d5ba9 100644 --- a/certbot/src/dns01_client.rs +++ b/certbot/src/dns01_client.rs @@ -6,6 +6,7 @@ use anyhow::Result; use cloudflare::CloudflareClient; use enum_dispatch::enum_dispatch; use serde::{Deserialize, Serialize}; +use tracing::debug; mod cloudflare; @@ -51,9 +52,11 @@ pub(crate) trait Dns01Api { /// Deletes all TXT DNS records matching the given domain. async fn remove_txt_records(&self, domain: &str) -> Result<()> { for record in self.get_records(domain).await? { - if record.r#type == "TXT" { - self.remove_record(&record.id).await?; + if record.r#type != "TXT" { + continue; } + debug!(domain = %domain, id = %record.id, "removing txt record"); + self.remove_record(&record.id).await?; } Ok(()) } @@ -68,7 +71,9 @@ pub enum Dns01Client { } impl Dns01Client { - pub fn new_cloudflare(zone_id: String, api_token: String) -> Self { - Self::Cloudflare(CloudflareClient::new(zone_id, api_token)) + pub async fn new_cloudflare(api_token: String, base_domain: String) -> Result { + Ok(Self::Cloudflare( + CloudflareClient::new(api_token, base_domain).await?, + )) } } diff --git a/certbot/src/dns01_client/cloudflare.rs b/certbot/src/dns01_client/cloudflare.rs index 408f181a..222028da 100644 --- a/certbot/src/dns01_client/cloudflare.rs +++ b/certbot/src/dns01_client/cloudflare.rs @@ -2,10 +2,13 @@ // // SPDX-License-Identifier: Apache-2.0 -use anyhow::{Context, Result}; +use std::collections::HashMap; + +use anyhow::{bail, Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; +use tracing::debug; use crate::dns01_client::Record; @@ -29,51 +32,241 @@ struct ApiResult { id: String, } +#[derive(Deserialize, Debug)] +struct CloudflareListResponse { + result: Vec, + result_info: ResultInfo, +} + +#[derive(Deserialize, Debug)] +struct ResultInfo { + total_pages: u32, +} + +#[derive(Deserialize, Debug)] +struct ZoneInfo { + id: String, + name: String, +} + +#[derive(Deserialize, Debug)] +struct ZonesResultInfo { + page: u32, + per_page: u32, + total_pages: u32, + count: u32, + total_count: u32, +} + impl CloudflareClient { - pub fn new(zone_id: String, api_token: String) -> Self { - Self { zone_id, api_token } + pub async fn new(api_token: String, base_domain: String) -> Result { + let zone_id = Self::resolve_zone_id(&api_token, &base_domain).await?; + Ok(Self { api_token, zone_id }) + } + + async fn resolve_zone_id(api_token: &str, base_domain: &str) -> Result { + let base = base_domain + .trim() + .trim_start_matches("*.") + .trim_end_matches('.') + .to_lowercase(); + + let client = Client::new(); + let url = format!("{CLOUDFLARE_API_URL}/zones"); + + let per_page = 50u32; + let mut page = 1u32; + let mut zones: HashMap = HashMap::new(); + let mut total_pages = 1u32; + + while page <= total_pages { + debug!(url = %url, base_domain = %base, page, per_page, "cloudflare list zones request"); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {api_token}")) + .query(&[ + ("page", page.to_string()), + ("per_page", per_page.to_string()), + ]) + .send() + .await + .context("failed to list zones")?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read zones response body")?; + if !status.is_success() { + bail!("failed to list zones: {body}"); + } + + #[derive(Deserialize, Debug)] + struct ZonesPageResponse { + result: Vec, + result_info: ZonesResultInfo, + } + + let zones_response: ZonesPageResponse = + serde_json::from_str(&body).context("failed to parse zones response")?; + + let zone_names = zones_response + .result + .iter() + .map(|z| z.name.as_str()) + .collect::>(); + debug!( + url = %url, + status = %status, + page = zones_response.result_info.page, + per_page = zones_response.result_info.per_page, + count = zones_response.result_info.count, + total_count = zones_response.result_info.total_count, + total_pages = zones_response.result_info.total_pages, + zones = ?zone_names, + "cloudflare list zones response" + ); + + total_pages = zones_response.result_info.total_pages; + for z in zones_response.result { + zones.insert(z.name.to_lowercase(), z.id); + } + + page += 1; + } + + let parts: Vec<&str> = base.split('.').collect(); + for i in 0..parts.len() { + let candidate = parts[i..].join("."); + if let Some(zone_id) = zones.get(&candidate) { + debug!(base_domain = %base, zone = %candidate, zone_id = %zone_id, "resolved cloudflare zone"); + return Ok(zone_id.clone()); + } + } + + bail!("no matching zone found for base_domain: {base_domain}") } async fn add_record(&self, record: &impl Serialize) -> Result { let client = Client::new(); - let url = format!("{}/zones/{}/dns_records", CLOUDFLARE_API_URL, self.zone_id); + let url = format!("{CLOUDFLARE_API_URL}/zones/{}/dns_records", self.zone_id); + let response = client .post(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .header("Content-Type", "application/json") - .json(&record) + .json(record) .send() .await .context("failed to send add_record request")?; - if !response.status().is_success() { - anyhow::bail!("failed to add record: {}", response.text().await?); + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read add_record response body")?; + if !status.is_success() { + anyhow::bail!("failed to add record: {body}"); } - let response = response.json().await.context("failed to parse response")?; + let response = serde_json::from_str(&body).context("failed to parse response")?; Ok(response) } -} -impl Dns01Api for CloudflareClient { - async fn remove_record(&self, record_id: &str) -> Result<()> { + async fn remove_record_inner(&self, record_id: &str) -> Result<()> { let client = Client::new(); let url = format!( - "{}/zones/{}/dns_records/{}", - CLOUDFLARE_API_URL, self.zone_id, record_id + "{CLOUDFLARE_API_URL}/zones/{zone_id}/dns_records/{record_id}", + zone_id = self.zone_id ); + debug!(url = %url, "cloudflare remove_record request"); + let response = client .delete(&url) .header("Authorization", format!("Bearer {}", self.api_token)) .send() .await?; - if !response.status().is_success() { - anyhow::bail!( - "failed to remove acme challenge: {}", - response.text().await? - ); + let status = response.status(); + let body = response + .text() + .await + .context("failed to read remove_record response body")?; + if !status.is_success() { + anyhow::bail!("failed to remove acme challenge: {body}"); } + Ok(()) + } + + async fn get_records_inner(&self, domain: &str) -> Result> { + let client = Client::new(); + let url = format!("{CLOUDFLARE_API_URL}/zones/{}/dns_records", self.zone_id); + + let per_page = 100u32; + let mut records = Vec::new(); + let target = domain.trim_end_matches('.'); + + for page in 1..20 { + // Safety limit to prevent infinite loops + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .query(&[ + ("name", domain), + ("page", &page.to_string()), + ("per_page", &per_page.to_string()), + ]) + .send() + .await?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read get_records response body")?; + + if !status.is_success() { + anyhow::bail!("failed to get dns records: {body}"); + } + + let response: CloudflareListResponse = + serde_json::from_str(&body).context("failed to parse response")?; + + records.extend(response.result.into_iter().filter(|record| { + record + .name + .trim_end_matches('.') + .eq_ignore_ascii_case(target) + })); + + if page >= response.result_info.total_pages { + break; + } + } + + Ok(records) + } +} + +impl Dns01Api for CloudflareClient { + async fn remove_record(&self, record_id: &str) -> Result<()> { + self.remove_record_inner(record_id).await + } + async fn remove_txt_records(&self, domain: &str) -> Result<()> { + let records = self.get_records_inner(domain).await?; + let txt_records = records + .into_iter() + .filter(|r| r.r#type == "TXT") + .collect::>(); + let ids = txt_records.iter().map(|r| r.id.clone()).collect::>(); + debug!(domain = %domain, zone_id = %self.zone_id, count = txt_records.len(), ids = ?ids, "removing txt records"); + + for record in txt_records { + debug!(domain = %domain, id = %record.id, "removing txt record"); + self.remove_record_inner(&record.id).await?; + } Ok(()) } @@ -110,33 +303,7 @@ impl Dns01Api for CloudflareClient { } async fn get_records(&self, domain: &str) -> Result> { - let client = Client::new(); - let url = format!("{}/zones/{}/dns_records", CLOUDFLARE_API_URL, self.zone_id); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", self.api_token)) - .send() - .await?; - - if !response.status().is_success() { - anyhow::bail!("failed to get dns records: {}", response.text().await?); - } - - #[derive(Deserialize, Debug)] - struct CloudflareResponse { - result: Vec, - } - - let response: CloudflareResponse = - response.json().await.context("failed to parse response")?; - - let records = response - .result - .into_iter() - .filter(|record| record.name == domain) - .collect(); - Ok(records) + self.get_records_inner(domain).await } } @@ -168,11 +335,13 @@ mod tests { } } - fn create_client() -> CloudflareClient { + async fn create_client() -> CloudflareClient { CloudflareClient::new( - std::env::var("CLOUDFLARE_ZONE_ID").expect("CLOUDFLARE_ZONE_ID not set"), std::env::var("CLOUDFLARE_API_TOKEN").expect("CLOUDFLARE_API_TOKEN not set"), + std::env::var("TEST_DOMAIN").expect("TEST_DOMAIN not set"), ) + .await + .unwrap() } fn random_subdomain() -> String { @@ -185,7 +354,7 @@ mod tests { #[tokio::test] async fn can_add_txt_record() { - let client = create_client(); + let client = create_client().await; let subdomain = random_subdomain(); println!("subdomain: {}", subdomain); let record_id = client @@ -202,7 +371,7 @@ mod tests { #[tokio::test] async fn can_remove_txt_record() { - let client = create_client(); + let client = create_client().await; let subdomain = random_subdomain(); println!("subdomain: {}", subdomain); let record_id = client @@ -219,7 +388,7 @@ mod tests { #[tokio::test] async fn can_add_caa_record() { - let client = create_client(); + let client = create_client().await; let subdomain = random_subdomain(); let record_id = client .add_caa_record(&subdomain, 0, "issue", "letsencrypt.org;") diff --git a/docs/deployment.md b/docs/deployment.md index c11e32f3..e8466b52 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -211,7 +211,6 @@ VMM_RPC=unix:../../vmm-data/vmm.sock # Cloudflare API token for DNS challenge used to get the SSL certificate. CF_API_TOKEN=your_cloudflare_api_token -CF_ZONE_ID=your_zone_id # Service domain SRV_DOMAIN=test2.dstack.phala.network diff --git a/docs/dstack-gateway.md b/docs/dstack-gateway.md index d42aa687..be1e2cf4 100644 --- a/docs/dstack-gateway.md +++ b/docs/dstack-gateway.md @@ -12,17 +12,10 @@ Set up a second-level wildcard domain using Cloudflare; make sure to disable pro You need to get a Cloudflare API Key and ensure the API can manage this domain. -You can check your Cloudflare API key and get `cf_zone_id` using this command: - -```shell -curl -X GET "https://api.cloudflare.com/client/v4/zones" -H "Authorization: Bearer " -H "Content-Type: application/json" | jq . -``` - Open your `certbot.toml`, and update these fields: - `acme_url`: change to `https://acme-v02.api.letsencrypt.org/directory` - `cf_api_token`: Obtain from Cloudflare -- `cf_zone_id`: Obtain from the API call above ## Step 3: Run Certbot Manually and Get First SSL Certificates diff --git a/gateway/dstack-app/builder/entrypoint.sh b/gateway/dstack-app/builder/entrypoint.sh index 862db9a1..cd25da1f 100755 --- a/gateway/dstack-app/builder/entrypoint.sh +++ b/gateway/dstack-app/builder/entrypoint.sh @@ -48,7 +48,6 @@ validate_env() { validate_env "$MY_URL" validate_env "$BOOTNODE_URL" validate_env "$CF_API_TOKEN" -validate_env "$CF_ZONE_ID" validate_env "$SRV_DOMAIN" validate_env "$WG_ENDPOINT" @@ -113,7 +112,6 @@ enabled = true workdir = "$CERTBOT_WORKDIR" acme_url = "$ACME_URL" cf_api_token = "$CF_API_TOKEN" -cf_zone_id = "$CF_ZONE_ID" auto_set_caa = true domain = "*.$SRV_DOMAIN" renew_interval = "1h" diff --git a/gateway/dstack-app/deploy-to-vmm.sh b/gateway/dstack-app/deploy-to-vmm.sh index 46925cfe..2584d450 100755 --- a/gateway/dstack-app/deploy-to-vmm.sh +++ b/gateway/dstack-app/deploy-to-vmm.sh @@ -47,9 +47,6 @@ else # Cloudflare API token for DNS challenge # CF_API_TOKEN=your_cloudflare_api_token -# Cloudflare Zone ID -# CF_ZONE_ID=your_zone_id - # Service domain # SRV_DOMAIN=test5.dstack.phala.network @@ -98,7 +95,6 @@ fi required_env_vars=( "VMM_RPC" "CF_API_TOKEN" - "CF_ZONE_ID" "SRV_DOMAIN" "PUBLIC_IP" "WG_ADDR" @@ -137,7 +133,6 @@ cat "$COMPOSE_TMP" # Update .env file with current values cat <.app_env CF_API_TOKEN=$CF_API_TOKEN -CF_ZONE_ID=$CF_ZONE_ID SRV_DOMAIN=$SRV_DOMAIN WG_ENDPOINT=$PUBLIC_IP:$WG_PORT MY_URL=$MY_URL diff --git a/gateway/dstack-app/docker-compose.yaml b/gateway/dstack-app/docker-compose.yaml index bebd4e97..6fdc1d8b 100644 --- a/gateway/dstack-app/docker-compose.yaml +++ b/gateway/dstack-app/docker-compose.yaml @@ -15,7 +15,6 @@ services: - WG_ENDPOINT=${WG_ENDPOINT} - SRV_DOMAIN=${SRV_DOMAIN} - CF_API_TOKEN=${CF_API_TOKEN} - - CF_ZONE_ID=${CF_ZONE_ID} - MY_URL=${MY_URL} - BOOTNODE_URL=${BOOTNODE_URL} - ACME_STAGING=${ACME_STAGING} diff --git a/gateway/gateway.toml b/gateway/gateway.toml index 4445a138..78446b0e 100644 --- a/gateway/gateway.toml +++ b/gateway/gateway.toml @@ -32,7 +32,6 @@ enabled = false workdir = "/etc/certbot" acme_url = "https://acme-staging-v02.api.letsencrypt.org/directory" cf_api_token = "" -cf_zone_id = "" auto_set_caa = true domain = "*.example.com" renew_interval = "1h" diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 03d2b125..4809aef8 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -193,8 +193,6 @@ pub struct CertbotConfig { pub acme_url: String, /// Cloudflare API token pub cf_api_token: String, - /// Cloudflare zone ID - pub cf_zone_id: String, /// Auto set CAA record pub auto_set_caa: bool, /// Domain to issue certificates for @@ -224,7 +222,6 @@ impl CertbotConfig { .credentials_file(workdir.account_credentials_path()) .acme_url(self.acme_url.clone()) .cert_subject_alt_names(vec![self.domain.clone()]) - .cf_zone_id(self.cf_zone_id.clone()) .cf_api_token(self.cf_api_token.clone()) .renew_interval(self.renew_interval) .renew_timeout(self.renew_timeout)