diff --git a/Cargo.lock b/Cargo.lock index c773153..48a0ffd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] @@ -1194,7 +1193,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" dependencies = [ "async-trait", - "aws-lc-rs", "base64", "bytes", "http", @@ -1205,6 +1203,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "rcgen", + "ring", "rustls", "rustls-pki-types", "serde", @@ -1940,12 +1939,14 @@ checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" name = "proxy" version = "0.2.3" dependencies = [ + "arc-swap", "async-trait", "chrono", "http", "instant-acme", "pingora", "rcgen", + "rustls", "serde", "thiserror 2.0.19", "tokio", @@ -2032,7 +2033,6 @@ version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ - "aws-lc-rs", "pem", "ring", "rustls-pki-types", @@ -2089,7 +2089,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -2154,12 +2154,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2223,7 +2225,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -2323,9 +2325,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2781,12 +2783,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -3093,7 +3089,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", - "aws-lc-rs", "data-encoding", "der-parser", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index adfe95c..f04283e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,11 +22,20 @@ unused = { level = "allow", priority = -1 } async-trait = "0.1.91" tokio = { version = "1.53.1", features = ["full"] } +# Arc +arc-swap = "1.9.1" + # Certs # used Ring as backend for default rcgen = { version = "0.14.8", features = ["default"] } # match the backed from rcgen -instant-acme = { version = "0.8.5", features = ["rcgen"] } +instant-acme = { version = "0.8.5", default-features = false, features = [ + "ring", + "rcgen", + "hyper-rustls", +] } +rustls = "0.23.41" + # TOML toml = "1.1.3" diff --git a/README.md b/README.md index 7029610..7904482 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ services: proxy: image: maxvanderschee/reverse-proxy:latest # or ghcr.io/mvdschee/reverse-proxy:latest restart: unless-stopped + # host ports 80/443 mapped to container ports 8080/8443 + # see ## Under the hood for explanation ports: - "80:8080" - "443:8443" @@ -164,5 +166,12 @@ I'll be upfront on every public project about what was done with AI. For this on - Research and tradeoff discussions - Cleanup of the README and other prose -- Talking through code-level solutions +- Discussing code-level solutions - Generating the Docker image scaffolding from a spec + +# TODO: + +- finish create_acme_dns_challenge +- create a dns entry checker for the background task +- generate a staging certificate with instance_acme +- wire up the full flow in CertBackgroundRenewal diff --git a/docker-compose.yml b/docker-compose.yml index dec2d7b..a289748 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: context: . dockerfile: .docker/Dockerfile ports: + # nonroot user can't bind to ports < 1024, so map to 8080/8443 - "80:8080" - "443:8443" # Lets upstream = "host.docker.internal:" reach apps running diff --git a/example/example.toml b/example/example.toml index 69e6f8a..0da207d 100644 --- a/example/example.toml +++ b/example/example.toml @@ -1,5 +1,5 @@ [acme] -email = "max@example.com" +email = "max@example.com" # use a real email this will fail the even on local testing [[routes]] host = "git.example.com" diff --git a/makefile b/makefile index 3cd59f6..b0e05da 100644 --- a/makefile +++ b/makefile @@ -1,5 +1,5 @@ dev: - CONFIG_PATH=./example/example.toml watchexec -q -c -w src --exts rs --restart "cargo run" + CONFIG_PATH=./example/local.toml watchexec -q -c -w src --exts rs --restart "cargo run" scan: foxguard --config .foxguard.yml diff --git a/src/config.rs b/src/config.rs index 6a6ddde..3aa71ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,8 +20,8 @@ const HTTP_PORT_DEFAULT: u16 = 80; const HTTPS_PORT_DEFAULT: u16 = 443; const INPUT_ADDRESS: &str = "0.0.0.0"; -// in seconds -const CERT_BACKGROUND_TASK_INTERVAL: u64 = 120; +/// in seconds +const CERT_BACKGROUND_TASK_INTERVAL: u64 = 3600; // 1 hour #[derive(Debug, Clone)] pub struct Config { diff --git a/src/core/handlers/certs.rs b/src/core/handlers/certs.rs index f9d088d..c28ffc9 100644 --- a/src/core/handlers/certs.rs +++ b/src/core/handlers/certs.rs @@ -1,73 +1,185 @@ use crate::{ Error, Result, core::{ - handlers::filesystem::{safe_path, write_file}, + handlers::filesystem::{check_file_exists, read_file, safe_path, write_file}, models::{ - certs::{CertificateConfig, CertificateType}, + certs::{ + CertDir, CertPath, CertificateConfig, CertificateType, Email, KeyPath, TlsMaterial, + TlsStore, + }, + routes::Host, tasks::TaskInterval, }, }, - info, + error, info, + services::certs::{ + acme::{create_account, create_order}, + self_signed::create_self_signed_certificate_files, + }, + warn, }; +use arc_swap::ArcSwap; +use async_trait::async_trait; +use instant_acme::{Identifier, NewOrder, OrderStatus}; +use pingora::{server::ShutdownWatch, services::background::BackgroundService, tls}; use rcgen::{CertifiedKey, generate_simple_self_signed}; -use std::time::Duration; +use std::{collections::HashMap, sync::Arc, time::Duration}; use tokio::time; -pub fn generate_certs(certificate_configs: Vec) -> Result<()> { - for config in &certificate_configs { - // self signed certificates are good until the year 4096 - // this will be replace every restart so it's safe to keep using the default setting +pub fn create_self_signed_certs(certificate_configs: &Vec) -> Result<()> { + for config in certificate_configs { match config.cert_type { CertificateType::SelfSigned => { - info!("generating self-signed certificate for {}", config.host); + // self signed certificates are good until the year 4096 + // this will be replace every restart so it's safe to keep using the default setting + // for selfsigned we will create the certs here right away + create_self_signed_certificate_files(config); + }, + _ => {}, + } + } - let subject_alt_names = vec![config.host.to_string()]; - let pem_filename = format!("{}.pem", config.host); - let key_filename = format!("{}.key", config.host); + Ok(()) +} - let key_path = safe_path(&config.cert_dir, &key_filename)?; - let pem_path = safe_path(&config.cert_dir, &pem_filename)?; +pub fn load_tls_store(certificate_configs: &Vec) -> Result { + let mut tls_certs = HashMap::new(); - let CertifiedKey { - cert, - signing_key, - } = generate_simple_self_signed(subject_alt_names) - .map_err(|e| Error::Certificate(e.to_string()))?; + for config in certificate_configs { + if config.cert_type != CertificateType::None { + let (key_path, cert_path) = certificate_paths(&config.host, &config.cert_dir)?; - let pem_serialized = cert.pem(); - let key_serialized = signing_key.serialize_pem(); + let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path); - write_file(pem_path, pem_serialized.as_bytes())?; - write_file(key_path, key_serialized.as_bytes())?; - }, - CertificateType::Acme => { - info!("generating acme certificate for {}", config.host); - }, - CertificateType::None => {}, + // We only show a warning so its easier to debug once its running, + // but we are not stopping any traffic. + if !has_tls_files { + warn!("Certificate files not found for host '{}' but is expected", &config.host); + continue; + } + + let cert_bytes = read_file(&cert_path)?; + let cert = tls::x509::X509::from_pem(&cert_bytes) + .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?; + + let key_bytes = read_file(&key_path)?; + let key = tls::pkey::PKey::private_key_from_pem(&key_bytes) + .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?; + + tls_certs.insert( + config.host.clone(), + TlsMaterial { + cert, + key, + }, + ); } } - Ok(()) + let tls_store: TlsStore = Arc::new(ArcSwap::from_pointee(tls_certs)); + + Ok(tls_store) } -pub async fn background_certs_task( - certificates: Vec, - task_interval: TaskInterval, -) -> Result<()> { - // only acme certificates need to be renewed - let certificates = certificates - .into_iter() - .filter(|cert| cert.cert_type == CertificateType::Acme) - .collect::>(); +pub fn certificate_paths(host: &Host, cert_dir: &CertDir) -> Result<(KeyPath, CertPath)> { + let cert_filename = format!("{}.pem", host); + let key_filename = format!("{}.key", host); - loop { - info!("certificates: {}", certificates.len()); + let key_path = safe_path(cert_dir, &key_filename)?; + let cert_path = safe_path(cert_dir, &cert_filename)?; - // for certificate in &certificates { - // } + Ok((key_path, cert_path)) +} - time::sleep(Duration::from_secs(*task_interval)).await; +pub struct CertBackgroundRenewal { + pub certificate_configs: Vec, + pub task_interval: TaskInterval, + pub tls_store: TlsStore, + pub email: Email, +} + +impl CertBackgroundRenewal { + pub fn new( + certificate_configs: Vec, + task_interval: TaskInterval, + tls_store: TlsStore, + email: Email, + ) -> Self { + Self { + certificate_configs, + task_interval, + tls_store, + email, + } } +} - Ok(()) +#[async_trait] +impl BackgroundService for CertBackgroundRenewal { + // start should never return this will stop the background task, + // this means we have to be a little more verbose with our error handeling. + // TLDR; just continue on any error :D, problem for the next loop :') + async fn start(&self, mut shutdown: ShutdownWatch) { + // TODO what to do when creating an account fails (acme endpoints is 500 etc..) + let account_result = create_account(&self.email) + .await + .map_err(|e| Error::Certificate(format!("Failed with create_account: {}", e))); + + let configs = self + .certificate_configs + .clone() + .into_iter() + .filter(|c| c.cert_type == CertificateType::Acme); + + loop { + let (account, credentials) = match account_result { + Ok(ref pair) => pair, + Err(ref err) => { + error!("{err:?}"); + continue; + }, + }; + + for config in configs.clone() { + let order_result = create_order(&account, &config.host).await; + + let mut order = match order_result { + Ok(order) => order, + Err(err) => { + error!("{err:?}"); + continue; + }, + }; + + let state = order.state(); + info!("order state: {:#?}", state); + + if !matches!(state.status, OrderStatus::Pending) { + warn!("Skipping non-Pending order: {:?}", state.status); + continue; + } + + // + // if so verify the dns records with cloudflare + // + // if its set allow the order to be proccessed + // + // write to the file system + // + // swap the file content in the store with the new values if any + // + // + // note: we write to the file system so we can pick the files up and load them in the store when we restart or bootup + // this so we don't have to deal here with loading if the files are there (so we only have to check here if the order is invalid or valid and swap when its time) + // so on boot we load all the tls certs from self-signed / acme and check in this flow it its valid or not and fix it with a swap. + } + + info!("background thing"); + + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(*self.task_interval)) => {} + _ = shutdown.changed() => break, + } + } + } } diff --git a/src/core/handlers/proxy.rs b/src/core/handlers/proxy.rs index 62693f4..a257d6f 100644 --- a/src/core/handlers/proxy.rs +++ b/src/core/handlers/proxy.rs @@ -1,21 +1,22 @@ use crate::{ Error, Result, core::{ - handlers::filesystem::read_file, + handlers::{certs::CertBackgroundRenewal, filesystem::read_file}, models::{ - certs::{TlsCerts, TlsMaterial}, + certs::{TlsMaterial, TlsStore}, proxy::{ProxyConfig, ProxyRoute, ProxyRouteMap}, }, }, error, }; +use arc_swap::ArcSwap; use async_trait::async_trait; use http::header; use pingora::{ ErrorType, http::ResponseHeader, listeners::{TlsAccept, tls::TlsSettings}, - prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult}, + prelude::{Error as PingoraError, HttpPeer, Result as PingoraResult, background_service}, proxy::{ProxyHttp, Session, http_proxy_service}, server::{Server, configuration::ServerConf}, services::Service, @@ -23,7 +24,12 @@ use pingora::{ }; use std::{collections::HashMap, sync::Arc}; -pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<()> { +pub fn run_proxy( + proxy_config: ProxyConfig, + routes: Vec, + tls_store: TlsStore, + renewal: CertBackgroundRenewal, +) -> Result<()> { let mut server = Server::new(None).map_err(|e| Error::Proxy(e.to_string()))?; server.bootstrap(); @@ -31,34 +37,13 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<( let server_conf = server.configuration.clone(); let http_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.http_port); let https_addr = format!("{}:{}", proxy_config.input_address, *proxy_config.https_port); - let mut routes_map = HashMap::new(); - let mut tls_certs = HashMap::new(); for route in routes { - if *route.tls { - let cert_bytes = read_file(&route.cert_path)?; - let cert = tls::x509::X509::from_pem(&cert_bytes) - .map_err(|e| Error::Certificate(format!("Failed to parse certificate: {}", e)))?; - - let key_bytes = read_file(&route.key_path)?; - let key = tls::pkey::PKey::private_key_from_pem(&key_bytes) - .map_err(|e| Error::Certificate(format!("Failed to parse private key: {}", e)))?; - - tls_certs.insert( - route.host.clone(), - TlsMaterial { - cert, - key, - }, - ); - } - routes_map.insert(route.host.clone(), route); } let routes_map = Arc::new(routes_map); - let tls_certs = Arc::new(tls_certs); // plain proxies with redirect let plain_service = @@ -66,10 +51,12 @@ pub fn run_proxy(proxy_config: ProxyConfig, routes: Vec) -> Result<( server.add_service(plain_service); // tls proxies - let tls_service = - tls_routes_service(server_conf, https_addr.clone(), routes_map.clone(), tls_certs)?; + let tls_service = tls_routes_service(server_conf, https_addr, routes_map, tls_store)?; server.add_service(tls_service); + // background cert services + server.add_service(background_service("cert-renewal", renewal)); + server.run_forever(); } @@ -77,14 +64,14 @@ pub fn tls_routes_service( server_conf: Arc, listen_addr: String, routes_map: ProxyRouteMap, - tls_certs: TlsCerts, + tls_store: TlsStore, ) -> Result { let proxy_app = ProxyToUpstream::new(routes_map.clone(), false); let mut service = http_proxy_service(&server_conf, proxy_app); - let sni_resolver = SniResolver::new(tls_certs); - let callback = Box::new(sni_resolver); + let cert_resolver = CertResolver::new(tls_store); + let callback = Box::new(cert_resolver); let tls_settings = TlsSettings::with_callbacks(callback).map_err(|e| Error::Proxy(e.to_string()))?; service.add_tls_with_settings(&listen_addr, None, tls_settings); @@ -186,20 +173,20 @@ fn host_from_session(session: &Session) -> Option<&str> { session.get_header(header::HOST).and_then(|h| h.to_str().ok()) } -struct SniResolver { - certs: TlsCerts, +struct CertResolver { + tls_store: TlsStore, } -impl SniResolver { - fn new(tls_certs: TlsCerts) -> Self { +impl CertResolver { + fn new(tls_store: TlsStore) -> Self { Self { - certs: tls_certs, + tls_store, } } } #[async_trait] -impl TlsAccept for SniResolver { +impl TlsAccept for CertResolver { async fn certificate_callback(&self, ssl: &mut ssl::SslRef) -> () { let sni_provided = ssl.servername(ssl::NameType::HOST_NAME).map(str::to_owned); @@ -208,10 +195,12 @@ impl TlsAccept for SniResolver { return; }; + let certs = self.tls_store.load(); + let Some(TlsMaterial { cert, key, - }) = self.certs.get(sni_provided.as_str()) + }) = certs.get(sni_provided.as_str()) else { error!("No certificate found for SNI: {}", sni_provided); return; diff --git a/src/core/models/certs.rs b/src/core/models/certs.rs index 818149a..e7eaa6d 100644 --- a/src/core/models/certs.rs +++ b/src/core/models/certs.rs @@ -1,4 +1,5 @@ -use crate::core::models::routes::Host; +use crate::core::models::{filesystem::SafePath, routes::Host}; +use arc_swap::ArcSwap; use pingora::tls::{ pkey::{PKey, Private}, x509::X509, @@ -22,7 +23,6 @@ pub enum CertificateType { pub struct CertificateConfig { pub host: Host, pub cert_dir: CertDir, - pub email: Email, pub cert_type: CertificateType, } @@ -31,7 +31,10 @@ pub struct TlsMaterial { pub key: PKey, } -pub type TlsCerts = Arc>; +pub type KeyPath = SafePath; +pub type CertPath = SafePath; + +pub type TlsStore = Arc>>; // --- EMAIL --- #[derive(Debug, Clone, Deserialize)] diff --git a/src/core/models/dns.rs b/src/core/models/dns.rs new file mode 100644 index 0000000..f5792e4 --- /dev/null +++ b/src/core/models/dns.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; +use std::{fmt, ops::Deref}; + +// --- EMAIL --- +#[derive(Debug, Clone, Deserialize)] +pub struct Record(String); + +impl Deref for Record { + type Target = String; + + fn deref(&self) -> &String { + &self.0 + } +} + +impl fmt::Display for Record { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for Record { + fn from(s: String) -> Self { + Record(s) + } +} diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs index 1cf9ecb..7052b71 100644 --- a/src/core/models/mod.rs +++ b/src/core/models/mod.rs @@ -1,4 +1,5 @@ pub mod certs; +pub mod dns; pub mod filesystem; pub mod proxy; pub mod routes; diff --git a/src/core/models/proxy.rs b/src/core/models/proxy.rs index dab8d40..ca5438f 100644 --- a/src/core/models/proxy.rs +++ b/src/core/models/proxy.rs @@ -13,13 +13,10 @@ pub struct ProxyRoute { pub host: Host, pub upstream: Upstream, pub tls: ProxyTls, - pub cert_path: SafePath, - pub key_path: SafePath, } #[derive(Debug, Clone)] pub struct ProxyConfig { - pub cert_dir: CertDir, pub http_port: ProxyPort, pub https_port: ProxyPort, pub input_address: ProxyInputAddress, diff --git a/src/core/models/tasks.rs b/src/core/models/tasks.rs index 1cc2b46..8722ed1 100644 --- a/src/core/models/tasks.rs +++ b/src/core/models/tasks.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use std::ops::Deref; // --- TASK INTERVAL --- +/// Interval for a task in seconds. #[derive(Debug, Clone, Deserialize)] pub struct TaskInterval(u64); diff --git a/src/core/setup.rs b/src/core/setup.rs index b133345..6f74afd 100644 --- a/src/core/setup.rs +++ b/src/core/setup.rs @@ -2,12 +2,14 @@ use crate::{ Error, Result, core::{ handlers::{ - certs::generate_certs, + certs::{ + CertBackgroundRenewal, certificate_paths, create_self_signed_certs, load_tls_store, + }, filesystem::{check_file_exists, safe_path}, proxy::run_proxy, }, models::{ - certs::{CertDir, CertificateConfig, CertificateType, Email}, + certs::{CertDir, CertificateConfig, CertificateType, Email, TlsStore}, proxy::{ProxyConfig, ProxyRoute, ProxyTls}, routes::Route, tasks::TaskInterval, @@ -47,6 +49,7 @@ impl HandleFileSystem { pub struct HandleCertificates { certificate_configs: Vec, task_interval: TaskInterval, + email: Email, } impl HandleCertificates { @@ -61,7 +64,6 @@ impl HandleCertificates { .map(|route| CertificateConfig { host: route.host.clone(), cert_dir: cert_dir.clone(), - email: email.clone(), cert_type: route.cert_type.clone(), }) .collect::>(); @@ -69,64 +71,59 @@ impl HandleCertificates { Self { certificate_configs, task_interval, + email, } } - pub fn run(self) -> Result<()> { - generate_certs(self.certificate_configs.clone())?; + pub fn run(self) -> Result<(TlsStore, CertBackgroundRenewal)> { + create_self_signed_certs(&self.certificate_configs)?; - // spawn(async move { - // info!("starting certificates tasks..."); - // background_certs_task(self.certificates, self.task_interval).await; - // }); + let store = load_tls_store(&self.certificate_configs)?; + let renewal = CertBackgroundRenewal::new( + self.certificate_configs.clone(), + self.task_interval.clone(), + store.clone(), + self.email.clone(), + ); - Ok(()) + Ok((store, renewal)) } } pub struct HandleProxy { proxy_config: ProxyConfig, proxy_routes: Vec, + tls_store: TlsStore, } impl HandleProxy { - pub fn new(proxy_config: ProxyConfig, routes: Vec) -> Result { + pub fn new(proxy_config: ProxyConfig, routes: Vec, tls_store: TlsStore) -> Result { let mut proxy_routes = Vec::new(); for route in routes { - let cert_filename = format!("{}.pem", route.host); - let key_filename = format!("{}.key", route.host); - - let key_path = safe_path(&proxy_config.cert_dir, &key_filename)?; - let cert_path = safe_path(&proxy_config.cert_dir, &cert_filename)?; - - let has_tls_files = check_file_exists(&key_path) && check_file_exists(&cert_path); - - // We only show a warning so its easier to debug once its running, - // but we are not stopping any traffic. - if !has_tls_files && route.cert_type != CertificateType::None { - warn!("Certificate files not found for host `{}` but is expected", route.host); - } - proxy_routes.push(ProxyRoute { host: route.host.clone(), upstream: route.upstream.clone(), tls: ProxyTls::from(route.cert_type != CertificateType::None), - cert_path, - key_path, }); } Ok(Self { proxy_config, proxy_routes, + tls_store, }) } - pub fn run(&self) -> Result<()> { + pub fn run(&self, renewal: CertBackgroundRenewal) -> Result<()> { info!("proxy running..."); - run_proxy(self.proxy_config.clone(), self.proxy_routes.clone())?; + run_proxy( + self.proxy_config.clone(), + self.proxy_routes.clone(), + self.tls_store.clone(), + renewal, + )?; Err(Error::Proxy("proxy exited".to_string())) } diff --git a/src/error.rs b/src/error.rs index a9f79ac..80712fa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,9 +19,15 @@ pub enum Error { #[error("certificate error: {0}")] Certificate(String), + #[error("Acme error: {0}")] + Acme(String), + #[error("proxy error: {0}")] Proxy(String), + #[error("dns error: {0}")] + Dns(String), + #[error("config error: {0}")] Config(String), diff --git a/src/main.rs b/src/main.rs index ad61be0..bf0e8fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ pub use error::{Error, Result}; mod config; mod core; pub mod error; +mod services; mod utils; // entry needs to be synchronous as pingora has there own @@ -29,17 +30,16 @@ fn main() -> Result<()> { config.routes.clone(), config.task_interval, ); - cert_handler.run()?; + let (store, renewal) = cert_handler.run()?; // start the proxy let proxy_config = ProxyConfig { - cert_dir: config.cert_dir.clone(), http_port: config.http_port, https_port: config.https_port, input_address: config.input_address, }; - let proxy_handler = HandleProxy::new(proxy_config, config.routes)?; - proxy_handler.run()?; + let proxy_handler = HandleProxy::new(proxy_config, config.routes, store)?; + proxy_handler.run(renewal)?; Err(Error::MainLoopClosed) } diff --git a/src/services/certs/acme.rs b/src/services/certs/acme.rs new file mode 100644 index 0000000..f6aaea5 --- /dev/null +++ b/src/services/certs/acme.rs @@ -0,0 +1,46 @@ +use crate::{ + Error, Result, + core::models::{ + certs::{CertificateConfig, Email}, + routes::Host, + }, +}; +use instant_acme::{ + Account, AccountCredentials, Identifier, LetsEncrypt, NewAccount, NewOrder, Order, +}; +use rustls::crypto::CryptoProvider; + +pub async fn create_account(email: &Email) -> Result<(Account, AccountCredentials)> { + CryptoProvider::install_default(rustls::crypto::ring::default_provider()); + + let (account, credentials) = Account::builder() + .map_err(|e| Error::Acme(e.to_string()))? + .create( + &NewAccount { + contact: &[&format!("mailto:{}", email.as_str())], + terms_of_service_agreed: true, + only_return_existing: false, + }, + // TODO: switch to prod + LetsEncrypt::Staging.url().to_owned(), + None, + ) + .await + .map_err(|e| Error::Acme(e.to_string()))?; + + Ok((account, credentials)) +} + +pub async fn create_order(account: &Account, host: &Host) -> Result { + let identifier = Identifier::Dns(host.to_string()); + // instant_acme support multiple host per order, + // but we need to know which dns needs to be updated + // which is why we split them up, this is maybe something we can refine in V2 + let identifiers = vec![identifier]; + let order_result = account + .new_order(&NewOrder::new(&identifiers)) + .await + .map_err(|e| Error::Acme(format!("Failed with new_order: {}", e))); + + order_result +} diff --git a/src/services/certs/mod.rs b/src/services/certs/mod.rs new file mode 100644 index 0000000..7c4495a --- /dev/null +++ b/src/services/certs/mod.rs @@ -0,0 +1,2 @@ +pub mod acme; +pub mod self_signed; diff --git a/src/services/certs/self_signed.rs b/src/services/certs/self_signed.rs new file mode 100644 index 0000000..21099bf --- /dev/null +++ b/src/services/certs/self_signed.rs @@ -0,0 +1,34 @@ +use crate::{ + Error, Result, + core::{ + handlers::{ + certs::certificate_paths, + filesystem::{safe_path, write_file}, + }, + models::certs::CertificateConfig, + }, + info, +}; +use rcgen::{CertifiedKey, generate_simple_self_signed}; + +pub fn create_self_signed_certificate_files(config: &CertificateConfig) -> Result<()> { + info!("generating self-signed certificate for {}", config.host); + + let subject_alt_names = vec![config.host.to_string()]; + + let (key_path, cert_path) = certificate_paths(&config.host, &config.cert_dir)?; + + let CertifiedKey { + cert, + signing_key, + } = generate_simple_self_signed(subject_alt_names) + .map_err(|e| Error::Certificate(e.to_string()))?; + + let cert_serialized = cert.pem(); + let key_serialized = signing_key.serialize_pem(); + + write_file(cert_path, cert_serialized.as_bytes())?; + write_file(key_path, key_serialized.as_bytes())?; + + Ok(()) +} diff --git a/src/services/dns/mod.rs b/src/services/dns/mod.rs new file mode 100644 index 0000000..cf94684 --- /dev/null +++ b/src/services/dns/mod.rs @@ -0,0 +1,15 @@ +use crate::core::models::dns::Record; +use crate::core::models::routes::Host; +use crate::{Error, Result}; + +trait DnsProvider: Send + Sync + 'static { + /// Only returns single record + /// gets all records but internally filters out all records either, + /// after the call or if it supports it directly on the endpoint call. + fn get_challenge_record(&self, host: Host) -> Result; + /// Single fuction to set and update + /// will return the set record (if returned otherwise fake it) + fn update_challenge_record(&self, host: Host) -> Result; +} + +pub struct CloudflareDns {} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..ad80d1c --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod certs; +pub mod dns;