Skip to content

fix: Source Push sent the apex certificate instead of the wildcard (#153) - #156

Merged
X4Applegate merged 1 commit into
mainfrom
fix/issue-153
Oct 26, 2026
Merged

X4Applegate merged 1 commit into
mainfrom
fix/issue-153

Conversation

@X4Applegate

Copy link
Copy Markdown
Owner

Fixes #153.

Symptom

A Managed ACME certificate definition listing both *.example.com and
example.com pushes the wrong certificate to its fleet targets. After a
Source Push the target presents a certificate valid only for the bare apex, so
every subdomain fails TLS with a name-mismatch error — while the source node
itself keeps serving all names correctly, which makes it look like a
target-only or network problem.

Root cause

Caddy stores *.example.com and example.com as two separate
certificates
in its storage tree, because a wildcard SAN does not cover its
own apex.

findStorageCertificate in internal/server/certificate_export.go chose
between stored candidates on latest NotAfter alone. The apex is normally
the newer of the two — it gets added to an existing wildcard definition later —
so it expires later, won the comparison, and was handed to Source Push. The
exported keypair therefore presented only example.com.

Expiry was a reasonable tie-break when a definition mapped to one stored
certificate; it was never a correct way to decide which name set to ship.

Fix

Selection now follows domain coverage, with expiry only breaking a tie:

  • certificateCoversName matches a SAN against a requested name the way TLS
    does, including single-label wildcard matching, and deliberately does not
    treat *.example.com as cover for a bare example.com.
  • storageCertificateCoverage scores a candidate against the requested names
    and returns the ones it does not present. A wildcard name scores 2, a
    concrete name scores 1
    — see the proof below for why that weighting is
    load-bearing rather than cosmetic.
  • findStorageCertificate picks the highest score, falling back to latest
    NotAfter on a tie, so the v2.42.0 export feature and renewal pickup keep
    their exact previous behaviour for any single-name definition.

A target certificate row holds one keypair, and fleet_deployments is keyed one
target row per source certificate, so one push structurally cannot carry both
certificates. Rather than drop the uncovered names silently, they are recorded
on the source's activity log (internal/server/fleet_sync.go) with the
remedy: give them their own certificate definition and push targets.

Tests and the proof they fail without the fix

New: internal/server/certificate_storage_coverage_test.go,
internal/server/fleet_sync_wildcard_push_test.go.

Proof 1 — revert the selector to the old latest-NotAfter-only behaviour:

--- FAIL: TestFindStorageCertificatePrefersBroadestCoverage (0.00s)
    certificate_storage_coverage_test.go:35: selected .../example.com/example.com.crt, want the wildcard certificate
--- FAIL: TestSourcePushPrefersWildcardOverApexWhenBothAreStored (0.39s)
    fleet_sync_wildcard_push_test.go:62: pushed certificate presents [example.com] — it does not cover a subdomain, so HTTPS breaks on the target
FAIL	github.com/X4Applegate/caddyui/internal/server	2.882s

The second failure message is the reported bug verbatim: the pushed certificate
presents only [example.com].

Proof 2 — keep coverage scoring but remove the wildcard weighting (score
+1 for every covered name instead of +2 for a wildcard):

--- FAIL: TestFindStorageCertificatePrefersBroadestCoverage (0.00s)
    certificate_storage_coverage_test.go:35: selected .../example.com/example.com.crt, want the wildcard certificate
--- FAIL: TestSourcePushPrefersWildcardOverApexWhenBothAreStored (0.38s)
    fleet_sync_wildcard_push_test.go:62: pushed certificate presents [example.com] — it does not cover a subdomain, so HTTPS breaks on the target
FAIL	github.com/X4Applegate/caddyui/internal/server	2.960s

This is the important one. Coverage scoring alone does not fix the bug: for
a *.example.com + example.com definition the wildcard and the apex each
cover exactly one of the two requested names, so it is a 1–1 tie and the expiry
tie-break re-selects the apex. The wildcard must outweigh a concrete name —
because it protects every subdomain behind it — or the fix silently regresses.
Please keep that weighting if this code is refactored.

With the fix in place, go test ./... and go vet ./... pass on this branch
with a clean tree.

Review notes

  • Rebased onto main (a2735a9, = v2.68.5) so this is a single standalone
    commit. It was originally developed stacked on fix/issue-151 ([Bug]: Stale "Issuance error" persists after successful ACME renewal information update #151); the
    two bugs are unrelated and touch disjoint files, so the rebase was clean and
    verified to produce an identical tree. Reviewable and mergeable in either
    order.
  • No CHANGELOG entry here — that belongs to the release commit.
  • Draft on purpose: no merge or release has been approved yet.

)

A Managed ACME definition listing both "*.example.com" and "example.com"
is stored by Caddy as two separate certificates. findStorageCertificate
chose between them on latest NotAfter alone, so the apex — normally the
newer one, because it was added to the definition later — won, and
Source Push shipped it to the fleet targets. The target then presented a
certificate valid only for the bare domain and every subdomain failed
TLS, while the source itself kept serving correctly.

Selection now follows domain coverage, with expiry only breaking a tie,
so the v2.42.0 export feature and renewal pickup keep their behaviour
for a single-name definition. A wildcard scores above a concrete name
because it protects every subdomain behind it; otherwise the wildcard
and the apex each cover exactly one requested name and the expiry
tie-break reintroduces the bug.

One target certificate row holds one keypair, and fleet_deployments is
keyed one target row per source certificate, so a single push cannot
carry both certificates. Rather than fail silently, the names the chosen
certificate does not present are recorded on the source's activity log
with the fix: give them their own certificate definition and push
targets.
@X4Applegate
X4Applegate marked this pull request as ready for review October 26, 2026 01:14
@X4Applegate
X4Applegate merged commit a1a3bec into main Oct 26, 2026
7 checks passed
@X4Applegate
X4Applegate deleted the fix/issue-153 branch October 26, 2026 01:14
@claude

claude Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Claude finished @X4Applegate's task in 18s —— View job


Review of #156 (already merged)

The fix is correct. I found no blocking issues.

Minor, non-blocking

  1. fleet_sync.go:543 logs the "does not cover" activity entry on every Source Push. For a wildcard + apex definition, the log will carry that warning on every sync, even though the setup is working as designed. If the activity log gets noisy, dedupe it or log only when the uncovered set changes.
  2. In findStorageCertificate, storageCertificateCoverage runs once per candidate path, and the loop reads the same file once per domain in domains. The result is the same each time. Hoisting the per-issuer work is a cheap tidy-up, not a correctness problem.
  3. Weighting limit: a definition with many concrete names plus one wildcard, where a separate certificate covers more concrete names than the wildcard certificate does, could favour the concrete-heavy one. For example, *.x.com (2) loses to a certificate covering 3 apex-style names. That is unlikely, since Caddy issues one certificate per definition. Even so, a code comment could state the intent "wildcard beats any single concrete name", not "beats any number of them".

The PR body says there is no CHANGELOG entry because it belongs to the release commit. That is fine as long as the v2.68.6 release commit adds one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Source Push breaks HTTPS on target servers when a Managed ACME certificate includes both apex and wildcard domains

1 participant