From 0fab9f0f7db04fdb0c1c3113d51e7c420d3c8f06 Mon Sep 17 00:00:00 2001 From: Rijul <31570722+Rijul-A@users.noreply.github.com> Date: Sun, 10 May 2026 19:38:40 +0530 Subject: [PATCH] caddytls: avoid duplicate automation for wildcard-covered hosts (#7697) * caddytls: Fix wildcard race in auto-HTTPS launch When evaluating whether to skip managing an individual subdomain due to an existing wildcard configuration, we now explicitly consult the automate loader. Because Caddy apps can start in any order, relying strictly on the TLS app's internal management state was non-deterministic if the HTTP app started first. Checking the automate loader guarantees predictable behavior since it is fully populated during the Provision phase, well before any apps are started. * respond to review comments 1. update requested comment 2. remove personal domain from test 3. add regression test * remove unnecessary mutex lock * refactor: -integration test, +explicit cases * refactor: remove redundant test, add comment * rename file and add header * update copyright year --- modules/caddytls/tls.go | 11 ++- modules/caddytls/tls_wildcard_test.go | 96 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 modules/caddytls/tls_wildcard_test.go diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index e5f6e6fc0..928e109e6 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -614,8 +614,8 @@ func (t *TLS) Manage(subjects map[string]struct{}) error { // managingWildcardFor returns true if the app is managing a certificate that covers that // subject name (including consideration of wildcards), either from its internal list of -// names that it IS managing certs for, or from the otherSubjsToManage which includes names -// that WILL be managed. +// names that it IS managing certs for, from the otherSubjsToManage which includes names +// that WILL be managed, or from names configured in the 'automate' loader. func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]struct{}) bool { // TODO: we could also consider manually-loaded certs using t.HasCertificateForSubject(), // but that does not account for how manually-loaded certs may be restricted as to which @@ -630,7 +630,9 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str return managing } - // replace labels of the domain with wildcards until we get a match + // replace labels of the domain with wildcards until we get a match from names + // already being managed, those about to be managed in this batch, or those + // configured for automation labels := strings.Split(subj, ".") for i := range labels { if labels[i] == "*" { @@ -644,6 +646,9 @@ func (t *TLS) managingWildcardFor(subj string, otherSubjsToManage map[string]str if _, ok := otherSubjsToManage[candidate]; ok { return true } + if _, ok := t.automateNames[candidate]; ok { + return true + } } return false diff --git a/modules/caddytls/tls_wildcard_test.go b/modules/caddytls/tls_wildcard_test.go new file mode 100644 index 000000000..0151ca5dd --- /dev/null +++ b/modules/caddytls/tls_wildcard_test.go @@ -0,0 +1,96 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddytls + +import ( + "encoding/json" + "testing" + + "github.com/caddyserver/caddy/v2" +) + +func TestAvoidDuplicateAutomation(t *testing.T) { + tests := []struct { + name string + automateNames []string + expectedToManage bool + }{ + { + name: "do not manage if wildcard is automated", + automateNames: []string{"*.example.com"}, + expectedToManage: false, + }, + { + name: "manage if no automation configured", + automateNames: []string{}, + expectedToManage: true, + }, + { + name: "manage if explicitly requested even when wildcard automated", + automateNames: []string{"*.example.com", "sub.example.com"}, + expectedToManage: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + automateJSON, err := json.Marshal(tc.automateNames) + if err != nil { + t.Fatal(err) + } + + tlsApp := &TLS{ + Automation: &AutomationConfig{ + Policies: []*AutomationPolicy{ + { + IssuersRaw: []json.RawMessage{ + []byte(`{"module": "internal"}`), + }, + }, + }, + }, + CertificatesRaw: map[string]json.RawMessage{ + "automate": automateJSON, + }, + } + + var cfg caddy.Config + ctx, err := caddy.ProvisionContext(&cfg) + if err != nil { + t.Fatal(err) + } + + if err := tlsApp.Provision(ctx); err != nil { + t.Fatal(err) + } + + // simulate a case wherein the HTTP app starts first and + // tells the TLS app about the following auto-HTTPS domains + httpDomains := map[string]struct{}{"sub.example.com": {}} + if err := tlsApp.Manage(httpDomains); err != nil { + t.Fatal(err) + } + + _, actuallyManaged := tlsApp.managing["sub.example.com"] + if actuallyManaged != tc.expectedToManage { + t.Errorf( + "expected sub.example.com individually managed: %v, got: %v", + tc.expectedToManage, + actuallyManaged, + ) + } + }) + } +}