#![cfg_attr(docsrs, feature(doc_cfg))]
use std::any::Any;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{HeaderValue, Request, header};
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
middleware::Next,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tracing::{Span, info};
pub mod admin;
pub mod audit;
pub mod cert;
pub mod challenge;
pub mod cli;
pub mod config;
pub mod dns;
pub mod eab;
pub mod error;
pub mod extractors;
pub mod filter;
pub mod handlers;
pub mod http_client;
pub mod ipam;
pub mod jobs;
pub mod key_change;
pub mod listener;
pub mod metrics;
pub mod middlewares;
pub mod notify;
pub mod pemfile;
pub mod proxy;
mod random;
pub mod reload;
pub mod script_hook;
pub mod signer;
pub mod sqlite;
mod templating;
#[cfg(test)]
pub(crate) mod testutil;
pub mod tls;
pub mod webadmin;
use crate::challenge::ChallengeRegistry;
use crate::config::Config;
use crate::error::Problem;
use crate::filter::FilterPolicy;
use crate::notify::NotifyDispatcher;
use crate::signer::SignerBackend;
use crate::sqlite::db::Database;
pub use handlers::helpers::{is_wildcard, normalize_dns_name, well_formed_name};
pub mod routes {
pub const DIRECTORY: &str = "/directory";
pub const NEW_NONCE: &str = "/newNonce";
pub const NEW_ACCOUNT: &str = "/newAccount";
pub const NEW_ORDER: &str = "/newOrder";
pub const REVOKE_CERT: &str = "/revokeCert";
pub const KEY_CHANGE: &str = "/keyChange";
pub const RENEWAL_INFO: &str = "/renewalInfo";
pub const CRL: &str = "/crl";
pub const CA_CHAIN: &str = "/ca.pem";
}
pub const PROFILE_PREFIX: &str = "/profile";
#[must_use]
pub fn millis(duration: std::time::Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
pub struct Profile {
pub name: String,
pub path: String,
pub base_url: String,
pub signer: Arc<dyn SignerBackend>,
pub filter: Arc<FilterPolicy>,
pub challenges: Arc<ChallengeRegistry>,
pub order: config::OrderConfig,
pub eab: config::EabConfig,
pub meta: config::MetaConfig,
pub notify: Arc<NotifyDispatcher>,
}
pub struct ProfileParts {
pub signer: Arc<dyn SignerBackend>,
pub filter: Arc<FilterPolicy>,
pub challenges: Arc<ChallengeRegistry>,
pub order: config::OrderConfig,
pub eab: config::EabConfig,
pub meta: config::MetaConfig,
pub notify: Arc<NotifyDispatcher>,
}
impl Profile {
pub fn new(name: &str, base_url: &str, parts: ProfileParts) -> Self {
let path = format!("{PROFILE_PREFIX}/{name}");
Self {
name: name.to_string(),
base_url: format!("{}{path}", base_url.trim_end_matches('/')),
path,
signer: parts.signer,
filter: parts.filter,
challenges: parts.challenges,
order: parts.order,
eab: parts.eab,
meta: parts.meta,
notify: parts.notify,
}
}
#[must_use]
pub fn directory_url(&self) -> String {
format!("{}{}", self.base_url, routes::DIRECTORY)
}
pub fn build_all(
config: &Config,
database: Arc<Database>,
jobs: &crate::jobs::JobQueue,
) -> anyhow::Result<Vec<Arc<Profile>>> {
let resolved = config.resolve_profiles()?;
let (_assembly, first) = Assembly::new(&resolved, database, jobs.clone(), config)?;
Self::build_all_with(config, &resolved, &first)
}
pub fn build_all_with(
config: &Config,
resolved: &[config::ProfileConfig],
generation: &GenerationParts,
) -> anyhow::Result<Vec<Arc<Profile>>> {
let egress = &generation.egress;
let dispatchers = &generation.dispatchers;
let backends = &generation.signers;
let mut profiles = Vec::with_capacity(resolved.len());
for profile in resolved {
let sections = &profile.sections;
let span = tracing::info_span!("profile", profile = %profile.name);
let (filter, challenges) = span.in_scope(|| {
let ipam = ipam::from_config(§ions.ipam, egress.outbound())
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
let filter =
filter::from_config(§ions.filter, &config.dns, ipam, sections.eab.enabled)
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
let challenges = challenge::from_config(
§ions.challenge,
&config.dns,
egress.proxies.clone(),
)
.map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
check_request_timeout(config, profile.name.as_str(), sections)?;
Ok::<_, anyhow::Error>((filter, challenges))
})?;
profiles.push(Arc::new(Profile::new(
&profile.name,
&config.server.base_url,
ProfileParts {
signer: backends
.get(&profile.name)
.ok_or_else(|| {
anyhow::anyhow!("profile `{}`: no signer backend", profile.name)
})?
.clone(),
filter,
challenges,
order: sections.order.clone(),
eab: sections.eab.clone(),
meta: sections.meta.clone(),
notify: dispatchers[&profile.name].clone(),
},
)));
}
Ok(profiles)
}
}
pub struct Egress {
pub resolver: Arc<dyn dns::Resolver>,
pub proxies: Arc<proxy::OutboundProxies>,
pub identity: String,
}
impl Egress {
pub fn from_config(config: &Config) -> anyhow::Result<Self> {
let proxies = crate::proxy::from_config(&config.proxy)?;
let resolver = challenge::build_resolver(crate::dns::resolver_addr(&config.dns)?)?;
Ok(Self {
resolver,
proxies,
identity: format!("{:?}|{:?}", config.dns, config.proxy),
})
}
#[must_use]
pub fn outbound(&self) -> http_client::Outbound {
http_client::Outbound::new(self.resolver.clone(), self.proxies.clone())
}
}
pub struct GenerationParts {
pub egress: Arc<Egress>,
pub dispatchers: notify::DispatcherMap,
pub signers: signer::SignerSet,
}
pub struct Assembly {
pub database: Arc<Database>,
pub jobs: crate::jobs::JobQueue,
pub metrics: Arc<metrics::Metrics>,
pub notifiers: notify::Notifiers,
notifiers_tx: notify::NotifiersSender,
signers: std::sync::Mutex<signer::SignerSet>,
}
impl Assembly {
pub fn new(
resolved: &[config::ProfileConfig],
database: Arc<Database>,
jobs: crate::jobs::JobQueue,
config: &Config,
) -> anyhow::Result<(Self, GenerationParts)> {
let metrics = Arc::new(metrics::Metrics::new(database.clone()));
let (notifiers_tx, notifiers) = notify::notifiers_channel(notify::DispatcherMap::new());
let assembly = Self {
database,
jobs,
metrics,
notifiers,
notifiers_tx,
signers: std::sync::Mutex::new(signer::SignerSet::default()),
};
let parts = assembly.build_parts(resolved, config)?;
assembly.publish_notifiers(parts.dispatchers.clone());
assembly.publish_signers(parts.signers.clone());
Ok((assembly, parts))
}
pub fn build_parts(
&self,
resolved: &[config::ProfileConfig],
config: &Config,
) -> anyhow::Result<GenerationParts> {
let egress = Arc::new(Egress::from_config(config)?);
let mut dispatchers = notify::build_registry(resolved, egress.outbound(), &self.jobs)?;
if config.admin.enabled {
dispatchers.insert(
notify::ADMIN_DISPATCHER_KEY.to_string(),
notify::from_config(
notify::ADMIN_DISPATCHER_KEY,
&config.admin.notify,
egress.outbound(),
&self.jobs,
)?,
);
}
let previous = self
.signers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let signers = signer::build_backends(
resolved,
&signer::SignerParts {
database: self.database.clone(),
notifiers: self.notifiers.clone(),
metrics: self.metrics.clone(),
egress: egress.clone(),
jobs: self.jobs.clone(),
},
&previous,
)?;
Ok(GenerationParts {
egress,
dispatchers,
signers,
})
}
pub fn publish_notifiers(&self, dispatchers: notify::DispatcherMap) {
self.notifiers_tx.send_replace(Arc::new(dispatchers));
}
pub fn publish_signers(&self, signers: signer::SignerSet) {
*self
.signers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = signers;
}
}
fn check_request_timeout(
config: &Config,
name: &str,
sections: &config::ProfileSections,
) -> anyhow::Result<()> {
let deadline = config.server.request_timeout_ms;
let inline = [
("challenge.timeout_ms", sections.challenge.timeout_ms),
(
"signer.custom.timeout_ms",
if sections.signer.backend == "custom" {
sections.signer.custom.timeout_ms
} else {
0
},
),
];
for (key, budget) in inline {
anyhow::ensure!(
deadline > budget,
"profile `{name}`: server.request_timeout_ms ({deadline}) must exceed {key} \
({budget}) — that hook runs inside the request, so a shorter deadline would cut \
off work that was going to succeed and report it to the client as a server failure",
);
}
Ok(())
}
#[derive(Clone)]
pub struct AppState {
pub database: Arc<Database>,
pub config: Arc<Config>,
pub profile: Arc<Profile>,
pub audit: Arc<audit::Auditor>,
}
fn http01_stores(profiles: &[Arc<Profile>]) -> Vec<Arc<dyn signer::Http01TokenStore>> {
let mut stores: Vec<Arc<dyn signer::Http01TokenStore>> = Vec::new();
for profile in profiles {
if let Some(store) = profile.signer.http01_tokens()
&& !stores.iter().any(|existing| Arc::ptr_eq(existing, &store))
{
stores.push(store);
}
}
stores
}
pub(crate) fn security_headers() -> (
SetResponseHeaderLayer<HeaderValue>,
SetResponseHeaderLayer<HeaderValue>,
SetResponseHeaderLayer<HeaderValue>,
) {
(
SetResponseHeaderLayer::overriding(
header::STRICT_TRANSPORT_SECURITY,
HeaderValue::from_static("max-age=31536000; includeSubDomains"),
),
SetResponseHeaderLayer::overriding(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
),
SetResponseHeaderLayer::overriding(
header::X_FRAME_OPTIONS,
HeaderValue::from_static("DENY"),
),
)
}
pub(crate) fn panic_message(err: &(dyn Any + Send)) -> &str {
err.downcast_ref::<&'static str>()
.copied()
.or_else(|| err.downcast_ref::<String>().map(String::as_str))
.unwrap_or("a handler panicked")
}
fn acme_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
tracing::error!(
event = "request_handler_panicked",
outcome = "failure",
listener = "acme",
error = %panic_message(err.as_ref()),
);
Problem::server_internal("Internal server error").into_response()
}
pub fn catch_panic_acme() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
CatchPanicLayer::custom(acme_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response)
}
pub fn build_app(
database: Arc<Database>,
config: Arc<Config>,
profiles: Vec<Arc<Profile>>,
audit: Arc<audit::Auditor>,
metrics: Arc<metrics::Metrics>,
) -> Router {
let mut root = Router::new()
.route("/", get(|| async { Redirect::temporary("/health") }))
.route("/health", get(handlers::get_health_check));
let stores = http01_stores(&profiles);
if !stores.is_empty() {
info!(
event = "http_01_responder_mounted",
outcome = "advisory",
path = challenge::http_01::WELL_KNOWN_PREFIX,
stores = stores.len(),
"a reverse proxy must forward or redirect \
http://<identifier>:80/.well-known/acme-challenge/ here for the upstream to reach it"
);
root = root.merge(
Router::new()
.route(
&format!("{}{{token}}", challenge::http_01::WELL_KNOWN_PREFIX),
get(handlers::get_challenge_file),
)
.with_state(handlers::Http01Stores(Arc::new(stores))),
);
}
let mut acme = Router::new();
for profile in &profiles {
let path = profile.path.clone();
acme = acme.nest(
&path,
build_router(
database.clone(),
config.clone(),
profile.clone(),
audit.clone(),
),
);
}
let server = &config.server;
let acme = acme
.layer(middleware::from_fn_with_state(
middlewares::admission::Admission::new(
server.max_concurrent_requests,
server.admission_wait_ms,
server.request_timeout_ms,
),
middlewares::admission::admission_middleware,
))
.layer(DefaultBodyLimit::max(server.max_body_bytes));
let app = root.merge(acme);
let app = app.layer(catch_panic_acme());
let app = if config.metrics.enabled {
app.layer(middleware::from_fn_with_state(
metrics,
middlewares::metrics::record_request,
))
} else {
app
};
app.layer(security_headers())
.layer(middleware::from_fn(
middlewares::access::add_access_middleware,
))
}
pub fn metrics_app(metrics: Arc<metrics::Metrics>) -> Router {
Router::new()
.route("/metrics", get(handlers::get_metrics))
.with_state(handlers::MetricsState(metrics))
.layer(middleware::from_fn(
middlewares::access::add_access_middleware,
))
}
pub fn build_router(
database: Arc<Database>,
config: Arc<Config>,
profile: Arc<Profile>,
audit: Arc<audit::Auditor>,
) -> Router {
let filter = profile.filter.clone();
let state = AppState {
database: database.clone(),
config,
profile: profile.clone(),
audit,
};
let profile_name = profile.name.clone();
let index_link =
HeaderValue::from_str(&format!("<{}/directory>;rel=\"index\"", profile.base_url));
let router = Router::<AppState>::new()
.route(
routes::DIRECTORY,
get(handlers::get_directory).post(handlers::post_directory),
)
.route(
routes::NEW_NONCE,
get(handlers::get_new_nonce)
.head(handlers::head_new_nonce)
.post(handlers::post_new_nonce),
)
.route(routes::NEW_ACCOUNT, post(handlers::post_new_account))
.route("/acct/{id}", post(handlers::post_account))
.route("/acct/{id}/orders", post(handlers::post_account_orders))
.route(routes::KEY_CHANGE, post(handlers::post_key_change))
.route(routes::NEW_ORDER, post(handlers::post_new_order))
.route("/order/{id}", post(handlers::post_order))
.route("/order/{id}/finalize", post(handlers::post_finalize))
.route("/authz/{id}", post(handlers::post_authz))
.route("/chall/{id}", post(handlers::post_challenge))
.route("/certificate/{id}", post(handlers::post_certificate))
.route(routes::REVOKE_CERT, post(handlers::post_revoke_cert))
.route(
&format!("{}/{{id}}", routes::RENEWAL_INFO),
get(handlers::get_renewal_info),
)
.route(routes::CRL, get(handlers::get_crl))
.route(routes::CA_CHAIN, get(handlers::get_ca_chain))
.method_not_allowed_fallback(|| async {
Problem::method_not_allowed("This resource must be read with POST-as-GET")
})
.fallback(|| async { Problem::not_found("No such resource") })
.with_state(state)
.layer(middleware::from_fn_with_state(
filter,
middlewares::filter::add_filter_middleware,
))
.layer(middleware::from_fn_with_state(
database.clone(),
middlewares::nonce::add_nonce_middleware,
));
let router = match index_link {
Ok(value) => router.layer(middleware::from_fn_with_state(
value,
middlewares::index_link::add_index_link_middleware,
)),
Err(error) => {
tracing::error!(
event = "request_index_link_header_invalid",
outcome = "failure",
base_url = %profile.base_url,
error = %error,
);
router
}
};
router.layer(middleware::from_fn(
move |request: Request<Body>, next: Next| {
let name = profile_name.clone();
async move {
Span::current().record("profile", &*name);
next.run(request).await
}
},
))
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from(body: &str) -> Config {
let _lock = crate::config::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = crate::testutil::TempDir::new("lib");
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
fn two_profiles_config(dir: impl AsRef<std::path::Path>) -> Config {
let dir = dir.as_ref();
let a = dir.join("a");
let b = dir.join("b");
config_from(&format!(
r#"
[challenge]
enabled = ["http-01"]
bypass = true
[profiles.a]
signer.local_ca.cert_path = "{a}.pem"
signer.local_ca.key_path = "{a}.key"
signer.local_ca.crl_path = "{a}.crl"
[profiles.b]
challenge.bypass = false
signer.local_ca.cert_path = "{b}.pem"
signer.local_ca.key_path = "{b}.key"
signer.local_ca.crl_path = "{b}.crl"
"#,
a = a.display(),
b = b.display(),
))
}
async fn database() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
#[tokio::test]
async fn build_all_assembles_every_endpoint_from_its_own_configuration() {
let dir = crate::testutil::TempDir::new("build");
let config = two_profiles_config(&dir);
let profiles = Profile::build_all(
&config,
database().await,
&crate::testutil::idle_job_queue(database().await),
)
.unwrap();
assert_eq!(profiles.len(), 2);
assert_eq!(profiles[0].name, "a");
assert_eq!(profiles[0].path, "/profile/a");
assert_eq!(profiles[0].base_url, "http://localhost:3000/profile/a");
assert!(profiles[0].challenges.is_bypassed());
assert!(!profiles[1].challenges.is_bypassed());
assert_eq!(profiles[1].challenges.enabled_types(), ["http-01"]);
}
#[tokio::test]
async fn build_all_refuses_a_configuration_that_mounts_nothing() {
let config = config_from("[server]\nbase_url = \"http://acme.test\"\n");
let error = match Profile::build_all(
&config,
database().await,
&crate::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("a server with no endpoint must not start"),
};
assert!(error.contains("[profiles.default]"), "{error}");
}
#[tokio::test]
async fn build_all_names_the_profile_a_failure_came_from() {
let config = config_from(
r#"
[profiles.le]
challenge.enabled = ["not-a-challenge"]
"#,
);
let error = match Profile::build_all(
&config,
database().await,
&crate::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("an unknown challenge type is a startup error"),
};
assert!(error.contains("profile `le`"), "{error}");
assert!(error.contains("not-a-challenge"), "{error}");
}
#[tokio::test]
async fn build_all_refuses_a_deadline_shorter_than_an_inline_hook() {
let config = config_from(
r#"
[server]
request_timeout_ms = 1000
[profiles.le]
challenge.timeout_ms = 5000
"#,
);
let error = match Profile::build_all(
&config,
database().await,
&crate::testutil::idle_job_queue(database().await),
) {
Err(error) => error.to_string(),
Ok(_) => panic!("a deadline below challenge.timeout_ms is a startup error"),
};
assert!(error.contains("profile `le`"), "{error}");
assert!(error.contains("request_timeout_ms"), "{error}");
assert!(error.contains("challenge.timeout_ms"), "{error}");
}
#[tokio::test]
async fn an_unused_custom_signer_timeout_does_not_constrain_the_deadline() {
let config = config_from(
r#"
[server]
request_timeout_ms = 2000
[signer.custom]
script_path = "/bin/true"
timeout_ms = 30000
[profiles.le]
challenge.timeout_ms = 1000
"#,
);
assert!(
Profile::build_all(
&config,
database().await,
&crate::testutil::idle_job_queue(database().await)
)
.is_ok()
);
}
mod catch_panic {
use super::*;
use axum::body::to_bytes;
use axum::http::StatusCode;
use axum::routing::get;
use tower::ServiceExt;
#[test]
fn panic_message_covers_every_payload_shape() {
assert_eq!(panic_message(&"boom"), "boom");
assert_eq!(panic_message(&String::from("boom")), "boom");
assert_eq!(panic_message(&0u8), "a handler panicked");
}
#[tokio::test]
async fn acme_panic_response_is_a_problem_document() {
let response = acme_panic_response(Box::new("secret internal detail"));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
);
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let problem: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
assert_eq!(problem["status"], 500);
assert!(
!body_contains(&body, "secret internal detail"),
"the panic message must not reach the client",
);
}
fn body_contains(bytes: &[u8], needle: &str) -> bool {
std::str::from_utf8(bytes)
.map(|s| s.contains(needle))
.unwrap_or(false)
}
async fn boom() -> &'static str {
panic!("this handler panics on purpose")
}
fn app() -> Router {
Router::new()
.route("/ok", get(|| async { "ok" }))
.route("/boom", get(boom))
.layer(catch_panic_acme())
}
#[tokio::test]
async fn a_panicking_route_answers_a_problem_document() {
let response = app()
.oneshot(Request::get("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
);
}
#[tokio::test]
async fn the_layer_is_transparent_on_the_happy_path() {
let response = app()
.oneshot(Request::get("/ok").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
}