diff --git a/Cargo.lock b/Cargo.lock index 6b0fabe..eee5635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,7 +350,7 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "le-easy-certs" -version = "0.1.0" +version = "0.2.0" dependencies = [ "acme2", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 66efaa0..64125df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "le-easy-certs" -version = "0.1.0" +version = "0.2.0" edition = "2021" [profile.release] diff --git a/src/config.rs b/src/config.rs index 8f641ce..727ea40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,15 @@ use serde_derive::Deserialize; use std::collections::{HashMap, HashSet}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct Config { pub http: ConfigHttp, pub certs: HashMap, + pub haproxy: Option, } /// Config for the http server used to serve the challenges -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct ConfigHttp { /// Binding IP address pub ip: String, @@ -16,8 +17,19 @@ pub struct ConfigHttp { pub port: u16, } +/// Config for a haproxy runtime api endpoint +#[derive(Debug, Clone, Deserialize)] +pub struct ConfigHaProxyRuntime { + /// Ip address (or hostname) for the haproxy runtime api + pub ip: String, + /// Port for the haproxy runtime api + pub port: u16, + /// The directory path on the haproxy host that contains the certificates + pub cert_dir: String, +} + /// Configuration for a single certificate -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct ConfigCert { /// Path to the account file to use for this certificate request pub account_file: String, diff --git a/src/haproxy.rs b/src/haproxy.rs new file mode 100644 index 0000000..61e9524 --- /dev/null +++ b/src/haproxy.rs @@ -0,0 +1,109 @@ +use std::{path::Path, time::Duration}; +use log::debug; +use tokio::{io::{AsyncReadExt, AsyncWriteExt}, time::timeout}; + +#[derive(thiserror::Error, Debug)] +pub enum HaProxyErr { + #[error("Connection error")] + Connection, + #[error("Update failed")] + UpdateFailed, +} + +pub struct HaProxyApi { + ip: String, + port: u16, + cert_dir: String, +} + +impl HaProxyApi { + pub fn new(ip: &str, port: u16, cert_dir: &str) -> Self { + let ip = ip.to_string(); + let cert_dir = cert_dir.to_string(); + Self { ip, port, cert_dir } + } + + pub async fn update_cert>(&mut self, cert_path: T) -> Result<(), HaProxyErr> { + let cert_path = cert_path.as_ref(); + let cert_name = cert_path.file_name().unwrap().to_string_lossy().to_string(); + let cert_content = tokio::fs::read_to_string(cert_path).await.unwrap(); + let haproxy_ssl_dir = &self.cert_dir; + + debug!("Trying to update haproxy cert in memory: {haproxy_ssl_dir}/{cert_name} <- {}", cert_path.display()); + + // Check for active transactions and abort them + let resp = self.send(&format!("show ssl cert")).await?; + let transactions = resp.lines().filter(|line| line.starts_with("*")).map(|line| &line[1..]).collect::>(); + for transaction in transactions { + self.send(&format!("abort ssl cert {transaction}")).await?; + } + + // Try to create a new certificate in memory. This will do nothing when existing + let resp = self.send(&format!("new ssl cert {haproxy_ssl_dir}/{cert_name}")).await?; + + if !resp.contains("already exists") && !resp.contains("New empty certificate store ") { + return Err(HaProxyErr::UpdateFailed); + } + + // Set the cert data in memory to the new cert. This beginns a transaction + let resp = self.send(&format!("set ssl cert {haproxy_ssl_dir}/{cert_name} <<\n{cert_content}")).await?; + + if !resp.contains("Transaction created") { + return Err(HaProxyErr::UpdateFailed); + } + + // Commit the cert data transaction + let resp = self.send(&format!("commit ssl cert {haproxy_ssl_dir}/{cert_name}")).await?; + + if !resp.contains("Success") { + self.send(&format!("abort ssl cert {haproxy_ssl_dir}/{cert_name}")).await?; + return Err(HaProxyErr::UpdateFailed); + } + + // Try to add the certificate to the active list. This will do nothing if it is contained already + let resp = self.send(&format!("add ssl crt-list {haproxy_ssl_dir} <<\n{haproxy_ssl_dir}/{cert_name}\n")).await?; + + if !resp.contains("already exists") && !resp.contains("Inserting certificate") { + return Err(HaProxyErr::UpdateFailed); + } + + Ok(()) + } + + pub async fn send(&self, cmd: &str) -> Result { + debug!("[Haproxy] send> '{cmd}'"); + + let mut con = tokio::net::TcpStream::connect((self.ip.as_str(), self.port)) + .await + .map_err(|_| HaProxyErr::Connection)?; + + con.write_all(cmd.as_bytes()) + .await + .map_err(|_| HaProxyErr::Connection)?; + + con.write_all(&[b'\n']) + .await + .map_err(|_| HaProxyErr::Connection)?; + + con.flush().await.unwrap(); + + let mut response = Vec::new(); + let mut buf = [0_u8; 1024]; + + loop { + match timeout(Duration::from_millis(5_000), con.read(&mut buf)).await { + // No bytes read, or timeout + Ok(Ok(0)) | Err(_) => break, + Ok(Ok(read)) => { + response.extend_from_slice(&buf[..read]); + } + Ok(Err(_)) => return Err(HaProxyErr::Connection), + } + } + + let response = String::from_utf8_lossy(&response).to_string(); + debug!("[Haproxy] receive> '{response}'"); + + Ok(response) + } +} diff --git a/src/main.rs b/src/main.rs index c0b9782..8ad05bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,12 +3,13 @@ use log::{debug, error, info}; use crate::{ certs::{load_cert_from_fullchain, AcmeApiEndpoint, CertExt, CertRequester}, config::Config, - http::ChallengeServer, + http::ChallengeServer, haproxy::HaProxyApi, }; mod certs; mod config; mod http; +mod haproxy; #[tokio::main] async fn main() -> Result<(), Box> { @@ -31,6 +32,8 @@ async fn main() -> Result<(), Box> { debug!("Config file: {:#?}", &conf); + let mut haproxyapi = conf.haproxy.map(|ha| HaProxyApi::new(&ha.ip, ha.port, &ha.cert_dir)); + // Create the http server for serving the challenges let srv = ChallengeServer::new(&conf.http.ip, conf.http.port)?; let mgr = srv.clone_challenge_mgr(); @@ -66,9 +69,16 @@ async fn main() -> Result<(), Box> { info!("Certificate {name} needs to be renewed. Using endpoint: {endpoint}"); - let requester = CertRequester::new(endpoint, conf, mgr.clone()); - if let Err(e) = requester.request_certs().await { - error!("Certificate request for {name} failed: {e}"); + let requester = CertRequester::new(endpoint, conf.clone(), mgr.clone()); + match (requester.request_certs().await, &mut haproxyapi) { + (Ok(_), Some(api)) => { + match api.update_cert(&conf.fullchain_file).await { + Ok(()) => info!("Certificate update in haproxy completed"), + Err(e) => error!("Certificate update in haproxy failed: {e}") + } + } + (Err(e), _) => error!("Certificate request for {name} failed: {e}"), + _ => () } } else { info!("Certificate {name} does not need to be renewed");