waf-tls-managed
Managed TLS for Light WAF — the enterprise implementation of the core's
waf_proxy::tls::TlsCertSource seam (BOUNDARY §3.2/§4). Injected via
Proxy::builder(&cfg).cert_source(..) without forking the core.
Two capabilities, both plugged into the SAME trait via the additive seams added in core
0.2 (TlsCertSource::resolver / client_verifier — defaulted methods, so the OPEN
FileCertSource path is unchanged):
| Capability | Seam | What it does |
|---|---|---|
| ACME, hitless | resolver() |
Server-cert provisioning + background renewal that rotates the live cert without restarting the acceptor. TLS-ALPN-01 (single-port, via rustls-acme) or DNS-01 (wildcards / no inbound :443 needed, via instant-acme + a DNS provider). |
| mTLS / managed PKI | client_verifier() |
Requires + verifies client certs against a configured CA bundle. Via rustls WebPkiClientVerifier. |
Config ([tls.acme] / [tls.mtls], in the same file)
The OPEN waf_core::Config ignores these tables; waf-enterprise re-parses the file to
read them. [tls].enabled (OPEN) must be on for any TLS at all.
[]
= true # OPEN: turn on TLS termination
[]
= true
= ["example.com"]
= "ops@example.com"
= "tls-alpn-01" # "tls-alpn-01" (default, single-port) | "dns-01"
= "/var/lib/waf/acme" # caches the ACME account + issued cert (warm restarts)
= true # staging CA (default) — flip to false for production
# directory_url = "https://localhost:14000/dir" # override CA (e.g. a Pebble test server)
# provider = "cloudflare" # DNS-01 only: "cloudflare" | "route53"
# DNS-01 only. Wildcards (`*.example.com`) and works behind NAT (the CA never connects to you).
[]
= "Z123…" # Cloudflare zone id, or Route 53 hosted-zone id
= 120 # max wait for the TXT on the AUTHORITATIVE nameservers
= 5
= 10 # courtesy delay after first sighting (multi-vantage CAs)
= 60
[]
= true
= "/etc/waf/clients-ca.pem" # PEM bundle of trusted client CAs
- ACME off, mTLS on: the server cert still comes from the OPEN
[tls].cert_path/key_path(a file fallback); mTLS only adds client verification. - ACME on: the resolver owns the server cert (the file pair is not needed) and the core
advertises
acme-tls/1so the TLS-ALPN-01 challenge negotiates on the same:443. fail-closed at boot: a missing CA file or an ACME start error refuses to start — never a silent downgrade.
DNS-01 providers
With strategy = "dns-01", pick the DNS provider with [tls.acme].provider. The provider
publishes the _acme-challenge.<domain> TXT, the driver waits for it to appear on the zone's
authoritative nameservers (not the system resolver — avoids a stale-cache false "ready")
plus a grace delay, then asks the CA to validate. Secrets come from the environment, never
the config file.
provider |
Record model | Secrets (env) | Notes |
|---|---|---|---|
cloudflare |
per-record (create/delete by record id) | CF_API_TOKEN |
zone_id = Cloudflare zone id. Token scope Zone:DNS:Edit. |
route53 |
rrset (read-modify-write the value set) | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN, AWS_REGION) |
zone_id = the hosted-zone id. Official aws-sdk-route53 (SigV4). |
Both keep apex + wildcard values that share one _acme-challenge.<base> name coexisting:
Cloudflare adds/removes each value as its own record; Route 53 UPSERTs the full value set.
Posture notes
- Rotation: the hitless path is the SERVER cert (ACME resolver — both TLS-ALPN-01 and DNS-01). The mTLS client-CA roots are loaded once at boot (rotation = restart), like the acceptor.
- DNS-01 cache:
cache_dirpersists the ACME account (so a restart reuses it instead of re-registering) and the issued cert/key (served immediately on restart while renewal runs). - Strategy is global to
[tls.acme]: a domain can't run under both TLS-ALPN-01 and DNS-01 at once.
Verifying ACME end-to-end (manual / CI, against Pebble)
The mTLS handshake and the resolver/ALPN wiring are covered by cargo test. The live ACME
issuance needs a test CA and inbound reachability, so it is a manual/CI step:
# Pebble = Let's Encrypt's test ACME server
# point a config at it: [tls.acme].directory_url = https://localhost:14000/dir, staging=true,
# a domain that resolves to this host, and [tls].enabled = true. Pebble must be able to
# reach the WAF's TLS port for the TLS-ALPN-01 challenge.
WAF_CONFIG=acme.toml
# then: curl --cacert <pebble-root> https://<domain>/ → handshake uses the ACME cert
DNS-01 needs no inbound reachability, so it tests well against a real zone:
# config: strategy="dns-01", provider="route53" (or "cloudflare"), staging=true,
# domains=["*.example.com"], [tls.acme.dns].zone_id=<hosted-zone>
# or CF_API_TOKEN=… for cloudflare
WAF_CONFIG=acme-dns01.toml
# the driver UPSERTs the _acme-challenge TXT, waits for authoritative propagation, then issues.
A fully local DNS-01 run uses Pebble + pebble-challtestsrv (the mock-DNS provider sets the
TXT); see the env-gated tests/dns01_pebble.rs probe for the exact container setup.