httpcaddyfile: inherit global ACME issuer settings in tls shortcuts (#7617)

This commit is contained in:
Zen Dodd 2026-04-26 23:39:57 +10:00 committed by GitHub
parent fdbef2a6ef
commit c1918ff1ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 412 additions and 19 deletions

View File

@ -550,26 +550,11 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
}
case acmeIssuer != nil:
// implicit ACME issuers (from various subdirectives) - use defaults; there might be more than one
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
// if an ACME CA endpoint was set, the user expects to use that specific one,
// not any others that may be defaults, so replace all defaults with that ACME CA
if acmeIssuer.CA != "" {
defaultIssuers = []certmagic.Issuer{acmeIssuer}
}
// implicit ACME issuers (from various subdirectives) should inherit from
// any globally-configured ACME issuer templates, then apply the local
// shortcut settings as overrides.
defaultIssuers := implicitACMEIssuers(h, acmeIssuer)
for _, issuer := range defaultIssuers {
// apply settings from the implicitly-configured ACMEIssuer to any
// default ACMEIssuers, but preserve each default issuer's CA endpoint,
// because, for example, if you configure the DNS challenge, it should
// apply to any of the default ACMEIssuers, but you don't want to trample
// out their unique CA endpoints
if iss, ok := issuer.(*caddytls.ACMEIssuer); ok && iss != nil {
acmeCopy := *acmeIssuer
acmeCopy.CA = iss.CA
issuer = &acmeCopy
}
configVals = append(configVals, ConfigValue{
Class: "tls.cert_issuer",
Value: issuer,

View File

@ -3,7 +3,9 @@ package httpcaddyfile
import (
"encoding/json"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddytls"
_ "github.com/caddyserver/caddy/v2/modules/logging"
@ -166,3 +168,126 @@ func TestGlobalResolversOption(t *testing.T) {
})
}
}
func TestGlobalCertIssuerAppliesToImplicitACMEIssuer(t *testing.T) {
adapter := caddyfile.Adapter{
ServerType: ServerType{},
}
input := `{
cert_issuer acme {
disable_tlsalpn_challenge
}
}
report.company.intern {
tls {
ca https://deglacme01.company.intern/acme/acme/directory
ca_root /etc/certs/company_root2.crt
}
respond "ok"
}`
out, _, err := adapter.Adapt([]byte(input), nil)
if err != nil {
t.Fatalf("adapting caddyfile: %v", err)
}
var config struct {
Apps struct {
TLS *caddytls.TLS `json:"tls"`
} `json:"apps"`
}
if err := json.Unmarshal(out, &config); err != nil {
t.Fatalf("unmarshaling adapted config: %v", err)
}
if config.Apps.TLS == nil || config.Apps.TLS.Automation == nil {
t.Fatal("expected tls automation config")
}
var subjectPolicy *caddytls.AutomationPolicy
for _, ap := range config.Apps.TLS.Automation.Policies {
if len(ap.SubjectsRaw) == 1 && ap.SubjectsRaw[0] == "report.company.intern" {
subjectPolicy = ap
break
}
}
if subjectPolicy == nil {
t.Fatal("expected subject-specific automation policy")
}
if len(subjectPolicy.IssuersRaw) != 1 {
t.Fatalf("expected one issuer for subject-specific policy, got %d", len(subjectPolicy.IssuersRaw))
}
var issuer caddytls.ACMEIssuer
if err := json.Unmarshal(subjectPolicy.IssuersRaw[0], &issuer); err != nil {
t.Fatalf("unmarshaling issuer: %v", err)
}
if issuer.CA != "https://deglacme01.company.intern/acme/acme/directory" {
t.Fatalf("expected custom ACME CA, got %q", issuer.CA)
}
if len(issuer.TrustedRootsPEMFiles) != 1 || issuer.TrustedRootsPEMFiles[0] != "/etc/certs/company_root2.crt" {
t.Fatalf("expected trusted roots to include site CA root, got %v", issuer.TrustedRootsPEMFiles)
}
if issuer.Challenges == nil || issuer.Challenges.TLSALPN == nil || !issuer.Challenges.TLSALPN.Disabled {
t.Fatalf("expected tls-alpn challenge to be disabled, got %#v", issuer.Challenges)
}
}
func TestMergeACMEIssuers(t *testing.T) {
base := &caddytls.ACMEIssuer{
Email: "ops@example.com",
Challenges: &caddytls.ChallengesConfig{
HTTP: &caddytls.HTTPChallengeConfig{
AlternatePort: 8080,
},
TLSALPN: &caddytls.TLSALPNChallengeConfig{
Disabled: true,
AlternatePort: 8443,
},
DNS: &caddytls.DNSChallengeConfig{
Resolvers: []string{"1.1.1.1"},
OverrideDomain: "_acme-challenge.example.net",
},
},
TrustedRootsPEMFiles: []string{"global.pem"},
}
overrides := &caddytls.ACMEIssuer{
CA: "https://deglacme01.company.intern/acme/acme/directory",
Challenges: &caddytls.ChallengesConfig{
HTTP: &caddytls.HTTPChallengeConfig{
Disabled: true,
},
DNS: &caddytls.DNSChallengeConfig{
PropagationTimeout: caddy.Duration(time.Minute),
},
},
TrustedRootsPEMFiles: []string{"site.pem"},
}
merged := mergeACMEIssuers(base, overrides)
if merged.CA != overrides.CA {
t.Fatalf("expected merged CA %q, got %q", overrides.CA, merged.CA)
}
if merged.Email != base.Email {
t.Fatalf("expected merged email %q, got %q", base.Email, merged.Email)
}
if len(merged.TrustedRootsPEMFiles) != 2 || merged.TrustedRootsPEMFiles[0] != "global.pem" || merged.TrustedRootsPEMFiles[1] != "site.pem" {
t.Fatalf("expected merged roots [global.pem site.pem], got %v", merged.TrustedRootsPEMFiles)
}
if merged.Challenges == nil || merged.Challenges.HTTP == nil || !merged.Challenges.HTTP.Disabled || merged.Challenges.HTTP.AlternatePort != 8080 {
t.Fatalf("expected merged HTTP challenge config to preserve alternate port and apply disable flag, got %#v", merged.Challenges)
}
if merged.Challenges.TLSALPN == nil || !merged.Challenges.TLSALPN.Disabled || merged.Challenges.TLSALPN.AlternatePort != 8443 {
t.Fatalf("expected merged TLS-ALPN challenge config to preserve global settings, got %#v", merged.Challenges)
}
if merged.Challenges.DNS == nil || merged.Challenges.DNS.PropagationTimeout != caddy.Duration(time.Minute) || len(merged.Challenges.DNS.Resolvers) != 1 || merged.Challenges.DNS.Resolvers[0] != "1.1.1.1" || merged.Challenges.DNS.OverrideDomain != "_acme-challenge.example.net" {
t.Fatalf("expected merged DNS challenge config to preserve global values and apply overrides, got %#v", merged.Challenges)
}
if base.CA != "" {
t.Fatalf("expected base issuer to remain unchanged, got CA %q", base.CA)
}
if len(base.TrustedRootsPEMFiles) != 1 || base.TrustedRootsPEMFiles[0] != "global.pem" {
t.Fatalf("expected base roots to remain unchanged, got %v", base.TrustedRootsPEMFiles)
}
}

View File

@ -612,6 +612,289 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
return nil
}
// implicitACMEIssuers returns the issuers to use for ACME-related tls
// shortcuts such as ca, ca_root, and dns. If any global cert_issuer options
// configure ACME issuers, those become the templates for the local shortcut
// configuration; otherwise, default ACME issuers are used.
func implicitACMEIssuers(h Helper, acmeIssuer *caddytls.ACMEIssuer) []certmagic.Issuer {
globalIssuers, _ := h.Option("cert_issuer").([]certmagic.Issuer)
var implicitIssuers []certmagic.Issuer
for _, issuer := range globalIssuers {
acmeWrapper, ok := issuer.(acmeCapable)
if !ok {
continue
}
baseIssuer := acmeWrapper.GetACMEIssuer()
if baseIssuer == nil {
continue
}
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
}
if len(implicitIssuers) > 0 {
return implicitIssuers
}
// If an ACME CA endpoint was set locally, the user expects to use only that
// CA rather than the usual default fallback issuers.
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
if acmeIssuer.CA != "" {
defaultIssuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
}
implicitIssuers = make([]certmagic.Issuer, 0, len(defaultIssuers))
for _, issuer := range defaultIssuers {
acmeWrapper, ok := issuer.(acmeCapable)
if !ok {
implicitIssuers = append(implicitIssuers, issuer)
continue
}
baseIssuer := acmeWrapper.GetACMEIssuer()
if baseIssuer == nil {
implicitIssuers = append(implicitIssuers, issuer)
continue
}
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
}
return implicitIssuers
}
func mergeACMEIssuers(base, overrides *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
if base == nil {
return cloneACMEIssuer(overrides)
}
merged := cloneACMEIssuer(base)
if overrides == nil {
return merged
}
if overrides.CA != "" {
merged.CA = overrides.CA
}
if overrides.TestCA != "" {
merged.TestCA = overrides.TestCA
}
if overrides.Email != "" {
merged.Email = overrides.Email
}
if overrides.Profile != "" {
merged.Profile = overrides.Profile
}
if overrides.AccountKey != "" {
merged.AccountKey = overrides.AccountKey
}
if overrides.ExternalAccount != nil {
merged.ExternalAccount = cloneACMEEAB(overrides.ExternalAccount)
}
if overrides.ACMETimeout != 0 {
merged.ACMETimeout = overrides.ACMETimeout
}
if len(overrides.TrustedRootsPEMFiles) > 0 {
merged.TrustedRootsPEMFiles = appendUniqueStrings(merged.TrustedRootsPEMFiles, overrides.TrustedRootsPEMFiles...)
}
if overrides.PreferredChains != nil {
merged.PreferredChains = cloneChainPreference(overrides.PreferredChains)
}
if overrides.CertificateLifetime != 0 {
merged.CertificateLifetime = overrides.CertificateLifetime
}
if len(overrides.NetworkProxyRaw) > 0 {
merged.NetworkProxyRaw = slices.Clone(overrides.NetworkProxyRaw)
}
merged.Challenges = mergeChallengesConfig(merged.Challenges, overrides.Challenges)
return merged
}
func mergeChallengesConfig(base, overrides *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
if base == nil {
return cloneChallengesConfig(overrides)
}
merged := cloneChallengesConfig(base)
if overrides == nil {
return merged
}
merged.HTTP = mergeHTTPChallengeConfig(merged.HTTP, overrides.HTTP)
merged.TLSALPN = mergeTLSALPNChallengeConfig(merged.TLSALPN, overrides.TLSALPN)
merged.DNS = mergeDNSChallengeConfig(merged.DNS, overrides.DNS)
if overrides.BindHost != "" {
merged.BindHost = overrides.BindHost
}
if overrides.Distributed != nil {
value := *overrides.Distributed
merged.Distributed = &value
}
return merged
}
func mergeHTTPChallengeConfig(base, overrides *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
if base == nil {
return cloneHTTPChallengeConfig(overrides)
}
merged := cloneHTTPChallengeConfig(base)
if overrides == nil {
return merged
}
if overrides.Disabled {
merged.Disabled = true
}
if overrides.AlternatePort != 0 {
merged.AlternatePort = overrides.AlternatePort
}
return merged
}
func mergeTLSALPNChallengeConfig(base, overrides *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
if base == nil {
return cloneTLSALPNChallengeConfig(overrides)
}
merged := cloneTLSALPNChallengeConfig(base)
if overrides == nil {
return merged
}
if overrides.Disabled {
merged.Disabled = true
}
if overrides.AlternatePort != 0 {
merged.AlternatePort = overrides.AlternatePort
}
return merged
}
func mergeDNSChallengeConfig(base, overrides *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
if base == nil {
return cloneDNSChallengeConfig(overrides)
}
merged := cloneDNSChallengeConfig(base)
if overrides == nil {
return merged
}
if len(overrides.ProviderRaw) > 0 {
merged.ProviderRaw = slices.Clone(overrides.ProviderRaw)
}
if overrides.PropagationDelay != 0 {
merged.PropagationDelay = overrides.PropagationDelay
}
if overrides.PropagationTimeout != 0 {
merged.PropagationTimeout = overrides.PropagationTimeout
}
if overrides.Resolvers != nil {
merged.Resolvers = slices.Clone(overrides.Resolvers)
}
if overrides.OverrideDomain != "" {
merged.OverrideDomain = overrides.OverrideDomain
}
if overrides.TTL != 0 {
merged.TTL = overrides.TTL
}
return merged
}
func cloneACMEIssuer(iss *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
if iss == nil {
return nil
}
cloned := *iss
cloned.Challenges = cloneChallengesConfig(iss.Challenges)
cloned.ExternalAccount = cloneACMEEAB(iss.ExternalAccount)
cloned.TrustedRootsPEMFiles = slices.Clone(iss.TrustedRootsPEMFiles)
cloned.PreferredChains = cloneChainPreference(iss.PreferredChains)
cloned.NetworkProxyRaw = slices.Clone(iss.NetworkProxyRaw)
return &cloned
}
func cloneChallengesConfig(cfg *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
if cfg == nil {
return nil
}
cloned := *cfg
cloned.HTTP = cloneHTTPChallengeConfig(cfg.HTTP)
cloned.TLSALPN = cloneTLSALPNChallengeConfig(cfg.TLSALPN)
cloned.DNS = cloneDNSChallengeConfig(cfg.DNS)
if cfg.Distributed != nil {
value := *cfg.Distributed
cloned.Distributed = &value
}
return &cloned
}
func cloneHTTPChallengeConfig(cfg *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
return &cloned
}
func cloneTLSALPNChallengeConfig(cfg *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
return &cloned
}
func cloneDNSChallengeConfig(cfg *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
if cfg == nil {
return nil
}
cloned := *cfg
cloned.ProviderRaw = slices.Clone(cfg.ProviderRaw)
cloned.Resolvers = slices.Clone(cfg.Resolvers)
return &cloned
}
func cloneACMEEAB(eab *acme.EAB) *acme.EAB {
if eab == nil {
return nil
}
cloned := *eab
return &cloned
}
func cloneChainPreference(pref *caddytls.ChainPreference) *caddytls.ChainPreference {
if pref == nil {
return nil
}
cloned := *pref
cloned.RootCommonName = slices.Clone(pref.RootCommonName)
cloned.AnyCommonName = slices.Clone(pref.AnyCommonName)
if pref.Smallest != nil {
value := *pref.Smallest
cloned.Smallest = &value
}
return &cloned
}
func appendUniqueStrings(existing []string, additions ...string) []string {
for _, value := range additions {
if !slices.Contains(existing, value) {
existing = append(existing, value)
}
}
return existing
}
// newBaseAutomationPolicy returns a new TLS automation policy that gets
// its values from the global options map. It should be used as the base
// for any other automation policies. A nil policy (and no error) will be