diff --git a/.gitignore b/.gitignore index a8e6166..fddddc8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +certs/ +test_cert_config.json acme_dir/ .idea/ __temp_dir/ diff --git a/src/ali_dns.rs b/src/ali_dns.rs index 79f4ed6..e921194 100644 --- a/src/ali_dns.rs +++ b/src/ali_dns.rs @@ -1,11 +1,40 @@ +use serde::{Deserialize, Serialize}; use aliyun_openapi_core_rust_sdk::RPClient; use rust_util::XResult; static ALI_DNS_ENDPOINT: &str = "https://alidns.aliyuncs.com"; static ALI_DNS_API_VERSION: &str = "2015-01-09"; +#[derive(Debug)] +pub struct AccessCredential { + access_key_id: String, + access_key_secret: String, +} + +// syntax: account://***:***@alibabacloud?id=dns +pub fn simple_parse_aliyun_supplier(supplier: &str) -> XResult { + if !supplier.starts_with("account://") { + return simple_error!("Supplier syntax error: {}", supplier); + } + let access_key_id_and_secret: String = supplier.chars().skip("account://".len()).take_while(|c| *c != '@').collect(); + let c_pos = opt_value_result!(access_key_id_and_secret.find(":"), "Supplier syntax error: {}", supplier); + + let access_key_id = access_key_id_and_secret.chars().take(c_pos).collect(); + let access_key_secret = access_key_id_and_secret.chars().skip(c_pos + 1).collect(); + + Ok(AccessCredential { access_key_id, access_key_secret }) +} + #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ListDnsResponseErrorResponse { +pub struct CommonSuccessResponse { + #[serde(rename = "RequestId")] + pub request_id: String, + #[serde(rename = "RecordId")] + pub record_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommonErrorResponse { #[serde(rename = "RequestId")] pub request_id: String, #[serde(rename = "Message")] @@ -29,7 +58,13 @@ pub struct ListDnsResponse { #[serde(rename = "PageNumber")] pub page_number: i32, #[serde(rename = "DomainRecords")] - pub domain_records: Vec, + pub domain_records: DnsRecords, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DnsRecords { + #[serde(rename = "Record")] + pub record: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -53,106 +88,69 @@ pub struct DnsRecord { #[serde(rename = "TTL")] pub ttl: i32, #[serde(rename = "Weight")] - pub weight: i32, + pub weight: Option, } -pub struct Response { - #[serde(rename = "RequestId")] - pub request_id: String, - #[serde(rename = "RecordId")] - pub record_id: String, -} - - -#[derive(Debug)] -pub struct AccessCredential { - access_key_id: String, - access_key_secret: String, -} - -#[test] -fn test() { - let a = AccessCredential { - access_key_id: "***".to_string(), - access_key_secret: "***".to_string(), - }; - let client = create_dns_client(&a); - println!("{}", list_dns(&client, "webauthn1.host").unwrap()); - // println!("{}", add_dns_txt(&client, "webauthn.host").unwrap()); - // println!("{}", delete_dns(&client, "744459160027659264").unwrap()); -} - -// { -// "TotalCount": 2, -// "RequestId": "8993B447-F1FF-58ED-9D4B-1027B551DC5E", -// "PageSize": 20, -// "DomainRecords": { -// "Record": [ -// { -// "RR": "www", -// "Line": "default", -// "Status": "ENABLE", -// "Locked": false, -// "Type": "A", -// "DomainName": "webauthn.host", -// "Value": "47.52.7.223", -// "RecordId": "714019124998091776", -// "TTL": 1800, -// "Weight": 1 -// }, -// { -// "RR": "@", -// "Line": "default", -// "Status": "ENABLE", -// "Locked": false, -// "Type": "A", -// "DomainName": "webauthn.host", -// "Value": "47.52.7.223", -// "RecordId": "714019101941960704", -// "TTL": 1800, -// "Weight": 1 -// } -// ] -// }, -// "PageNumber": 1 -// } -pub fn list_dns(client: &RPClient, domain: &str) -> XResult { - Ok(client.get("DescribeDomainRecords") +pub fn list_dns(client: &RPClient, domain: &str) -> XResult> { + let describe_domain_records_response = opt_result!(client.get("DescribeDomainRecords") .query(&[ ("RegionId", "cn-hangzhou"), ("DomainName", domain) ]) - .send()?) + .send(), "List domain records: {}, failed: {}", domain); + parse_result("DescribeDomainRecords", &describe_domain_records_response) } -// {"RequestId":"AD997158-68D2-5084-B6B9-5F5A0893DDC1","RecordId":"744459160027659264"} -pub fn delete_dns(client: &RPClient, record_id: &str) -> XResult { - Ok(client.get("DeleteDomainRecord") +pub fn delete_dns_record(client: &RPClient, record_id: &str) -> XResult> { + let delete_domain_record_response = opt_result!(client.get("DeleteDomainRecord") .query(&[ ("RegionId", "cn-hangzhou"), ("RecordId", record_id) ]) - .send()?) + .send(), "Delete domain record id: {}, failed: {}", record_id); + parse_result("DeleteDomainRecord", &delete_domain_record_response) } -// {"RequestId":"F3D54AB2-7058-54FD-AAF3-566FB8EC9BD1","RecordId":"744459160027659264"} -pub fn add_dns_txt(client: &RPClient, domain: &str) -> XResult { - Ok(client.get("AddDomainRecord") +pub fn add_txt_dns_record(client: &RPClient, domain: &str, rr: &str, value: &str) -> XResult> { + add_dns_record(client, domain, rr, "TXT", value) +} + +// domain -> "example.com" +// rr -> "@", "_acme-challenge" +// t -> "TXT" +// value -> "test" +pub fn add_dns_record(client: &RPClient, domain: &str, rr: &str, t: &str, value: &str) -> XResult> { + let add_domain_record_response = opt_result!(client.get("AddDomainRecord") .query(&[ ("RegionId", "cn-hangzhou"), ("DomainName", domain), - ("RR", "_acme-challenge_test"), - ("Type", "TXT"), - ("Value", "test") + ("RR", rr), + ("Type", t), + ("Value", value) ]) - .send()?) + .send(), "Add domain record: {}.{} -> {} {} ,failed: {}", rr, domain, t, value); + parse_result("AddDomainRecord", &add_domain_record_response) } -fn create_dns_client(access_credential: &AccessCredential) -> RPClient { +pub fn build_dns_client(access_credential: &AccessCredential) -> RPClient { RPClient::new( access_credential.access_key_id.clone(), access_credential.access_key_secret.clone(), String::from(ALI_DNS_ENDPOINT), String::from(ALI_DNS_API_VERSION), ) -} \ No newline at end of file +} + +fn parse_result<'a, S, E>(fn_name: &str, response: &'a str) -> XResult> where S: Deserialize<'a>, E: Deserialize<'a> { + let describe_domain_records_result: serde_json::Result = serde_json::from_str(&response); + match describe_domain_records_result { + Ok(r) => Ok(Ok(r)), + Err(_) => { + let describe_domain_records_error_result: serde_json::Result = serde_json::from_str(&response); + match describe_domain_records_error_result { + Ok(r) => Ok(Err(r)), + Err(_) => simple_error!("Parse {} response failed: {}", fn_name, response), + } + } + } +} diff --git a/src/config.rs b/src/config.rs index 0387ba8..acc304b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use serde::{Deserialize, Serialize}; use rust_util::XResult; use std::fs; @@ -11,6 +12,18 @@ use std::time::SystemTime; pub const CERT_NAME: &str = "cert.pem"; pub const KEY_NAME: &str = "key.pem"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcmeChallenge { + Http, + Dns, +} + +impl Default for AcmeChallenge { + fn default() -> Self { + AcmeChallenge::Http + } +} + #[derive(Debug, Clone, Copy)] pub enum AcmeMode { Prod, @@ -43,6 +56,9 @@ impl AcmeMode { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CertConfigItem { + // HTTP, DNS + pub r#type: Option, + pub supplier: Option, pub path: String, pub algo: Option, pub public_key_algo: Option, @@ -54,6 +70,7 @@ pub struct CertConfigItem { #[serde(rename_all = "camelCase")] pub struct CertConfig { pub port: Option, + pub credential_suppliers: Option>, pub cert_items: Vec, pub trigger_after_update: Option>, pub notify_token: Option, @@ -102,6 +119,7 @@ impl CertConfig { Self { port: self.port, + credential_suppliers: self.credential_suppliers, cert_items: filtered_cert_items, trigger_after_update: self.trigger_after_update, notify_token: self.notify_token, @@ -116,6 +134,15 @@ impl CertConfig { } impl CertConfigItem { + pub fn get_acme_challenge(&self) -> AcmeChallenge { + let t = self.r#type.as_ref().map(|t| t.to_ascii_lowercase()).unwrap_or_else(|| "http".to_string()); + if t == "dns" { + AcmeChallenge::Dns + } else { + AcmeChallenge::Http + } + } + pub fn fill_dns_names(&mut self) -> XResult> { if self.path.is_empty() { return simple_error!("Cert config item path is empty"); diff --git a/src/main.rs b/src/main.rs index ddfebcb..fe6687c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ extern crate lazy_static; #[macro_use] extern crate rust_util; +mod util; mod config; mod x509; mod network; @@ -28,14 +29,16 @@ use async_std::task; use async_std::channel; use async_std::channel::Sender; use config::AcmeMode; -use crate::config::{CertConfig, CERT_NAME, KEY_NAME}; +use crate::config::{AcmeChallenge, CertConfig, CERT_NAME, KEY_NAME}; use crate::x509::{X509PublicKeyAlgo, X509EcPublicKeyAlgo}; use std::path::PathBuf; +use aliyun_openapi_core_rust_sdk::RPClient; use rust_util::util_cmd::run_command_and_wait; -use crate::AcmeChallenge::Http; +use crate::ali_dns::{add_txt_dns_record, build_dns_client, delete_dns_record, list_dns, simple_parse_aliyun_supplier}; use crate::dingtalk::send_dingtalk_message; use crate::network::{get_local_public_ip, get_resolver, resolve_first_ipv4}; use crate::statics::{AcmeStatics, AcmeStatus}; +use crate::util::parse_dns_record; const NAME: &str = env!("CARGO_PKG_NAME"); const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -46,21 +49,12 @@ lazy_static! { static ref TOKEN_MAP: RwLock> = RwLock::new(BTreeMap::new()); } -#[derive(Debug, Clone, Copy)] -enum AcmeChallenge { - Http, - Dns, -} - -impl Default for AcmeChallenge { - fn default() -> Self { - Http - } -} - #[derive(Debug, Default)] struct AcmeRequest<'a> { challenge: AcmeChallenge, + // issue, single acme request can only process one supplier + credential_supplier: Option<&'a str>, + allow_interact: bool, contract_email: &'a str, primary_name: &'a str, alt_names: &'a [&'a str], @@ -97,6 +91,7 @@ async fn main() -> tide::Result<()> { .arg(Arg::with_name("hide-logo").long("hide-logo").help("Hide logo")) .arg(Arg::with_name("skip-verify-ip").short("k").long("skip-verify-ip").help("Skip verify public ip")) .arg(Arg::with_name("skip-verify-certificate").short("K").long("skip-verify-certificate").help("Skip verify certificate")) + .arg(Arg::with_name("allow-interact").long("allow-interact").help("Allow interact")) .get_matches(); if matches.is_present("verbose") { @@ -227,6 +222,7 @@ async fn main() -> tide::Result<()> { task::sleep(Duration::from_millis(500)).await; } + let mut dns_cleaned_domains: Vec = vec![]; match cert_config { None => { // cert config is not assigned if check { @@ -253,7 +249,9 @@ async fn main() -> tide::Result<()> { }; let acme_request = AcmeRequest { - challenge: Http, + challenge: AcmeChallenge::Http, + credential_supplier: None, + allow_interact: false, contract_email: &email, primary_name, alt_names: &alt_names, @@ -267,7 +265,7 @@ async fn main() -> tide::Result<()> { outputs_file: matches.value_of("outputs").map(|s| s.into()), ..Default::default() }; - if let Err(e) = request_acme_certificate(acme_request) { + if let Err(e) = request_acme_certificate(acme_request, &mut dns_cleaned_domains) { failure!("Request certificate by acme failed: {}", e); exit(1); } @@ -282,14 +280,35 @@ async fn main() -> tide::Result<()> { for item in &filtered_cert_config.cert_items { if item.common_name.as_ref().map(|n| n.contains('*')).unwrap_or(false) || item.dns_names.as_ref().map(|dns_names| dns_names.iter().any(|n| n.contains('*'))).unwrap_or(false) { - warning!("Currently not support wide card domain name"); - continue; + if item.get_acme_challenge() != AcmeChallenge::Dns { + warning!("Currently not support wide card domain name"); + continue; + } } if let (Some(common_name), Some(dns_names)) = (&item.common_name, &item.dns_names) { information!("Domains, main: {}, alt: {:?}", common_name, dns_names); let alt_names: Vec<&str> = dns_names.iter().map(|n| n.as_str()).collect(); + let challenge = item.get_acme_challenge(); + let credential_supplier = if challenge == AcmeChallenge::Dns { + match &item.supplier { + None => None, + Some(supplier) => { + let credential_supplier = filtered_cert_config.credential_suppliers.as_ref() + .map(|m| m.get(supplier)).flatten(); + match credential_supplier { + None => { + warning!("DNS challenge no credential supplier found"); + None + } + Some(credential_supplier) => Some(credential_supplier.as_str()), + } + } + } + } else { None }; let acme_request = AcmeRequest { - challenge: Http, + challenge, + credential_supplier, + allow_interact: matches.is_present("allow-interact"), contract_email: &email, primary_name: common_name, alt_names: &alt_names, @@ -304,7 +323,7 @@ async fn main() -> tide::Result<()> { }; let mut domains = vec![common_name.clone()]; dns_names.iter().for_each(|dns_name| domains.push(dns_name.clone())); - if let Err(e) = request_acme_certificate(acme_request) { + if let Err(e) = request_acme_certificate(acme_request, &mut dns_cleaned_domains) { failure!("Request certificate: {}, by acme failed: {}", item.path, e); acme_statics.add_item(domains, AcmeStatus::Fail(format!("{}", e))); } else { @@ -411,7 +430,7 @@ fn check_cert_config(cert_config: &CertConfig) { } } -fn request_acme_certificate(acme_request: AcmeRequest) -> XResult<()> { +fn request_acme_certificate(acme_request: AcmeRequest, dns_cleaned_domains: &mut Vec) -> XResult<()> { if let Some(local_public_ip) = acme_request.local_public_ip { let mut all_domains = vec![acme_request.primary_name.to_string()]; for alt_name in acme_request.alt_names { @@ -420,13 +439,15 @@ fn request_acme_certificate(acme_request: AcmeRequest) -> XResult<()> { information!("Checking domain dns records, domains: {:?}", all_domains); let resolver = opt_result!(get_resolver(), "Get resolver failed: {}"); - for domain in &all_domains { - debugging!("Checking domain: {}", domain); - let ipv4 = opt_result!(resolve_first_ipv4(&resolver, domain), "{}"); - match ipv4 { - None => return simple_error!("Resolve domain ip failed: {}", domain), - Some(ipv4) => if local_public_ip != ipv4 { - return simple_error!("Check domain ip: {}, mis-match, local: {} vs domain: {}", domain, local_public_ip, ipv4); + if acme_request.challenge == AcmeChallenge::Http { + for domain in &all_domains { + debugging!("Checking domain: {}", domain); + let ipv4 = opt_result!(resolve_first_ipv4(&resolver, domain), "{}"); + match ipv4 { + None => return simple_error!("Resolve domain ip failed: {}", domain), + Some(ipv4) => if local_public_ip != ipv4 { + return simple_error!("Check domain ip: {}, mis-match, local: {} vs domain: {}", domain, local_public_ip, ipv4); + } } } } @@ -438,6 +459,11 @@ fn request_acme_certificate(acme_request: AcmeRequest) -> XResult<()> { let dir = opt_result!(Directory::from_url(persist, url), "Create directory from url failed: {}"); let acc = opt_result!(dir.account(acme_request.contract_email), "Directory set account failed: {}"); let mut ord_new = opt_result!( acc.new_order(acme_request.primary_name, acme_request.alt_names), "Create order failed: {}"); + let ali_yun_client: Option = match acme_request.credential_supplier { + Some(credential_supplier) => Some(build_dns_client( + &opt_result!(simple_parse_aliyun_supplier(credential_supplier), "Parse credential supplier failed: {}"))), + None => None, + }; let mut order_csr_index = 0; let ord_csr = loop { @@ -453,7 +479,7 @@ fn request_acme_certificate(acme_request: AcmeRequest) -> XResult<()> { let auths = opt_result!(ord_new.authorizations(), "Order auth failed: {}"); for auth in &auths { match acme_request.challenge { - Http => { + AcmeChallenge::Http => { let chall = auth.http_challenge(); let token = chall.http_token(); let proof = chall.http_proof(); @@ -465,11 +491,53 @@ fn request_acme_certificate(acme_request: AcmeRequest) -> XResult<()> { debugging!("Valid acme certificate http challenge"); opt_result!(chall.validate(acme_request.timeout), "Validate http challenge failed: {}"); } - Dns => { + AcmeChallenge::Dns => { let chall = auth.dns_challenge(); let record = format!("_acme-challenge.{}.", auth.domain_name()); let proof = chall.dns_proof(); - information!("Add acme dns challenge: {} -> {}",record, proof); + information!("Add acme dns challenge: {} -> {}", record, proof); + + let rr_and_domain = opt_result!(parse_dns_record(&record), "Parse record to rr&domain failed: {}"); + + if !dns_cleaned_domains.contains(&rr_and_domain.1) { + information!("Clearing domain: {}", &rr_and_domain.1); + dns_cleaned_domains.push(rr_and_domain.1.clone()); + ali_yun_client.as_ref().map(|client| { + match list_dns(client, &rr_and_domain.1) { + Err(e) => warning!("List dns for: {}, failed: {}", &rr_and_domain.1, e), + Ok(Err(e)) => warning!("List dns for: {}, failed: {:?}", &rr_and_domain.1, e), + Ok(Ok(s)) => { + for r in &s.domain_records.record { + let rr = &r.rr; + if rr == "_acme-challenge" || rr.starts_with("_acme-challenge.") { + match delete_dns_record(client, &r.record_id) { + Err(e) => warning!("Delete dns: {}.{}, failed: {}", r.rr, r.domain_name, e), + Ok(Err(e)) => warning!("Delete dns: {}.{}, failed: {:?}", r.rr, r.domain_name, e), + Ok(Ok(_)) => success!("Delete dns: {}.{}", r.rr, r.domain_name), + } + } + } + } + } + }); + } + + match &ali_yun_client { + Some(client) => { + let add_txt_dns_result = opt_result!(add_txt_dns_record(client, &rr_and_domain.1, &rr_and_domain.0, &proof), "Add DNS TXT record failed: {}"); + match add_txt_dns_result { + Ok(s) => success!("Add dns txt record successes: {}", s.record_id), + Err(e) => return simple_error!("Add dns txt record failed: {:?}", e), + } + } + None => if acme_request.allow_interact { + let mut line = String::new(); + information!("You need to config dns manually, press enter to continue..."); + let _ = std::io::stdin().read_line(&mut line).unwrap(); + } else { + return simple_error!("Interact is not allowed, --allow-interact to allow interact"); + } + } debugging!("Valid acme certificate dns challenge"); opt_result!(chall.validate(acme_request.timeout), "Validate dns challenge failed: {}"); diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..590ef88 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,35 @@ +use rust_util::XResult; + +pub fn parse_dns_record(record: &str) -> XResult<(String, String)> { + let r = if record.ends_with(".") { + record.chars().take(record.len() - 1).collect::().to_ascii_lowercase() + } else { + record.to_ascii_lowercase() + }; + + let parts: Vec<&str> = r.split(".").collect(); + if parts.len() < 2 { + return simple_error!("Invalid record : {}", record); + } + + let last_part = parts[parts.len() - 1]; + let last_part_2 = parts[parts.len() - 2]; + + // SHOULD read from: https://publicsuffix.org/ + let domain_parts_len = match last_part { + "cn" => match last_part_2 { + "com" | "net" | "org" | "gov" => 3, + _ => 2, + }, + _ => 2, + }; + + if parts.len() < domain_parts_len { + return simple_error!("Invalid record: {}", record); + } + + let domain = parts.iter().skip(parts.len() - domain_parts_len).map(|s| s.to_string()).collect::>().join("."); + let rr = parts.iter().take(parts.len() - domain_parts_len).map(|s| s.to_string()).collect::>().join("."); + + Ok((if rr.is_empty() { "@".to_string() } else { rr }, domain)) +} \ No newline at end of file