Initial commit
This commit is contained in:
commit
f58886b89e
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
certs
|
||||
1121
Cargo.lock
generated
Normal file
1121
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal 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
263
src/certs.rs
Normal 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
34
src/config.rs
Normal 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
114
src/http.rs
Normal 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
81
src/main.rs
Normal 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(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user