Expand description
ACME (RFC 8555) Server Implementation
This is a server-side implementation of the ACME protocol (RFC 8555) for issuing and managing SSL/TLS certificates. It serves as a backend for certificate clients like certbot and acme.sh.
§Features
- The full RFC 8555 flow: directory, newNonce, newAccount, account lookup/update and deactivation, newOrder, authorizations and challenges, finalize, certificate retrieval via signed POST-as-GET, and revocation
- JWS signature verification for EC (ES256) and RSA (RS256) keys
- Automatic nonce management with replay protection
- Challenge validation behind pluggable validators (
http-01,dns-01,tls-alpn-01), with a configurable bypass - Certificate issuance behind a pluggable signer backend: a local CA (whose key may live in a PKCS#11 token), a relay to an upstream ACME CA, or an operator-supplied script
- Profiles — several independent ACME endpoints in one process, each with its own signer, filters, challenge validators and EAB policy
- External Account Binding (§7.3.4), account key rollover (§7.3.5) and Renewal Information (RFC 9773)
- Access control behind a policy engine of named checks combined by boolean rules, including an IPAM lookup (NetBox, phpIPAM or a script) asking the inventory whether the client’s own address owns the names it is requesting
- An append-only audit trail of every issuance and every refusal
- An optional web admin listener, and admin subcommands in the same binary
- Optional Prometheus metrics on a third listener of their own
- A durable job queue, so work the server owes itself survives a restart and an upstream blip is retried rather than invalidating a client’s order
- Configuration reload on
SIGHUP— a rebuild and a swap, withdatabase.urlthe only key that still needs a restart SQLitepersistence for accounts, nonces, orders and the audit trail- Configurable via TOML, environment variables, or defaults
§Architecture
The ACME request path, in the order a request meets it:
middlewares- Server-wide layers: request correlation and the access line, admission control, theReplay-NonceandLink: rel="index"headersfilter- Pluggable request filtering (who may ask at all)extractors- Parse and validate ACME JWS requests, verifying the media type, thecritheader, the signature, the JWSurland the nonce before any handler runshandlers- One module per ACME resourcechallenge- Pluggable challenge validators (http-01, dns-01, tls-alpn-01)signer- Pluggable certificate-issuance backends (local CA, ACME relay, custom script)
Supporting subsystems:
audit- The durable record of who asked this CA to sign or revokenotify- Pluggable operator notifications on lifecycle events (email, webhook, custom)ipam- The inventoryfilterasks which names an address owns (NetBox, phpIPAM, a custom script), behind one traiteab- Verification of the External Account Binding inner JWS (§7.3.4)key_change- Verification of account key rollover JWS (§7.3.5)dns- The resolver shared by every subsystem that looks anything uphttp_client- The transport every outbound HTTP client is built on, including theCONNECTtunnelproxy- Which forward proxy, if any, that transport dials throughscript_hook- The hardened contract everycustomhook runs undertls- Optional HTTPS termination for either listenercert- X.509 parsing helpers (serial, SPKI, leaf-from-chain)pemfile- PEM reading, atomic writing and key-permission warningssqlite- Database access, one module per tableconfig- Configuration loading from multiple sourceserror- ACME error types and problem document rendering
Process lifecycle — what keeps the server running and lets it be retuned without a restart:
listener- The sockets, and replacing one while it servesreload- Rebuild-and-swap onSIGHUP; nothing is mutated in placejobs- The durable queue and its runner, so work outlives the process that queued itmetrics- The Prometheus registry and its text exposition
Administration, which serves no ACME and is a second listener plus a CLI:
admin- The operation layer both front ends dispatch towebadmin- The optional HTML + JSON admin listenercli- Theclapcommand tree, and the startup path itself
§Usage
The main entry point is build_app(), which mounts one ACME router per
configured profile under /profile/<name> and serves the server-level
routes (/health) at the root.
use std::net::SocketAddr;
use std::sync::Arc;
use acme_proxy::{
Profile, ProfileParts, build_app, challenge, config::Config, filter, ipam, jobs, notify,
signer, sqlite::db::Database,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Arc::new(Config::load()?);
let database = Arc::new(Database::connect(&config.database.url).await?);
let resolved = config.resolve_profiles()?;
// The resolver and the proxy policy, resolved before anything can dial:
// a proxy URL that cannot be understood must stop the process rather
// than leave egress elsewhere, and `dns.resolver` governs every outbound
// connection this server makes, not just challenge lookups. Bundled,
// because every outbound client takes them together — and because the
// rendering beside them is what tells a reload whether a signer backend
// has to be rebuilt.
let egress = Arc::new(acme_proxy::Egress::from_config(&config)?);
let outbound = egress.outbound();
// The enqueue side of the durable queue, built first because everything
// below queues into it. A backend that defers issuance (`relay`) is
// handed one at construction, and so is every notify dispatcher — a
// notification is a job row too. The runner that drains it is started
// separately, below.
let job_queue = jobs::JobQueue::new(database.clone(), &config.jobs);
// Built once, up front: an asynchronous signer backend (`relay`)
// has no `Profile` to reach a notifier through from its background
// completion task, so it is handed this whole map instead — and so is
// the `NotifyJob` that performs the deliveries.
let mut notifiers = std::collections::HashMap::new();
for profile in &resolved {
notifiers.insert(
profile.name.clone(),
notify::from_config(
&profile.name,
&profile.sections.notify,
outbound.clone(),
&job_queue,
)?,
);
}
let notifiers = Arc::new(notifiers);
// The Prometheus counters. Built here rather than per generation, so a
// `SIGHUP` does not reset every counter to zero — see `Assembly`.
let metrics = Arc::new(acme_proxy::metrics::Metrics::new(database.clone()));
let mut profiles = Vec::new();
for profile in &resolved {
let sections = &profile.sections;
profiles.push(Arc::new(Profile::new(
&profile.name,
&config.server.base_url,
ProfileParts {
signer: signer::from_config(
§ions.signer,
vec![profile.name.clone()],
&signer::SignerParts {
database: database.clone(),
notifiers: notifiers.clone().into(),
metrics: metrics.clone(),
egress: egress.clone(),
jobs: job_queue.clone(),
},
// Nothing to adopt at startup; a reload passes what the
// previous generation's backends handed over.
&signer::CarriedState::new(),
)?,
filter: filter::from_config(
§ions.filter,
&config.dns,
ipam::from_config(§ions.ipam, outbound.clone())?,
sections.eab.enabled,
)?,
challenges: challenge::from_config(
§ions.challenge,
&config.dns,
egress.proxies.clone(),
)?,
order: sections.order.clone(),
eab: sections.eab.clone(),
meta: sections.meta.clone(),
notify: notifiers[&profile.name].clone(),
},
)));
}
// Process-wide, like `[audit]` itself: one trail for the whole CA,
// shared by every profile's router and by the web admin listener.
// The registry is a parameter rather than a builder step, so a serving
// process cannot build an auditor that counts into nothing. The counters
// come off the same `AuditRecord` the trail is written from, so the two
// can never disagree.
let audit = Arc::new(acme_proxy::audit::Auditor::from_config(
&config.audit,
&config.dns,
database.clone(),
metrics.clone(),
)?);
let app = build_app(
database.clone(),
config.clone(),
profiles,
audit,
metrics.clone(),
);
// One runner drains the queue for the process. Every handler comes from
// a subsystem that has background work — `SignerBackend::jobs`,
// notification delivery, and the periodic table sweeps — and the runner
// calls `recover` on each before it claims anything, which is how work a
// previous run left in flight is picked back up, and how each sweep's
// single row gets queued.
let mut registry = jobs::JobRegistry::new();
registry.register(Arc::new(notify::NotifyJob::new(notifiers)))?;
registry.register(Arc::new(jobs::SweepJob::nonces(
database.clone(),
std::time::Duration::from_secs(config.nonce.ttl_seconds),
)))?;
let (_shutdown, shutdown_rx) = tokio::sync::watch::channel(false);
jobs::spawn_runner(job_queue, Arc::new(registry), &config.jobs, shutdown_rx);
let listener = tokio::net::TcpListener::bind(&config.server.bind_address).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
Ok(())
}Re-exports§
pub use handlers::helpers::is_wildcard;pub use handlers::helpers::normalize_dns_name;pub use handlers::helpers::well_formed_name;
Modules§
- admin
- Administrative operations, shared by both front ends and owned by neither.
- audit
- The CA’s audit trail: who asked this server to sign or withdraw a certificate, from where, and how it ended.
- cert
- Small X.509 helpers shared by the certificate revocation paths: the ACME
POST /revokeCerthandler (crate::handlers::post_revoke_cert) and theorder revokeadmin CLI command (crate::admin::revoke_order). Both need to pull a certificate’s serial/public key out of raw DER, and pull the leaf back out of a storedleaf + CAPEM chain, so the parsing lives here once rather than twice. - challenge
- Challenge-validation abstraction.
- cli
- The command tree, and the startup path itself.
- config
- ACME Proxy Configuration Management
- dns
- DNS lookups, behind a trait.
- eab
- External Account Binding (RFC 8555 §7.3.4): verification of the inner HMAC
JWS a
newAccountpayload may carry, proving the client holds a pre-shared credential an operator issued out-of-band. - error
- extractors
- Request extraction and JWS verification — the security core of the crate.
- filter
- Request filtering: named checks, boolean rules over them, and the machinery that turns a request into one answer.
- handlers
- ACME resource handlers, one module per resource.
- http_
client - The transport half of this server’s four outbound HTTP clients.
- ipam
- IP address management: which names does an address own?
- jobs
- The durable job runner: background work that survives the process.
- key_
change - Account Key Rollover (RFC 8555 §7.3.5): verification of the nested inner
JWS a
keyChangerequest’s payload carries, proving simultaneous possession of both the old and new account keys. - listener
- The sockets, and replacing one while it is serving.
- metrics
- The Prometheus exposition endpoint (
GET /metrics,[metrics]). - middlewares
- Tower layers, split by what they are allowed to see.
- notify
- Operator notifications for ACME lifecycle events.
- pemfile
- PEM material on disk: reading a certificate chain or a private key, and writing a key that is never briefly world-readable.
- proxy
[proxy]— which forward proxy, if any, an outbound connection goes through.- reload
- Replacing a running configuration without restarting the process.
- routes
- The ACME resource paths, profile-relative.
- script_
hook - The contract every
customhook in this server runs under: one script, a cleared environment, JSON on stdin, an exit code for the verdict. - signer
- Certificate-issuance abstraction.
- sqlite
- Persistence: one module per table, over
sqlxand SQLite. - tls
- HTTPS termination for the server’s own listener.
- webadmin
- The web admin interface: a second HTTP listener, serving no ACME.
Structs§
- AppState
- Shared application state handed to every route via
State<AppState>. - Assembly
- What survives a configuration reload.
- Egress
- The outbound plumbing one configuration generation dials through, and the identity of the configuration it came from.
- Generation
Parts - The three things one configuration generation contributes to its profiles, built before any of them is published.
- Profile
- One ACME endpoint: its identity, its URLs, and the three subsystems that answer for it.
- Profile
Parts - The subsystems and per-endpoint sections a
Profileis assembled from.
Constants§
- PROFILE_
PREFIX - The URL namespace every ACME endpoint is mounted under: a profile named
leserves/profile/le/directory.
Functions§
- build_
app - build_
router - Builds one profile’s ACME router: every RFC 8555 resource, plus the two
layers that are per-endpoint (its filter chain) or ACME-specific (the
Replay-Nonceminting). - metrics_
app - Builds the metrics listener’s router:
GET /metricsand nothing else. - millis
- A duration in milliseconds, as a log field.