Core Concepts

TLS / HTTPS

New to acton-service?

Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See the Glossary for technical term definitions.


acton-service can terminate TLS directly, without a reverse proxy in front, using rustls. This requires the tls feature:

acton-service = { version = "0.39.0", features = ["tls"] }

TLS also needs exactly one rustls crypto provider enabled — see Crypto Provider if you disabled default features.

Enabling the HTTPS listener

Add a [tls] section to your configuration:

[tls]
enabled = true
cert_path = "./certs/server.pem"       # PEM-encoded certificate chain
key_path = "./certs/server-key.pem"    # PEM-encoded private key

enabled defaults to true once the section is present. A [tls] section whose certificate or key cannot be loaded is a hard failure at startup — the service refuses to bind rather than silently falling back to plaintext.

Negotiated protocols

The listener advertises h2 and http/1.1 in ALPN and serves both, so a client picks whichever it prefers: curl at its defaults and every browser take h2, and a client that offers no ALPN at all still works because the server sniffs the protocol. Nothing configures this and nothing needs to.

Before 0.34.1 the HTTP listener advertised h2 without being able to serve it, so an ALPN-honouring client had its connection dropped mid-request with no server-side log line, and only clients that chose http/1.1 worked. If you are on 0.34.0 or earlier and curl --http1.1 succeeds against a verification step where plain curl fails, that is this defect and not your certificates. Upgrade; there is no configuration workaround.

Mutual TLS (verifying client certificates)

Point client_ca_path at a PEM bundle of CA certificates to require connecting clients to present a certificate signed by one of them:

[tls]
enabled = true
cert_path = "./certs/server.pem"
key_path = "./certs/server-key.pem"
client_ca_path = "./certs/client-ca.pem"
client_auth_optional = false
  • client_ca_path omitted (the default) accepts all clients without a certificate — server-only TLS.
  • client_auth_optional = true still accepts clients that present no certificate, but any certificate that is presented must verify — "optional" means "absent is allowed," never "invalid is allowed." Handlers can distinguish the two cases through TlsConnectInfo. This flag is ignored unless client_ca_path is set.

This is calling into this service. To present a client certificate when this service calls another mutual-TLS peer, see the client_tls module described under the tls feature flag.

Authorizing the caller behind the certificate

client_ca_path decides whose certificates are accepted. It does not decide which of those callers may proceed. A certificate issued by a private fleet CA proves only that the CA has, at some point, issued to this principal — so with client_ca_path alone, every workload the CA has ever signed for is admitted identically, and one compromised workload reaches every mutual-TLS route of every peer.

The [caller_auth] section closes that gap by naming the caller from its leaf certificate's subjectAltName and checking that name against an allowlist:

[caller_auth]
mode = "mtls"
allowlist = [
  "spiffe://cluster.local/ns/prod/sa/ingest",
  "reporter.internal",
]

Modes

ModeWhat a caller must present
bearerTokens only. The section is inert — the default.
mtlsAn allowlisted client certificate, in addition to whatever [token] demands.
mtls-or-bearerAn allowlisted certificate or a bearer token.

mtls-or-bearer exists so a fleet can cut over caller-by-caller instead of on a flag day. A caller that has no certificate yet, or has one that is not yet allowlisted, keeps working on its token; a caller whose certificate is allowlisted is admitted on the certificate alone, and the token middleware stands down for that request. Under mtls the certificate is an additional requirement, so a configured token middleware still demands a valid token.

Matching

Entries are matched byte-exactly against the DNS and URI subjectAltName values of the caller's leaf certificate. An entry containing :// is treated as a URI SAN, anything else as a DNS SAN, and the two never match each other.

There is no wildcard, suffix or subdomain matching: reporter.internal does not match REPORTER.INTERNAL, a.reporter.internal or reporter.internal.. A wildcard SAN inside a certificate matches nothing at all — a wildcard names a set of hosts, which is not an identity anything should be authorized as.

Misconfiguration is a startup failure

Configuration that would look like protection without being it is refused before anything binds:

  • a certificate mode on a listener with no client_ca_path, or no TLS at all ([tls] and [grpc.tls] are checked separately, and the error names which one to fix)
  • an allowlist under mode = "bearer", where nothing would ever consult it
  • an empty allowlist under a certificate mode
  • an unknown key in the section — allow_list instead of allowlist would otherwise leave a service that looks allowlisted and admits everyone

Telling refusals apart

CauseHTTPgRPCCode
No client certificate401UNAUTHENTICATEDCLIENT_CERT_REQUIRED
Neither certificate nor token401UNAUTHENTICATEDCALLER_CREDENTIAL_REQUIRED
Certificate unparseable401UNAUTHENTICATEDCLIENT_CERT_UNPARSABLE
Certificate carries no usable SAN403PERMISSION_DENIEDCLIENT_CERT_NO_SAN
Caller not on the allowlist403PERMISSION_DENIEDCALLER_NOT_ALLOWED

401 means nothing was proven; 403 means an identity was proven and is not authorized. The operator of a misconfigured caller needs to tell those apart without access to the server's logs.

Scoping it to specific routes

The configuration section applies the policy to the whole surface. To guard only some routes, apply the layer yourself:

use acton_service::caller_auth::{
    CallerAllowlist, CallerAuthLayer, CallerAuthPolicy, CallerSan,
};

let allowlist = CallerAllowlist::new([
    CallerSan::uri("spiffe://cluster.local/ns/prod/sa/ingest")?,
])?;

let router = Router::new()
    .route("/admin/audit", get(audit_handler))
    .route_layer(CallerAuthLayer::http(CallerAuthPolicy::mtls(allowlist)));

Handlers read the established identity with Extension<CallerIdentity>. Its presence means a verified, allowlisted certificate — the layer never inserts one for a request admitted on a bearer token.

Caller authorization is not policy authorization

A certificate-authorized request carries no Claims, and Cedar derives its principal from claims. Cedar-protected routes therefore still require a bearer token even when the caller is allowlisted here. [caller_auth] is transport-level admission control, not a replacement for Cedar authorization.

It also only sees certificates on a listener that terminates TLS itself. Behind a TLS-terminating proxy there is no client certificate to read, and a certificate mode will refuse every request.

gRPC TLS

The separate-port gRPC listener has its own optional [grpc.tls] section:

[grpc]
enabled = true
use_separate_port = true
port = 9090

[grpc.tls]
enabled = true
cert_path = "./certs/grpc.pem"
key_path = "./certs/grpc.key"

When [grpc.tls] is present it is authoritative for the gRPC listener: enabled = false serves plaintext gRPC even while [tls] is active, useful for a loopback-only gRPC surface. When [grpc.tls] is absent, the gRPC listener falls back to the shared [tls] credentials — reloading either one rotates both.

Rotating credentials without a restart

Credentials loaded from [tls] / [grpc.tls] can be rotated while the service keeps running: a reload replaces what the next handshake uses, while connections already established are undisturbed. A reload that fails — missing, unparseable, or half-written files — is logged at ERROR and leaves the previous certificate serving; rotation can never take the listener down or downgrade it to plaintext.

There are four ways to trigger a reload, from most to least automatic.

Poll the files

Set reload_interval_secs to have the service hash the credential files on an interval and reload only when their contents change:

[tls]
enabled = true
cert_path = "./certs/server.pem"
key_path = "./certs/server-key.pem"
reload_interval_secs = 300

Change is detected by hashing file bytes, not by comparing modification times — cp -p and most certificate-management tooling preserve mtimes across a real rotation, which an mtime check would miss. A tick whose files are missing, unreadable, or half-written is retried on the next tick rather than treated as a rotation, so an in-progress write heals itself. Omit the field to disable polling; 0 is rejected at startup rather than busy-looping.

Reload on SIGHUP

[tls]
reload_on_sighup = true

Setting this on either [tls] or [grpc.tls] installs one handler that reloads every reloadable listener — a signal that rotated only half the surfaces would be confusing to reason about during an incident. Unix only; on other platforms a configured value is reported at WARN during startup and otherwise ignored. Pairs well with systemd ExecReload or a certbot deploy hook.

Register a hook (ServiceBuilder::with_tls_reload)

For triggers the config-driven options above don't model — a Vault lease renewal, a Kubernetes secret watch, an admin endpoint — register a callback. ActonService::serve calls it once, as the listeners come up, with a TlsReloadHandle over every reloadable source the service resolved:

let service = ServiceBuilder::new()
    .with_config(config)
    .with_routes(routes)
    .with_tls_reload(|handle| {
        tokio::spawn(async move {
            let mut events = watch_secret_store().await;
            while events.next().await.is_some() {
                for (listener, result) in handle.reload_all() {
                    if let Err(e) = result {
                        tracing::error!("{listener} TLS reload failed: {e}");
                    }
                }
            }
        });
    })
    .build();

service.serve().await?;

This is the preferred way to drive rotation from your own code: serve() calls the hook itself, so there's no ordering to get wrong. The hook is skipped, with a WARN explaining why, when no reloadable source resolved — TLS disabled, or every source injected as an already-loaded ServerConfig.

Hold the source yourself (tls_config_source / grpc_tls_config_source)

The escape hatch for lifecycles that don't fit a callback. serve() consumes the service, so clone the handle out before calling it:

let service = builder.build();
let tls = service.tls_config_source();          // before serve()
tokio::spawn(async move {
    if let Some(tls) = tls {
        watch_for_new_certs().await;
        let _ = tls.reload();
    }
});
service.serve().await?;                          // consumes the service

grpc_tls_config_source() is the gRPC twin. When [grpc.tls] is absent it returns the same source as tls_config_source(); use TlsConfigSource::ptr_eq to tell that case apart from two genuinely distinct certificates.

The plain Server::serve path

Services that use acton_service::server::Server directly (no ServiceBuilder) get the same reload_interval_secs and reload_on_sighup config-driven triggers, through the same shared implementation — one config file produces the same rotation behavior on either path. There is no with_tls_reload equivalent here, since Server has no builder to register a hook on; a service that needs to reload from a custom trigger should use ServiceBuilder.

Handshake timeout

Each TLS handshake runs concurrently, off the listener's accept path, bounded by a per-connection timeout. A peer that completes the TCP connect but never sends a ClientHello only ties up its own handshake task until the timeout elapses — it cannot stall accepting or handshaking any other connection.

[tls]
enabled = true
cert_path = "./certs/server.pem"
key_path = "./certs/server-key.pem"
handshake_timeout_secs = 10

Omit the field to use the built-in default of 10 seconds. 0 is rejected at startup rather than failing every handshake instantly. [grpc.tls] accepts the same field for the separate-port gRPC listener; when absent there, it inherits the [tls] value.

Unknown keys are rejected

[tls] and [grpc.tls] reject unrecognized keys at startup instead of silently ignoring them — a misspelled field like reload_interval_sec (missing the trailing s) now fails to parse rather than quietly disarming certificate rotation.

Outbound connect timeout

The handshake timeout above bounds connections this service accepts. The mirror image, for connections it makes, is connect_timeout_secs on ClientIdentityConfig:

[client_identity]
enabled = true
cert_path = "./certs/client.pem"
key_path = "./certs/client-key.pem"
root_ca_path = "./certs/peer-ca.pem"
connect_timeout_secs = 30

One budget spans both the TCP connect and the TLS handshake, not one each. A stall can sit in either phase, and two independent bounds would let a peer burn both in sequence.

Omit the field for the built-in default of 30 seconds (client_tls::DEFAULT_CLIENT_CONNECT_TIMEOUT). Unlike the listener's handshake_timeout_secs, a configured 0 resolves to the default rather than being rejected: [client_identity] is deserialized per peer by your code, not by the framework Config, so there is no startup hook to refuse it, and "no override" is the only reading under which the channel still works.

The default is deliberately generous. It exists to stop an indefinite stall, not to enforce latency — a per-RPC deadline is still the Endpoint's timeout.

Previous
Configuration