Skip to main content

Crate acme_proxy

Crate acme_proxy 

Source
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, with database.url the only key that still needs a restart
  • SQLite persistence 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, the Replay-Nonce and Link: rel="index" headers
  • filter - Pluggable request filtering (who may ask at all)
  • extractors - Parse and validate ACME JWS requests, verifying the media type, the crit header, the signature, the JWS url and the nonce before any handler runs
  • handlers - One module per ACME resource
  • challenge - 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 revoke
  • notify - Pluggable operator notifications on lifecycle events (email, webhook, custom)
  • ipam - The inventory filter asks which names an address owns (NetBox, phpIPAM, a custom script), behind one trait
  • eab - 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 up
  • http_client - The transport every outbound HTTP client is built on, including the CONNECT tunnel
  • proxy - Which forward proxy, if any, that transport dials through
  • script_hook - The hardened contract every custom hook runs under
  • tls - Optional HTTPS termination for either listener
  • cert - X.509 parsing helpers (serial, SPKI, leaf-from-chain)
  • pemfile - PEM reading, atomic writing and key-permission warnings
  • sqlite - Database access, one module per table
  • config - Configuration loading from multiple sources
  • error - 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 serves
  • reload - Rebuild-and-swap on SIGHUP; nothing is mutated in place
  • jobs - The durable queue and its runner, so work outlives the process that queued it
  • metrics - 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 to
  • webadmin - The optional HTML + JSON admin listener
  • cli - The clap command 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(
                    &sections.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(
                    &sections.filter,
                    &config.dns,
                    ipam::from_config(&sections.ipam, outbound.clone())?,
                    sections.eab.enabled,
                )?,
                challenges: challenge::from_config(
                    &sections.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 /revokeCert handler (crate::handlers::post_revoke_cert) and the order revoke admin 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 stored leaf + CA PEM 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 newAccount payload 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 keyChange request’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 custom hook 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 sqlx and 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.
GenerationParts
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.
ProfileParts
The subsystems and per-endpoint sections a Profile is assembled from.

Constants§

PROFILE_PREFIX
The URL namespace every ACME endpoint is mounted under: a profile named le serves /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-Nonce minting).
metrics_app
Builds the metrics listener’s router: GET /metrics and nothing else.
millis
A duration in milliseconds, as a log field.