#[macro_use] extern crate lazy_static; #[macro_use] extern crate rust_util; mod acme; mod util; mod config; mod x509; mod network; mod statics; mod dingtalk; mod dns; mod ali_dns; // mod simple_thread_pool; use std::fs; use std::env; use std::path::PathBuf; use std::process::{Command, exit}; use std::time::{Duration, SystemTime}; use std::str::FromStr; use tide::Request; use clap::{App, Arg}; use async_std::{task, channel, channel::Sender}; use rust_util::util_cmd::run_command_and_wait; use crate::config::{AcmeMode, AcmeChallenge, CertConfig, CERT_NAME, KEY_NAME}; use crate::x509::{X509PublicKeyAlgo}; use crate::dingtalk::send_dingtalk_message; use crate::statics::{AcmeStatics, AcmeStatus}; use crate::acme::{AcmeRequest, request_acme_certificate}; use crate::network::get_local_public_ip; const NAME: &str = env!("CARGO_PKG_NAME"); const VERSION: &str = env!("CARGO_PKG_VERSION"); const AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); #[async_std::main] async fn main() -> tide::Result<()> { let matches = App::new(NAME) .version(VERSION) .about(DESCRIPTION) .author(AUTHORS) .arg(Arg::with_name("version").short("V").long("version").help("Print version")) .arg(Arg::with_name("verbose").short("v").long("verbose").help("Verbose")) .arg(Arg::with_name("port").short("p").long("port").default_value("80").takes_value(true).help("Http port")) .arg(Arg::with_name("domain").short("d").long("domain").multiple(true).takes_value(true).help("Domains")) .arg(Arg::with_name("email").long("email").takes_value(true).help("Contract email")) .arg(Arg::with_name("algo").short("a").long("algo").takes_value(true).default_value("ec384").help("Pki algo")) .arg(Arg::with_name("timeout").long("timeout").takes_value(true).default_value("5000").help("Timeout (ms)")) .arg(Arg::with_name("mode").short("m").long("mode").takes_value(true).default_value("prod").help("Mode")) .arg(Arg::with_name("directory-url").long("directory-url").takes_value(true).help("ACME directory URL")) .arg(Arg::with_name("dir").long("dir").takes_value(true).default_value("acme_dir").help("Account key dir")) .arg(Arg::with_name("cert-dir").long("cert-dir").takes_value(true).help("Certificate dir")) .arg(Arg::with_name("config").short("c").long("config").takes_value(true).help("Cert config")) .arg(Arg::with_name("outputs").short("o").long("outputs").takes_value(true).help("Outputs file")) .arg(Arg::with_name("check").long("check").help("Check cert config")) .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("skip-listen").long("skip-listen").help("Skip http challenge listen")) .arg(Arg::with_name("allow-interact").long("allow-interact").help("Allow interact")) .arg(Arg::with_name("challenge-type").short("t").long("challenge-type").takes_value(true).default_value("http").help("Challenge type, http or dns")) .arg(Arg::with_name("dns-supplier").short("s").long("dns-supplier").takes_value(true).help("DNS supplier, e.g. account://***:****@**?id=*")) .get_matches(); if matches.is_present("verbose") { env::set_var("LOGGER_LEVEL", "*"); } if matches.is_present("version") { println!("{}", include_str!("logo.txt")); information!("{} v{}", NAME, VERSION); exit(1); } if !matches.is_present("hide-logo") { println!("{}", include_str!("logo.txt")); } let skip_verify_ip = matches.is_present("skip-verify-ip"); let local_public_ip = if skip_verify_ip { None } else { let skip_verify_certificate = matches.is_present("skip-verify-certificate"); Some(get_local_public_ip(skip_verify_certificate).unwrap_or_else(|e| { failure!("Get local public ip failed: {}, you can turn off verify IP by -k or --skip-verify-ip", e); exit(1); })) }; debugging!("Clap matches: {:?}", matches); let account_dir = matches.value_of("dir").unwrap_or("acme_dir"); information!("Acme dir: {}", account_dir); fs::create_dir_all(account_dir).ok(); let mut account_email = PathBuf::from(account_dir); account_email.push("account_email.conf"); let email = if account_email.exists() { match fs::read_to_string(&account_email) { Err(e) => { failure!("Read from file: {:?}, failed: {}", account_email, e); exit(1); } Ok(email) => { if let Some(email_from_args) = matches.value_of("email") { if email != email_from_args { warning!("Get email from account config: {}", email); } } email.trim().to_string() } } } else { let email = matches.value_of("email").unwrap_or_else(|| { failure!("Email is not assigned."); exit(1); }); information!("Write email to account config: {:?}", account_email); if let Err(e) = fs::write(&account_email, email) { warning!("Write email to account config: {:?}, failed: {}", account_email, e); } email.to_string() }; let port: u16 = match matches.value_of("port") { Some(p) => p.parse().unwrap_or_else(|e| { failure!("Parse port: {}, failed: {}", p, e); exit(1); }), None => { failure!("Port is not assigned."); exit(1); } }; let timeout: u64 = match matches.value_of("timeout") { Some(p) => p.parse().unwrap_or_else(|e| { failure!("Parse timeout: {}, failed: {}", p, e); exit(1); }), None => { failure!("Timeout is not assigned."); exit(1); } }; let algo = match matches.value_of("algo") { Some(a) => X509PublicKeyAlgo::from_str(a).unwrap_or_else(|e| { failure!("{}", e); exit(1); }), _ => { failure!("Algo is not assigned, should be: ec256, ec384, rsa2048, rsa3073 or rsa4096."); exit(1); } }; let mode = match matches.value_of("mode") { Some(m) => AcmeMode::parse(m).unwrap_or_else(|e| { failure!("{}", e); exit(1); }), _ => { failure!("AcmeMode is not assigned, should be: prod or test"); exit(1); } }; let cert_config_file = matches.value_of("config"); let cert_config = cert_config_file.map(|f| CertConfig::load(f).unwrap_or_else(|e| { failure!("Load cert config: {}, failed: {}", f, e); exit(1); })); let skip_listen = matches.is_present("skip-listen"); let port = iff!(skip_listen, 0, cert_config.as_ref().map(|c| c.port).flatten().unwrap_or(port)); let check_config = matches.is_present("check"); if !check_config && port > 0 { let (s, r) = channel::bounded(1); startup_http_server(s, port); r.recv().await.ok(); 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_config { failure!("Bad argument `--check`"); exit(1); } let domains_val = matches.values_of("domain").unwrap_or_else(|| { failure!("Domains is not assigned."); exit(1); }); let domains: Vec<&str> = domains_val.collect::>(); let primary_name = domains[0]; let alt_names: Vec<&str> = domains.into_iter().skip(1).collect(); information!("Domains, main: {}, alt: {:?}", primary_name, alt_names); let (cert_file, key_file) = match matches.value_of("cert-dir") { None => (None, None), Some(cert_dir) => { information!("Certificate output dir: {}", cert_dir); fs::create_dir_all(cert_dir).ok(); (Some(format!("{}/{}", cert_dir, CERT_NAME)), Some(format!("{}/{}", cert_dir, KEY_NAME))) } }; let acme_request = AcmeRequest { challenge: AcmeChallenge::from_str(matches.value_of("challenge-type")), credential_supplier: matches.value_of("dns-supplier"), allow_interact: matches.is_present("allow-interact"), contract_email: &email, primary_name, alt_names: &alt_names, algo, mode, directory_url: matches.value_of("directory-url").map(|u| u.to_string()), account_dir, timeout, local_public_ip: local_public_ip.as_deref(), cert_file, key_file, outputs_file: matches.value_of("outputs").map(|s| s.into()), ..Default::default() }; if let Err(e) = request_acme_certificate(acme_request, &mut dns_cleaned_domains) { failure!("Request certificate by acme failed: {}", e); exit(1); } } Some(cert_config) => { // cert config is assigned if check_config { check_cert_config(&cert_config); return Ok(()); } let mut acme_statics = AcmeStatics::start(); let filtered_cert_config = cert_config.filter_cert_config_items(30); 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) { 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, credential_supplier, allow_interact: matches.is_present("allow-interact"), contract_email: &email, primary_name: common_name, alt_names: &alt_names, algo, mode, directory_url: matches.value_of("directory-url").map(|u| u.to_string()).or(filtered_cert_config.directory_url.clone()), account_dir, timeout, local_public_ip: local_public_ip.as_deref(), cert_file: Some(format!("{}/{}", item.path, CERT_NAME)), key_file: Some(format!("{}/{}", item.path, KEY_NAME)), outputs_file: None, }; 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, &mut dns_cleaned_domains) { failure!("Request certificate: {}, by acme failed: {}", item.path, e); acme_statics.add_item(domains, AcmeStatus::Fail(format!("{}", e))); } else { acme_statics.add_item(domains, AcmeStatus::Success); } } } acme_statics.end(); let mut success_count = 0; for acme_static in &acme_statics.items { if let AcmeStatus::Success = acme_static.status { success_count += 1; } } success!("Statics: \n{}", acme_statics); let mut dingtalk_message = format!("Statics: \n{}", acme_statics); if success_count > 0 { if let Some(trigger_after_update) = &filtered_cert_config.trigger_after_update { if trigger_after_update.len() > 0 { let mut cmd = Command::new(&trigger_after_update[0]); for i in 1..trigger_after_update.len() { cmd.arg(&trigger_after_update[i]); } match run_command_and_wait(&mut cmd) { Ok(_) => { success!("Restart nginx success"); dingtalk_message.push_str(&format!("\n\ntrigger after update success: {:?}", cmd)); } Err(err) => { failure!("Restart nginx failed: {:?}", err); dingtalk_message.push_str(&format!("\n\ntrigger after update failed: {:?}, message: {:?}", cmd, err)); } } } else { warning!("No trigger after update is configured but is empty"); } } else { warning!("No trigger after update configured"); } } let mut success_domains = vec![]; let mut failed_domains = vec![]; for acme_item in &acme_statics.items { if let AcmeStatus::Success = acme_item.status { success_domains.push(format!("* {}", acme_item.domains.join(", "))); } if let AcmeStatus::Fail(_) = acme_item.status { failed_domains.push(format!("* {}", acme_item.domains.join(", "))); } } if !success_domains.is_empty() { dingtalk_message.push_str("\nsuccess domains:\n"); dingtalk_message.push_str(&success_domains.join("\n")); } if !failed_domains.is_empty() { dingtalk_message.push_str("\nfailed domains:\n"); dingtalk_message.push_str(&failed_domains.join("\n")); } if !acme_statics.items.is_empty() && filtered_cert_config.notify_token.is_some() { if let Err(err) = send_dingtalk_message(&filtered_cert_config, &dingtalk_message) { failure!("Send notification message failed: {:?}", err); } } else { information!("No notification message need to send, or not configed notification token"); } } } Ok(()) } fn check_cert_config(cert_config: &CertConfig) { let secs_from_unix_epoch = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs() as i64; let item_count = cert_config.cert_items.len(); for (i, item) in cert_config.cert_items.iter().enumerate() { information!("Checking: {}, item {} of {}", item.path, i, item_count); let cert_fn = format!("{}/{}", item.path, CERT_NAME); let pem = match fs::read_to_string(&cert_fn) { Ok(pem) => pem, Err(e) => { warning!("Read file: {}, failed: {}", cert_fn, e); continue; } }; let x509_certificate = match x509::parse_x509(&cert_fn, &pem) { Ok(cert) => cert, Err(e) => { failure!("Parse x509 file: {}, failed: {}", cert_fn, e); continue; } }; success!("Found certificate: common name: {}, dns names: {:?}, public key algo: {:?}, valid days: {}", x509_certificate.common_name, x509_certificate.alt_names, x509_certificate.public_key_algo, (x509_certificate.certificate_not_after - secs_from_unix_epoch) / (24 * 3600) ); } } fn startup_http_server(s: Sender, port: u16) { task::spawn(async move { information!("Listen at 0.0.0.0:{}", port); let mut app = tide::new(); app.at("/").get(|_req: Request<()>| async move { information!("Request / received"); Ok("acme-client\n") }); app.at("/.well-known/acme-challenge/:token").get(|req: Request<()>| async move { let token = match req.param("token") { Ok(token) => token, Err(e) => { warning!("Cannot get token from url, query: {:?}, error: {}", req.url().query(), e); return Ok("400 - bad request\n".to_string()); } }; let peer = req.peer_addr().unwrap_or("none"); let auth_token = { crate::acme::TOKEN_MAP.read().unwrap().get(token).cloned() }; match auth_token { Some(auth_token) => { information!("Request acme challenge: {} -> {}, peer: {:?}", token, auth_token, peer); Ok(auth_token) } None => { warning!("Request acme challenge not found: {}, peer: {:?}", token, peer); Ok("404 - not found\n".to_string()) } } }); app.at("/*").get(|req: Request<()>| async move { warning!("Request /* received: {}", req.url()); Ok("acme-client *\n") }); s.send(1).await.ok(); if let Err(e) = app.listen(&format!("0.0.0.0:{}", port)).await { failure!("Failed to listen 0.0.0.0:{}, program will exit, error: {}", port, e); exit(1); } }); }