Compare commits

...

10 Commits

Author SHA1 Message Date
6ce99e7e02 Increase timeouts 2022-07-28 20:00:35 +02:00
a7b349127f Update 'README.md' 2022-07-25 02:06:18 +02:00
27caa5edac Refactor haproxy rt api 2022-03-24 23:35:11 +01:00
c9431c62b8 Fix cron env vars 2022-03-24 20:37:31 +01:00
9207200007 Change docker image to use cron 2022-03-24 20:16:26 +01:00
a9dfdde9d5 Tiny refactor 2022-03-24 20:07:41 +01:00
096ac29f26 Implement haproxy live cert reload
- Implemented live certificate reloading for haproxy using the runtime
  api. This does NOT remove deleted certificates from haproxy
- Bump version to 0.2.0
2022-03-24 17:10:54 +01:00
7e1ba11c4d Add Dockerfile 2022-03-24 01:20:13 +01:00
a843b20f7a Fix dns names changed + log levels
- The dns names are now stored as a HashSet instead of a Vec to match
  regardless of ordering
- Fixed print without log & changed log level for http logs to debug
2022-03-24 00:14:28 +01:00
8aaaeb21ad Add README 2022-03-23 23:31:46 +01:00
9 changed files with 337 additions and 37 deletions

2
Cargo.lock generated
View File

@ -350,7 +350,7 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]] [[package]]
name = "le-easy-certs" name = "le-easy-certs"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"acme2", "acme2",
"env_logger", "env_logger",

View File

@ -1,8 +1,11 @@
[package] [package]
name = "le-easy-certs" name = "le-easy-certs"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[profile.release]
strip = "debuginfo"
[dependencies] [dependencies]
acme2 = "0.5" acme2 = "0.5"
env_logger = "0.9" env_logger = "0.9"

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM rust:1.59.0-slim-bullseye as build
WORKDIR /app
RUN apt-get update && apt-get install -y libssl-dev pkg-config
COPY ./src ./src
COPY ./Cargo.toml ./
COPY ./Cargo.lock ./
RUN cargo build --release
#################
# RELEASE IMAGE #
#################
FROM debian:bullseye-slim
# Install cron and clean default cron jobs
RUN apt-get update && apt-get install -y --no-install-recommends cron && rm -rf /etc/cron.*/*
# Creating the file like this is very suboptimal to say the least
RUN echo "0 0,12 * * * root cd /app && ./le-easy-certs >/proc/1/fd/1 2>/proc/1/fd/2\n" > /etc/crontab
RUN echo "@reboot root cd /app && ./le-easy-certs >/proc/1/fd/1 2>/proc/1/fd/2\n\n" >> /etc/crontab
WORKDIR /app
COPY --from=build /app/target/release/le-easy-certs /app/
ENV LE_CONF=/le-conf/le-conf.toml
# Creating the file like this is very suboptimal to say the least
RUN echo "#!/bin/sh" > /app/run.sh
RUN echo "env >> /etc/environment" >> /app/run.sh
RUN echo "cron -f" >> /app/run.sh
RUN chmod +x /app/run.sh
CMD [ "/app/run.sh" ]

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# Lé easy certs
> (Let's Encrypt easy certificates)
## Project state
This project is in a really early state and is in no way stable or ready for production.
## How to use
The program reads the configuration file and requests / renews the certificates according to the
configuration.
- If specified, the first CLI argument will be used as path for the config file
- If specified (and no CLI arg), the `LE_CONF` environment variable will be used as path for the
config file
- If nothing is specified, a config file `./le-conf.toml` will be used
## Example config
```toml
[http]
ip = "0.0.0.0"
port = 80
[certs.example_cert1]
renew_days = 30
account_file = "./account.pem"
fullchain_file = "./example_com_fullchain.pem"
domains = [
"example.com",
"www.example.com",
"sub1.example.com",
"sub2.example.com",
]
[certs.example_cert2]
renew_days = 30
account_file = "./account.pem"
fullchain_file = "./example2_com_fullchain.pem"
domains = [
"example2.com",
"www.example2.com",
]
```
## Docker
The docker image is not yet fully usable. The intention is to have a `le-easy-certs` container
running next to a a `haproxy` container and automatically renew / update the certificates when
needed. Also the certificates could be hot reloaded in `haproxy` at some point.
Right now the docker image could be used to run `le-easy-certs` manually / automated at fixed times
controlled by the host.

View File

@ -6,7 +6,7 @@ use acme2::{
DirectoryBuilder, OrderBuilder, OrderStatus, DirectoryBuilder, OrderBuilder, OrderStatus,
}; };
use log::{debug, info}; use log::{debug, info};
use std::{sync::Arc, time::Duration, fmt::Display}; use std::{collections::HashSet, fmt::Display, sync::Arc, time::Duration};
use tokio::fs; use tokio::fs;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
@ -29,6 +29,8 @@ pub enum ReqErr {
Finalizing, Finalizing,
#[error("No certificate chain error")] #[error("No certificate chain error")]
NoChain, NoChain,
#[error("Timeout: {0}")]
Timeout(Box<ReqErr>),
} }
/// Convenience extension for X509 information in certs /// Convenience extension for X509 information in certs
@ -37,7 +39,7 @@ pub trait CertExt {
/// expired already /// expired already
fn expires_in_days(&self) -> i32; fn expires_in_days(&self) -> i32;
/// Get a list of all domain names for which this cert is valid /// Get a list of all domain names for which this cert is valid
fn dns_names(&self) -> Vec<String>; fn dns_names(&self) -> HashSet<String>;
} }
/// The ACME API endpoint that should be used. This can be the Letsencrypt production or tesing, as /// The ACME API endpoint that should be used. This can be the Letsencrypt production or tesing, as
@ -105,11 +107,15 @@ impl CertRequester {
let acc = match fs::read(&self.conf.account_file).await { let acc = match fs::read(&self.conf.account_file).await {
Ok(pem) => { Ok(pem) => {
info!("Using account '{}'", &self.conf.account_file);
let key = PKey::private_key_from_pem(&pem)?; let key = PKey::private_key_from_pem(&pem)?;
builder.private_key(key).build().await? builder.private_key(key).build().await?
} }
Err(_) => { Err(_) => {
println!("Creating new account!"); info!(
"Account '{}' does not exists. Creating new",
&self.conf.account_file
);
let acc = builder.terms_of_service_agreed(true).build().await?; let acc = builder.terms_of_service_agreed(true).build().await?;
let key = acc.private_key().private_key_to_pem_pkcs8()?; let key = acc.private_key().private_key_to_pem_pkcs8()?;
fs::write(&self.conf.account_file, &key).await?; fs::write(&self.conf.account_file, &key).await?;
@ -168,7 +174,11 @@ impl CertRequester {
challenge.validate().await.map_err(|_| ReqErr::Validation)?; challenge.validate().await.map_err(|_| ReqErr::Validation)?;
let challenge = challenge.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Validation)?; let challenge = challenge
.wait_done(Duration::from_secs(15), 3)
.await
.map_err(|_| ReqErr::Timeout(ReqErr::Validation.into()))?;
if !matches!(challenge.status, ChallengeStatus::Valid) { if !matches!(challenge.status, ChallengeStatus::Valid) {
return Err(ReqErr::Validation); return Err(ReqErr::Validation);
} }
@ -176,14 +186,22 @@ impl CertRequester {
self.challenge_mgr.remove(&token).await; self.challenge_mgr.remove(&token).await;
debug!("Wait for authorization"); debug!("Wait for authorization");
let auth = auth.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Validation)?; let auth = auth
.wait_done(Duration::from_secs(15), 3)
.await
.map_err(|_| ReqErr::Timeout(ReqErr::Validation.into()))?;
if !matches!(auth.status, AuthorizationStatus::Valid) { if !matches!(auth.status, AuthorizationStatus::Valid) {
return Err(ReqErr::Validation); return Err(ReqErr::Validation);
} }
} }
info!("Waiting for order"); info!("Waiting for order");
let order = order.wait_ready(Duration::from_secs(10), 5).await.map_err(|_| ReqErr::Order)?; let order = order
.wait_ready(Duration::from_secs(15), 5)
.await
.map_err(|_| ReqErr::Timeout(ReqErr::Order.into()))?;
if !matches!(order.status, OrderStatus::Ready) { if !matches!(order.status, OrderStatus::Ready) {
return Err(ReqErr::Order); return Err(ReqErr::Order);
} }
@ -191,19 +209,34 @@ impl CertRequester {
let pkey = gen_rsa_private_key(4096).unwrap(); let pkey = gen_rsa_private_key(4096).unwrap();
info!("Finalizing certificate"); info!("Finalizing certificate");
let order = order.finalize(Csr::Automatic(pkey.clone())).await.map_err(|_| ReqErr::Finalizing)?; let order = order
let order = order.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Finalizing)?; .finalize(Csr::Automatic(pkey.clone()))
.await
.map_err(|_| ReqErr::Finalizing)?;
let order = order
.wait_done(Duration::from_secs(15), 3)
.await
.map_err(|_| ReqErr::Timeout(ReqErr::Finalizing.into()))?;
if !matches!(order.status, OrderStatus::Valid) { if !matches!(order.status, OrderStatus::Valid) {
return Err(ReqErr::Finalizing); return Err(ReqErr::Finalizing);
} }
let certs = order.certificate().await.map_err(|_| ReqErr::Finalizing)?.ok_or(ReqErr::Finalizing)?; let certs = order
.certificate()
.await
.map_err(|_| ReqErr::Finalizing)?
.ok_or(ReqErr::Finalizing)?;
if certs.len() <= 1 { if certs.len() <= 1 {
return Err(ReqErr::NoChain); return Err(ReqErr::NoChain);
} }
info!("The new certificate expires in {}", certs[0].expires_in_days()); info!(
"The new certificate expires in {} days",
certs[0].expires_in_days()
);
let x = certs let x = certs
.into_iter() .into_iter()
@ -212,7 +245,9 @@ impl CertRequester {
.chain(pkey.private_key_to_pem_pkcs8().unwrap().into_iter()) .chain(pkey.private_key_to_pem_pkcs8().unwrap().into_iter())
.collect::<Vec<u8>>(); .collect::<Vec<u8>>();
tokio::fs::write(&self.conf.fullchain_file, x).await.unwrap(); tokio::fs::write(&self.conf.fullchain_file, x)
.await
.unwrap();
Ok(()) Ok(())
} }
@ -225,8 +260,8 @@ impl CertExt for X509 {
diff.days diff.days
} }
fn dns_names(&self) -> Vec<String> { fn dns_names(&self) -> HashSet<String> {
let mut names = Vec::new(); let mut names = HashSet::new();
if let Some(alt_names) = self.subject_alt_names() { if let Some(alt_names) = self.subject_alt_names() {
names.extend( names.extend(

View File

@ -1,14 +1,15 @@
use serde_derive::Deserialize; use serde_derive::Deserialize;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct Config { pub struct Config {
pub http: ConfigHttp, pub http: ConfigHttp,
pub certs: HashMap<String, ConfigCert>, pub certs: HashMap<String, ConfigCert>,
pub haproxy: Option<ConfigHaProxyRuntime>,
} }
/// Config for the http server used to serve the challenges /// Config for the http server used to serve the challenges
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ConfigHttp { pub struct ConfigHttp {
/// Binding IP address /// Binding IP address
pub ip: String, pub ip: String,
@ -16,15 +17,26 @@ pub struct ConfigHttp {
pub port: u16, 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 /// Configuration for a single certificate
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ConfigCert { pub struct ConfigCert {
/// Path to the account file to use for this certificate request /// Path to the account file to use for this certificate request
pub account_file: String, pub account_file: String,
/// Path to the fullchain certificate file that will be created or updated /// Path to the fullchain certificate file that will be created or updated
pub fullchain_file: String, pub fullchain_file: String,
/// List of domain names to include in the certificate /// List of domain names to include in the certificate
pub domains: Vec<String>, pub domains: HashSet<String>,
/// Renew the certificate this many days before expiration /// Renew the certificate this many days before expiration
pub renew_days: i32, pub renew_days: i32,
/// Optional custom endpoint. If no enpoint is specified, the production letsencrypt endpoint /// Optional custom endpoint. If no enpoint is specified, the production letsencrypt endpoint

150
src/haproxy.rs Normal file
View File

@ -0,0 +1,150 @@
use log::debug;
use std::{path::Path, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
time::timeout,
};
#[derive(thiserror::Error, Debug)]
pub enum HaProxyErr {
#[error("Connection error")]
Connection,
#[error("Update failed")]
UpdateFailed,
}
#[derive(Debug, Clone)]
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<T: AsRef<Path>>(&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_cert_path = format!("{}/{cert_name}", &self.cert_dir);
debug!(
"Trying to update haproxy cert in memory: {}/{cert_name} <- {}",
&self.cert_dir,
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::<Vec<_>>();
for transaction in transactions {
self.abort_cert(transaction).await?;
}
self.new_cert(&haproxy_cert_path).await?;
self.set_cert(&haproxy_cert_path, &cert_content).await?;
self.commit_ssl_cert(&haproxy_cert_path).await?;
self.add_ssl_crt_list(&self.cert_dir, &haproxy_cert_path)
.await?;
Ok(())
}
/// See: https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/abort-ssl-cert/
pub async fn abort_cert(&self, cert_path: &str) -> Result<(), HaProxyErr> {
self.send(&format!("abort ssl cert {cert_path}")).await?;
Ok(())
}
/// See: https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/new-ssl-cert/
pub async fn new_cert(&self, cert_path: &str) -> Result<(), HaProxyErr> {
let resp = self.send(&format!("new ssl cert {cert_path}")).await?;
if !resp.contains("already exists") && !resp.contains("New empty certificate store ") {
return Err(HaProxyErr::UpdateFailed);
}
Ok(())
}
/// See: https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/set-ssl-cert/
pub async fn set_cert(&self, cert_path: &str, cert_content: &str) -> Result<(), HaProxyErr> {
let resp = self
.send(&format!("set ssl cert {cert_path} <<\n{cert_content}"))
.await?;
if !resp.contains("Transaction created") {
return Err(HaProxyErr::UpdateFailed);
}
Ok(())
}
/// See: https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/commit-ssl-cert/
pub async fn commit_ssl_cert(&self, cert_path: &str) -> Result<(), HaProxyErr> {
let resp = self.send(&format!("commit ssl cert {cert_path}")).await?;
if !resp.contains("Success") {
self.send(&format!("abort ssl cert {cert_path}")).await?;
return Err(HaProxyErr::UpdateFailed);
}
Ok(())
}
/// See: https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/add-ssl-crt-list/
pub async fn add_ssl_crt_list(
&self,
list_path: &str,
cert_path: &str,
) -> Result<(), HaProxyErr> {
let resp = self
.send(&format!("add ssl crt-list {list_path} <<\n{cert_path}\n"))
.await?;
if !resp.contains("already exists") && !resp.contains("Inserting certificate") {
return Err(HaProxyErr::UpdateFailed);
}
Ok(())
}
/// Send a string command to the haproxy runtime api and return the response string
pub async fn send(&self, cmd: &str) -> Result<String, HaProxyErr> {
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)
}
}

View File

@ -1,6 +1,6 @@
use std::{sync::Arc, collections::HashMap, net::{SocketAddr, AddrParseError}, convert::Infallible, future::Future}; use std::{sync::Arc, collections::HashMap, net::{SocketAddr, AddrParseError}, convert::Infallible, future::Future};
use hyper::{Server, service::{make_service_fn, service_fn}, Request, Body, Response, Method}; use hyper::{Server, service::{make_service_fn, service_fn}, Request, Body, Response, Method};
use log::info; use log::debug;
use tokio::sync::{RwLock, oneshot}; use tokio::sync::{RwLock, oneshot};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -68,10 +68,10 @@ impl ChallengeServer {
} }
async fn serve_challenges(req: Request<Body>, mgr: ChallengeManager) -> Response<Body> { async fn serve_challenges(req: Request<Body>, mgr: ChallengeManager) -> Response<Body> {
info!("New http request: {}", req.uri()); debug!("New http request: {}", req.uri());
if !matches!(req.method(), &Method::GET) { if !matches!(req.method(), &Method::GET) {
info!("Request is not GET -> Reject"); debug!("Request is not GET -> Reject");
return Response::builder() return Response::builder()
.status(405) .status(405)
@ -83,7 +83,7 @@ impl ChallengeServer {
let path = req.uri().path(); let path = req.uri().path();
if !path.starts_with(challenge_prefix) { if !path.starts_with(challenge_prefix) {
info!("Request is not for /.well-known -> Reject"); debug!("Request is not for /.well-known -> Reject");
return Response::builder() return Response::builder()
.status(404) .status(404)
@ -92,18 +92,18 @@ impl ChallengeServer {
} }
let tok = &path[challenge_prefix.len()..]; let tok = &path[challenge_prefix.len()..];
info!("Requested token: {}", tok); debug!("Requested token: {}", tok);
match mgr.get(tok).await { match mgr.get(tok).await {
Some(auth) => { Some(auth) => {
info!("Answering Request = {auth}"); debug!("Answering Request = {auth}");
Response::builder() Response::builder()
.status(200) .status(200)
.body(auth.to_string().into()) .body(auth.to_string().into())
.unwrap() .unwrap()
} }
None => { None => {
info!("No matching challenge"); debug!("No matching challenge");
Response::builder() Response::builder()
.status(404) .status(404)
.body(String::new().into()) .body(String::new().into())

View File

@ -3,12 +3,13 @@ use log::{debug, error, info};
use crate::{ use crate::{
certs::{load_cert_from_fullchain, AcmeApiEndpoint, CertExt, CertRequester}, certs::{load_cert_from_fullchain, AcmeApiEndpoint, CertExt, CertRequester},
config::Config, config::Config,
http::ChallengeServer, http::{ChallengeServer, ChallengeManager}, haproxy::HaProxyApi,
}; };
mod certs; mod certs;
mod config; mod config;
mod http; mod http;
mod haproxy;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -31,6 +32,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
debug!("Config file: {:#?}", &conf); debug!("Config file: {:#?}", &conf);
let haproxyapi = conf.clone().haproxy.map(|ha| HaProxyApi::new(&ha.ip, ha.port, &ha.cert_dir));
// Create the http server for serving the challenges // Create the http server for serving the challenges
let srv = ChallengeServer::new(&conf.http.ip, conf.http.port)?; let srv = ChallengeServer::new(&conf.http.ip, conf.http.port)?;
let mgr = srv.clone_challenge_mgr(); let mgr = srv.clone_challenge_mgr();
@ -41,8 +44,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
srv.start().await.unwrap().0.await; srv.start().await.unwrap().0.await;
}); });
check_update_certs(&conf, &mgr, &haproxyapi).await;
info!("All done. Shutting down");
Ok(())
}
pub async fn check_update_certs(conf: &Config, mgr: &ChallengeManager, haproxyapi: &Option<HaProxyApi>) {
// See what certs are requested in the config file // See what certs are requested in the config file
for (name, conf) in conf.certs { for (name, conf) in &conf.certs {
// Check if the cert needs to be created / renewed // Check if the cert needs to be created / renewed
let should_renew = load_cert_from_fullchain(&conf.fullchain_file) let should_renew = load_cert_from_fullchain(&conf.fullchain_file)
.await .await
@ -66,16 +77,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("Certificate {name} needs to be renewed. Using endpoint: {endpoint}"); info!("Certificate {name} needs to be renewed. Using endpoint: {endpoint}");
let requester = CertRequester::new(endpoint, conf, mgr.clone()); let requester = CertRequester::new(endpoint, conf.clone(), mgr.clone());
if let Err(e) = requester.request_certs().await { match (requester.request_certs().await, haproxyapi) {
error!("Certificate request for {name} failed: {e}"); (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 { } else {
info!("Certificate {name} does not need to be renewed"); info!("Certificate {name} does not need to be renewed");
} }
} }
info!("All done. Shutting down");
Ok(())
} }