ACME for off-fleet clients
Scenario: an ACME client running outside the Incus fleet (XCP-ng-side VM,
third-party host, anything that didn’t go through this repo’s init Ansible
role) wants to issue a cert from heimdall (the rrchnm internal step-ca) for a
*.rrchnm.internal hostname.
Canonical worked example: the kuvasz.rrchnm.internal cert, bound up
2026-05-19 by a Caddy container on an XCP-ng docker VM (pochita at
10.112.113.210), fronted by the XCP-ng HAProxy pair (agni/rudra at
.73/.74, VIPs at .70/.71). Six distinct landmines, each with its own
fix. This note exists so the next person/client through the same gauntlet
doesn’t re-debug them.
Related: internal-cert-authority.md + internal-ca-bootstrap.md (operator
notes, not published), and internal-dns
(zone authoring).
TL;DR
For any off-fleet ACME client wanting a heimdall-issued cert:
- Trust — distribute the rrchnm root CA cert to the client; in Caddy
point at it via the
trusted_rootsdirective in the ACME issuer block (not the OS trust store — scoped narrower). - Resolution — add a DNS A record for the cert subject via the new
dns_zonesmechanism inansible/group_vars/dns_resolvers.yaml; re-runmake dns. - Challenge selection — DNS-01 doesn’t work yet (static CoreDNS rejects RFC 2136 UPDATEs). TLS-ALPN-01 needs PROXY-protocol handling if the network path uses TCP-passthrough load balancing. Use HTTP-01.
- HAProxy frontend match — ensure a
:80frontend exists with ause_backend ... if { hdr(Host) -i <name> }rule routing the.well-known/acme-challenge/path to the docker VM. - Reachability — the trap. If ICMP/ARP succeed but TCP times out from heimdall to the LB, the LB’s F5-return policy routing is black-holing internal-LAN replies. Fix on the LB side — see landmine #6.
The six landmines (in order encountered)
1. Client doesn’t trust heimdall’s serving cert
Symptom (Caddy log):
HTTP request failed; retrying {"url": "https://heimdall.rrchnm.internal/acme/acme/directory","error": "performing request: ... tls: failed to verify certificate:x509: certificate signed by unknown authority"}Cause: the Caddy container’s OS trust store doesn’t include the rrchnm
internal root. (Fleet hosts get this via the init Ansible role’s ca-trust
task at init_ca_cert_path = /usr/local/share/ca-certificates/rrchnm-internal-ca.crt;
off-fleet clients don’t.)
Fix — Caddyfile trusted_roots:
kuvasz.rrchnm.internal { reverse_proxy ... tls { issuer acme { dir https://heimdall.rrchnm.internal/acme/acme/directory email syschnm@gmu.edu trusted_roots /etc/caddy/rrchnm-internal-ca.crt } }}The trusted_roots directive scopes the trust override to ACME calls only
— the rest of Caddy keeps using the OS trust store for non-ACME TLS. Bind-
mount the root cert into the container; permissions don’t matter (Caddy
reads it once at config-load time).
Naming gotcha: the JSON config field is
ca_root. The Caddyfile directive istrusted_roots(plural, accepts a space-separated list of PEM file paths). Mixing them givesunrecognized ACME issuer property: ca_rootat parse time.
2. Caddyfile syntax: explicit issuer vs implicit shortcuts conflict
Symptom:
parsing caddyfile tokens for 'tls': cannot mix issuer subdirective(explicit issuers) with other issuer-specific subdirectives (implicit issuers)Cause: the tls <email> shorthand syntax (tls syschnm@gmu.edu {...})
is itself an implicit-issuer subdirective. You can’t combine it with an
explicit issuer acme { ... } block.
Fix — move email inside the issuer block:
tls { <-- no email arg issuer acme { dir ... email syschnm@gmu.edu <-- email lives here trusted_roots ... }}3. DNS-01 challenge fails REFUSED against CoreDNS file plugin
Symptom:
solving challenges: presenting for challenge:adding temporary record for zone "rrchnm.internal.":dns response error code "REFUSED" (5)Cause: Caddy was configured for DNS-01 (often via an rfc2136 dns provider
block) and is trying to publish _acme-challenge.<name>.rrchnm.internal TXT
via RFC 2136 dynamic DNS UPDATE against mesprit/azelf. The CoreDNS file
plugin serves static zones and refuses UPDATEs.
Fix today: drop the dns ... block from the Caddyfile; Caddy defaults to
HTTP-01 + TLS-ALPN-01.
Fix future: wildcard cert use cases will need DNS-01 — and this has
since HAPPENED, differently than planned: authority moved to Knot DNS on
arceus, which accepts RFC2136 + TSIG natively (see
internal-dns). Landmine #3 only applies
to clients still pointed at the forward-only resolvers.
4. DNS A record missing for the cert subject
Symptom: Caddy log shows challenge_type: http-01 then a long hang with
no follow-up. The hang is heimdall trying to resolve <name>.rrchnm.internal
to know where to send the HTTP-01 callback.
Cause: the CoreDNS zone has no A record. The three existing mechanisms
in coredns.zone.j2 don’t cover off-fleet services:
| Mechanism | Source | Fit for off-fleet? |
|---|---|---|
| Per-host A records | inventory.yaml (ansible_host) | Bad fit — adds a fake “host” to inventory |
| CNAMEs | dns_service_aliases in group_vars/dns_resolvers.yaml | No — CNAME target must be an inventory host |
| Cilium Gateway A records | ansible/vars/k0s-gateways.yaml | No — k0s only |
Fix: new fourth mechanism dns_zones added to group_vars/dns_resolvers.yaml,
schema mirrors the XCP-ng ansible repo’s dns_zones var (cross-repo copy-paste
consistency during migration). Rendered by coredns.zone.j2 between the
service-alias CNAMEs and the Cilium-gateway A records.
dns_zones: - fqdn: rrchnm.internal resource_record_sets: - type: A name: kuvasz ips: [10.112.12.73, 10.112.12.74] # agni + rudra HOST IPs, NOT the F5-NAT'd VIPsCritical pattern: point internal-only hostnames at the LB hosts’ own
IPs (.73/.74 for agni/rudra), not the F5-NAT’d VIPs (.70/.71).
See landmine #6 below for the full reasoning — short version: the LB hosts’
mangle chain force-routes all responses from the public VIPs through the
F5-return path, which breaks direct-LAN replies. Sourcing responses from the
host IPs instead bypasses that trap entirely. Round-robin across both host
IPs mirrors the master-master VRRP pattern of the existing VIP split.
Template whitespace gotcha (debugged live): with Jinja
trim_blocks=True(Ansible default) andlstrip_blocks=False, putting{%-(left-strip) on a block tag immediately after an output line eats the output line’s trailing newline — records render concatenated on a single line. Pattern to follow: bare{% for %}/{% endfor %}(no minus) bracketing the output-producing inner loop; use{%- ... %}only on logic-only tags. See the existing inventory-A-record loop incoredns.zone.j2as the reference idiom.
5. HTTP-01 viable, TLS-ALPN-01 blocked by PROXY-protocol on TCP backend
The XCP-ng HAProxy frontend pair routes kuvasz differently for the two paths:
| Path | Backend mode | Send-proxy-v2 |
|---|---|---|
:80 HTTP | mode http (HTTP-aware forwarding) | no |
:443 HTTPS | mode tcp (TLS passthrough + SNI) | yes |
TLS-ALPN-01 cannot succeed through the HTTPS path without listener-side
PROXY-protocol awareness in Caddy. HAProxy prepends a 16-byte binary PROXY-v2
header before forwarding TCP bytes; Caddy on container :443 sees PROXY v2 header || TLS ClientHello, tries to parse the header as TLS, handshake fails,
challenge can’t complete. To enable it would need a Caddy global block:
{ servers { listener_wrappers { proxy_protocol { allow 10.112.0.0/16 } tls } }}HTTP-01 is unaffected — mode http rewrites cleanly, no PROXY-v2 in the
way. Simplest fix: explicit disable_tlsalpn_challenge in the Caddy issuer
block keeps Caddy on the HTTP-01 path and avoids the PROXY-v2 dead end.
issuer acme { ... disable_tlsalpn_challenge}6. The HAProxy hairpin-routing trap — ICMP works, TCP times out
Symptom: from heimdall, ping to the LB addresses is fine (sub-ms, ARP
REACHABLE), but nc -zv <ip> 80 times out — same on :443.
Cause (conceptually): the LB hosts carry F5-return policy routing —
responses leaving with sport ∈ {80, 443} are marked and routed via the
F5-return gateway instead of the normal LAN gateway. External F5’d traffic
needs this (F5 SNATs requests, so replies must return through it); internal
direct-LAN callers get black-holed — their SYN arrives, HAProxy’s SYN-ACK
takes the F5 path, and never reaches them via the LAN.
Fix (LB-side, summarized): discriminate by the source IP of the response — only responses originating from the F5-NAT’d VIPs take the F5-return path; responses from the LB hosts’ own IPs (i.e. direct-LAN connections) use the normal gateway. This pairs with landmine #4’s pattern (internal-only hostnames resolve to the LB hosts’ own IPs, never the F5-NAT’d VIPs), which is what makes the discriminant reliable — no F5-side cooperation needed. Two earlier destination-based attempts failed before the source-IP filter landed; the concrete ruleset and iteration history are operator-only (preserved in this note’s operator-side stub).
Diagnostic checklist (next time)
For any future off-fleet client failing to ACME against heimdall, walk in order — earliest-failing landmine is the one to fix first:
- Trust — from client:
curl -v https://heimdall.rrchnm.internal/acme/acme/directory. Should NOT say “unknown authority.” If it does → step 1. - DNS — from heimdall (or any fleet host):
dig +short <name>.rrchnm.internal. Should return real IP(s). If NXDOMAIN → step 4 (add todns_zones). - TCP reach — from heimdall:
nc -zv <ip> 80. Should succeed in <1s. If timeout (ping works, TCP doesn’t) → step 6 (LB mangle trap). - HAProxy routing — from heimdall:
curl -v -H 'Host: <name>.rrchnm.internal' http://<vip>/.well-known/acme-challenge/probe.404 + Server: Caddy→ path clean, just no live token.503 Service Unavailable→ HAProxy backend marked DOWN (health check fails because Caddy answers 4xx to OPTIONS/; removecheckor tune it).Connection refused→ no:80frontend, or wrong ACL.
- Caddy challenge port — container
:80must be port-published to the docker VM. Container’s Caddy must bind:80for HTTP-01 callbacks. If the port-publish maps to a non-80 port externally, addalt_http_port <port>to the issuer block.
Reusable pattern (any off-fleet ACME client)
Caddy is one client. The same six landmines apply to lego, certbot, cert- manager-elsewhere, acme.sh, etc. Generic recipe:
- Distribute root — drop the rrchnm internal root CA at a path the client
reads. Caddy:
trusted_roots. lego:LEGO_CA_CERTIFICATES. certbot: OS trust store +--server. acme.sh:CA_BUNDLE. Avoid stuffing it into the OS trust store unless other tools on the same host also need it. - Point at heimdall’s ACME directory —
https://heimdall.rrchnm.internal/acme/acme/directoryor the aliashttps://ca.rrchnm.internal/acme/acme/directory(thecaCNAME comes fromdns_service_aliasesingroup_vars/dns_resolvers.yaml). - Add A record for the cert subject via
dns_zones(this repo) AND/OR the XCP-ng ansible repo’s matching var. Runmake dns. Verify withdigfrom heimdall’s vantage. - Use HTTP-01 unless wildcards are needed.
disable_tlsalpn_challengeor equivalent if the network path has PROXY-protocol on:443. - Wire up the HAProxy
:80frontend rule (if traffic traverses HAProxy).use_backend http-<name>.rrchnm.internal if { hdr(Host) -i <name>.rrchnm.internal } !{ ssl_fc }. Matchip_whitelistACL or equivalent. - Verify TCP reachability from heimdall to the LB VIPs. ICMP-works-but- TCP-times-out is the mangle-trap signature — fix on the LB side.
Changes landed (this repo)
ansible/group_vars/dns_resolvers.yaml— addeddns_zonesblock. Kuvasz A records point at the LB hosts’ own IPs (10.112.12.73/74, agni/rudra), NOT the F5-NAT’d VIPs (.70/.71). Inline comment documents the “internal-only hostnames go to host IPs” rule for future entries. Also renamedinit_ca_cert→init_ca_root_certeverywhere inansible/(parallel rename for clarity vs intermediate; see commit history).ansible/playbooks/dns/templates/coredns.zone.j2— render section fordns_zones; supports A + CNAME with optional per-recordttl.ansible/README.md—dns_zonesdocumented as the fourth zone-record mechanism.
Changes landed (XCP-ng ansible repo, not this repo)
route_web_for_lbmangle chain on the LB pair — final form filters by response source IP (landmine #6 above; ruleset details operator-only). heimdall’s HTTP-01 validation succeeded immediately after the fix — the run ofacme:error:connectionerrors ended at 18:42:44 withstatus: valid.- Optional cleanup deferred: tighten the LB host-firewall
:80/:443accept with source restrictions (visibility hygiene; not required).
End-to-end verification (2026-05-19, post-bringup)
heimdall step-ca log proves the chain end-to-end:
2026-05-19T18:42:44 http-01 challenge validated for kuvasz.rrchnm.internal2026-05-19T18:42:45 cert issued issuer="RRCHNM Internal Intermediate CA" sans="map[dns:[kuvasz.rrchnm.internal]]" serial=316998321906591282463559570770140021861 valid-from "2026-05-19T18:40:25Z" valid-to "2026-05-20T18:41:25Z"24h cert lifetime is heimdall’s defaultTLSCertDuration claim
(ca.json’s ACME provisioner claims block — set at 24h; see the CA design
operator note). Caddy will auto-renew on its default ~2/3-lifetime schedule.
Final-state verification (2026-05-19, after the iteration ended): with
DNS pointing at [.73, .74] and the mangle filtering on saddr:
# from heimdall — direct-LAN reach to LB host IPs (no F5 path)$ incus exec hyperion:heimdall -- nc -zv 10.112.12.73 80Connection to 10.112.12.73 80 port [tcp/http] succeeded!$ incus exec hyperion:heimdall -- nc -zv 10.112.12.74 80Connection to 10.112.12.74 80 port [tcp/http] succeeded!
# from operator workstation (10.112.112.x):$ curl --cacert rrchnm-internal-ca.crt https://kuvasz.rrchnm.internal # works$ curl https://teachinghistory.org # works (F5 hairpin)Both paths green, no F5 involvement.
Last updated: 2026-05-19. Bound up the same session that introduced the
dns_zones mechanism, the init_ca_root_cert rename, and the LB-side
mangle-chain fix.