From f2647e38ddf415a7c6dec3de072238676b32bd53 Mon Sep 17 00:00:00 2001 From: steadytao Date: Thu, 2 Apr 2026 15:41:22 +1000 Subject: [PATCH] httpcaddyfile: inherit global ACME issuer settings in tls shortcuts --- caddyconfig/httpcaddyfile/builtins.go | 23 +- caddyconfig/httpcaddyfile/options_test.go | 125 ++++++++++ caddyconfig/httpcaddyfile/tlsapp.go | 283 ++++++++++++++++++++++ 3 files changed, 412 insertions(+), 19 deletions(-) diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go index a7bb3b1de0d..1ad86200ec7 100644 --- a/caddyconfig/httpcaddyfile/builtins.go +++ b/caddyconfig/httpcaddyfile/builtins.go @@ -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, diff --git a/caddyconfig/httpcaddyfile/options_test.go b/caddyconfig/httpcaddyfile/options_test.go index 524187f30e2..50b431d3e33 100644 --- a/caddyconfig/httpcaddyfile/options_test.go +++ b/caddyconfig/httpcaddyfile/options_test.go @@ -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) + } +} diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go index ddec0b941d7..61bddcd5013 100644 --- a/caddyconfig/httpcaddyfile/tlsapp.go +++ b/caddyconfig/httpcaddyfile/tlsapp.go @@ -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