Initial commit

This commit is contained in:
Daniel M 2022-03-23 23:00:22 +01:00
commit f58886b89e
7 changed files with 1630 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
certs

1121
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "le-easy-certs"
version = "0.1.0"
edition = "2021"
[dependencies]
acme2 = "0.5"
env_logger = "0.9"
hyper = { version = "0.14", features = [ "server" ] }
log = "0.4"
serde = "1.0"
serde_derive = "1.0"
thiserror = "1.0"
tokio = { version = "1", features = [ "macros", "rt", "rt-multi-thread", "fs", "parking_lot" ] }
toml = "0.5"

263
src/certs.rs Normal file
View File

@ -0,0 +1,263 @@
use crate::{config::ConfigCert, http::ChallengeManager};
use acme2::{
gen_rsa_private_key,
openssl::{asn1::Asn1Time, pkey::PKey, x509::X509},
Account, AccountBuilder, AuthorizationStatus, ChallengeStatus, Csr, Directory,
DirectoryBuilder, OrderBuilder, OrderStatus,
};
use log::{debug, info};
use std::{sync::Arc, time::Duration, fmt::Display};
use tokio::fs;
#[derive(thiserror::Error, Debug)]
pub enum ReqErr {
#[error("Invalid endpoint")]
InvaidEndpoint,
#[error("Account error")]
Account,
#[error("Order building error")]
OrderBuild,
#[error("Get Authorizations error")]
Authorizations,
#[error("Challenge error")]
Challenge,
#[error("Validation error")]
Validation,
#[error("Order error")]
Order,
#[error("Finalizing error")]
Finalizing,
#[error("No certificate chain error")]
NoChain,
}
/// Convenience extension for X509 information in certs
pub trait CertExt {
/// Number of days until the certificate expires. This will be negative when the cert has
/// expired already
fn expires_in_days(&self) -> i32;
/// Get a list of all domain names for which this cert is valid
fn dns_names(&self) -> Vec<String>;
}
/// The ACME API endpoint that should be used. This can be the Letsencrypt production or tesing, as
/// well as any custom endpoint
#[derive(Debug, Clone)]
pub enum AcmeApiEndpoint {
/// Letsencrypt Staging API (testing)
LetsEncryptStaging,
/// Letsencrypt Production API (valid certs)
LetsEncryptProduction,
/// Custom ACME API specified via URL
Custom(String),
}
#[derive(Debug)]
pub struct CertRequester {
endpoint: AcmeApiEndpoint,
challenge_mgr: ChallengeManager,
conf: ConfigCert,
}
impl AcmeApiEndpoint {
/// Get the actual URL for the endpoint as string
pub fn url(&self) -> String {
match self {
AcmeApiEndpoint::LetsEncryptStaging => {
"https://acme-staging-v02.api.letsencrypt.org/directory".to_string()
}
AcmeApiEndpoint::LetsEncryptProduction => {
"https://acme-v02.api.letsencrypt.org/directory".to_string()
}
AcmeApiEndpoint::Custom(url) => url.to_string(),
}
}
}
impl Display for AcmeApiEndpoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AcmeApiEndpoint::LetsEncryptStaging => write!(f, "LetsEncryptStaging"),
AcmeApiEndpoint::LetsEncryptProduction => write!(f, "LetsEncryptProduction"),
AcmeApiEndpoint::Custom(url) => write!(f, "Custom({url})"),
}
}
}
impl CertRequester {
pub fn new(
endpoint: AcmeApiEndpoint,
conf: ConfigCert,
challenge_mgr: ChallengeManager,
) -> Self {
Self {
endpoint,
challenge_mgr,
conf,
}
}
async fn load_or_create_account(
&self,
dir: Arc<Directory>,
) -> Result<Arc<Account>, Box<dyn std::error::Error>> {
let mut builder = AccountBuilder::new(dir);
let acc = match fs::read(&self.conf.account_file).await {
Ok(pem) => {
let key = PKey::private_key_from_pem(&pem)?;
builder.private_key(key).build().await?
}
Err(_) => {
println!("Creating new account!");
let acc = builder.terms_of_service_agreed(true).build().await?;
let key = acc.private_key().private_key_to_pem_pkcs8()?;
fs::write(&self.conf.account_file, &key).await?;
acc
}
};
Ok(acc)
}
pub async fn request_certs(&self) -> Result<(), ReqErr> {
let dir = DirectoryBuilder::new(self.endpoint.url())
.build()
.await
.map_err(|_| ReqErr::InvaidEndpoint)?;
let account = self
.load_or_create_account(dir)
.await
.map_err(|_| ReqErr::Account)?;
let order = self
.conf
.domains
.iter()
.fold(&mut OrderBuilder::new(account), |acc, domain| {
acc.add_dns_identifier(domain.to_string())
})
.build()
.await
.map_err(|_| ReqErr::OrderBuild)?;
let authorizations = order
.authorizations()
.await
.map_err(|_| ReqErr::Authorizations)?;
info!("Creating challenges");
for auth in authorizations {
let challenge = auth.get_challenge("http-01").ok_or(ReqErr::Challenge)?;
let token = challenge
.token
.as_ref()
.ok_or(ReqErr::Challenge)?
.to_string();
let key_auth = challenge
.key_authorization()
.ok()
.flatten()
.ok_or(ReqErr::Challenge)?;
debug!("Interting challenge in manager: {token} => {key_auth}");
self.challenge_mgr.insert(token.clone(), key_auth).await;
challenge.validate().await.map_err(|_| ReqErr::Validation)?;
let challenge = challenge.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Validation)?;
if !matches!(challenge.status, ChallengeStatus::Valid) {
return Err(ReqErr::Validation);
}
self.challenge_mgr.remove(&token).await;
debug!("Wait for authorization");
let auth = auth.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Validation)?;
if !matches!(auth.status, AuthorizationStatus::Valid) {
return Err(ReqErr::Validation);
}
}
info!("Waiting for order");
let order = order.wait_ready(Duration::from_secs(10), 5).await.map_err(|_| ReqErr::Order)?;
if !matches!(order.status, OrderStatus::Ready) {
return Err(ReqErr::Order);
}
let pkey = gen_rsa_private_key(4096).unwrap();
info!("Finalizing certificate");
let order = order.finalize(Csr::Automatic(pkey.clone())).await.map_err(|_| ReqErr::Finalizing)?;
let order = order.wait_done(Duration::from_secs(5), 3).await.map_err(|_| ReqErr::Finalizing)?;
if !matches!(order.status, OrderStatus::Valid) {
return Err(ReqErr::Finalizing);
}
let certs = order.certificate().await.map_err(|_| ReqErr::Finalizing)?.ok_or(ReqErr::Finalizing)?;
if certs.len() <= 1 {
return Err(ReqErr::NoChain);
}
info!("The new certificate expires in {}", certs[0].expires_in_days());
let x = certs
.into_iter()
.map(|it| it.to_pem().unwrap())
.flatten()
.chain(pkey.private_key_to_pem_pkcs8().unwrap().into_iter())
.collect::<Vec<u8>>();
tokio::fs::write(&self.conf.fullchain_file, x).await.unwrap();
Ok(())
}
}
impl CertExt for X509 {
fn expires_in_days(&self) -> i32 {
let now = Asn1Time::days_from_now(0).unwrap();
let diff = now.diff(self.not_after()).unwrap();
diff.days
}
fn dns_names(&self) -> Vec<String> {
let mut names = Vec::new();
if let Some(alt_names) = self.subject_alt_names() {
names.extend(
alt_names
.into_iter()
.filter_map(|it| it.dnsname().map(String::from)),
);
}
names
}
}
pub async fn load_cert_from_fullchain(path: &str) -> Option<X509> {
const START: &str = "-----BEGIN CERTIFICATE-----";
const END: &str = "-----END CERTIFICATE-----";
let fullchain = tokio::fs::read_to_string(path).await.ok()?;
let mut fullchain = fullchain.as_str();
while let (Some(start), Some(end)) = (fullchain.find(START), fullchain.find(END)) {
let pem = &fullchain[start..end + END.len()];
fullchain = &fullchain[end + END.len()..];
let cert = X509::from_pem(pem.as_bytes()).unwrap();
if cert.dns_names().is_empty() {
continue;
}
return Some(cert);
}
None
}

34
src/config.rs Normal file
View File

@ -0,0 +1,34 @@
use serde_derive::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
pub struct Config {
pub http: ConfigHttp,
pub certs: HashMap<String, ConfigCert>,
}
/// Config for the http server used to serve the challenges
#[derive(Debug, Deserialize)]
pub struct ConfigHttp {
/// Binding IP address
pub ip: String,
/// Binding port
pub port: u16,
}
/// Configuration for a single certificate
#[derive(Debug, Deserialize)]
pub struct ConfigCert {
/// Path to the account file to use for this certificate request
pub account_file: String,
/// Path to the fullchain certificate file that will be created or updated
pub fullchain_file: String,
/// List of domain names to include in the certificate
pub domains: Vec<String>,
/// Renew the certificate this many days before expiration
pub renew_days: i32,
/// Optional custom endpoint. If no enpoint is specified, the production letsencrypt endpoint
/// is used. "LetsEncryptStaging" and "LetsEncryptProduction" will use the corresponding
/// letsencrypt endpoints. Everything else has to be an actual URL specifying the endpoint.
pub endpoint: Option<String>,
}

114
src/http.rs Normal file
View File

@ -0,0 +1,114 @@
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 log::info;
use tokio::sync::{RwLock, oneshot};
#[derive(Debug, Clone)]
pub struct ChallengeManager {
challenges: Arc<RwLock<HashMap<String, String>>>,
}
/// Http server to host the challenges
pub struct ChallengeServer {
mgr: ChallengeManager,
sock_addr: SocketAddr,
}
impl ChallengeManager {
pub fn new() -> Self {
let challenges = Arc::new(RwLock::new(HashMap::new()));
Self { challenges }
}
pub async fn insert(&self, token: String, key: String) {
self.challenges.write().await.insert(token, key);
}
pub async fn get(&self, token: &str) -> Option<String> {
self.challenges.read().await.get(token).map(String::to_string)
}
pub async fn remove(&self, token: &str) {
self.challenges.write().await.remove(token);
}
}
impl ChallengeServer {
pub fn new(bind_ip: &str, bind_port: u16) -> Result<Self, AddrParseError> {
let mgr = ChallengeManager::new();
let sock_addr = format!("{}:{}", bind_ip, bind_port).parse()?;
Ok(Self { mgr, sock_addr })
}
pub fn clone_challenge_mgr(&self) -> ChallengeManager {
self.mgr.clone()
}
pub async fn start(&self) -> Result<(impl Future, oneshot::Sender<()>), hyper::Error> {
let mgr = self.clone_challenge_mgr();
let make_service = make_service_fn(move |_con| {
let mgr = mgr.clone();
async move {
let mgr = mgr.clone();
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let mgr = mgr.clone();
async move { Ok::<_, Infallible>(Self::serve_challenges(req, mgr).await) }
}))
}
});
let srv = Server::bind(&self.sock_addr).serve(make_service);
let (tx, rx) = oneshot::channel::<()>();
let srv = srv.with_graceful_shutdown(async move {
let _ = rx.await;
});
Ok((srv, tx))
}
async fn serve_challenges(req: Request<Body>, mgr: ChallengeManager) -> Response<Body> {
info!("New http request: {}", req.uri());
if !matches!(req.method(), &Method::GET) {
info!("Request is not GET -> Reject");
return Response::builder()
.status(405)
.body(String::new().into())
.unwrap();
}
let challenge_prefix = "/.well-known/acme-challenge/";
let path = req.uri().path();
if !path.starts_with(challenge_prefix) {
info!("Request is not for /.well-known -> Reject");
return Response::builder()
.status(404)
.body(String::new().into())
.unwrap();
}
let tok = &path[challenge_prefix.len()..];
info!("Requested token: {}", tok);
match mgr.get(tok).await {
Some(auth) => {
info!("Answering Request = {auth}");
Response::builder()
.status(200)
.body(auth.to_string().into())
.unwrap()
}
None => {
info!("No matching challenge");
Response::builder()
.status(404)
.body(String::new().into())
.unwrap()
}
}
}
}

81
src/main.rs Normal file
View File

@ -0,0 +1,81 @@
use log::{debug, error, info};
use crate::{
certs::{load_cert_from_fullchain, AcmeApiEndpoint, CertExt, CertRequester},
config::Config,
http::ChallengeServer,
};
mod certs;
mod config;
mod http;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "info"));
// Config path evaluation order:
// - first cli argument
// - LE_CONF environment variable
// ./le-conf.toml
let config_path = std::env::args()
.skip(1)
.next()
.unwrap_or_else(|| std::env::var("LE_CONF").unwrap_or("./le-conf.toml".to_string()));
info!("Loading config file '{}'", config_path);
let s_conf = tokio::fs::read_to_string(&config_path)
.await
.expect(&format!("Failed to load config file: {}", config_path));
let conf: Config = toml::from_str(&s_conf)?;
debug!("Config file: {:#?}", &conf);
// Create the http server for serving the challenges
let srv = ChallengeServer::new(&conf.http.ip, conf.http.port)?;
let mgr = srv.clone_challenge_mgr();
// Start the server
info!("Starting http server: {}:{}", &conf.http.ip, conf.http.port);
tokio::spawn(async move {
srv.start().await.unwrap().0.await;
});
// See what certs are requested in the config file
for (name, conf) in conf.certs {
// Check if the cert needs to be created / renewed
let should_renew = load_cert_from_fullchain(&conf.fullchain_file)
.await
.map(|cert| {
let expires_in_days = cert.expires_in_days();
let dns_names = cert.dns_names();
expires_in_days <= conf.renew_days || dns_names != conf.domains
})
.unwrap_or(true);
if should_renew {
let endpoint = conf
.endpoint
.as_ref()
.map(|ep| match ep.as_ref() {
"LetsEncryptProduction" => AcmeApiEndpoint::LetsEncryptProduction,
"LetsEncryptStaging" => AcmeApiEndpoint::LetsEncryptStaging,
url => AcmeApiEndpoint::Custom(url.to_string()),
})
.unwrap_or(AcmeApiEndpoint::LetsEncryptProduction);
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}");
}
} else {
info!("Certificate {name} does not need to be renewed");
}
}
info!("All done. Shutting down");
Ok(())
}