Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 12:15 UTC
httpcaddyfile: inherit global ACME issuer settings in tls shortcuts (#7617)
Zen Dodd committed
Apr 26, 2026 at 23:39 UTC
c1918ff1ad6fd411d828c8ea82ad535c5c793219
3 files changed
+412
-19
caddyconfig/httpcaddyfile/builtins.go
+4
-19
@@ -550,26 +550,11 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
550
}
551
552
case acmeIssuer != nil:
553
- // implicit ACME issuers (from various subdirectives) - use defaults; there might be more than one
554
- defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
555
-
556
- // if an ACME CA endpoint was set, the user expects to use that specific one,
557
- // not any others that may be defaults, so replace all defaults with that ACME CA
558
- if acmeIssuer.CA != "" {
559
- defaultIssuers = []certmagic.Issuer{acmeIssuer}
560
- }
561
-
553
+ // implicit ACME issuers (from various subdirectives) should inherit from
554
+ // any globally-configured ACME issuer templates, then apply the local
555
+ // shortcut settings as overrides.
556
+ defaultIssuers := implicitACMEIssuers(h, acmeIssuer)
557
for _, issuer := range defaultIssuers {
563
- // apply settings from the implicitly-configured ACMEIssuer to any
564
- // default ACMEIssuers, but preserve each default issuer's CA endpoint,
565
- // because, for example, if you configure the DNS challenge, it should
566
- // apply to any of the default ACMEIssuers, but you don't want to trample
567
- // out their unique CA endpoints
568
- if iss, ok := issuer.(*caddytls.ACMEIssuer); ok && iss != nil {
569
- acmeCopy := *acmeIssuer
570
- acmeCopy.CA = iss.CA
571
- issuer = &acmeCopy
572
- }
558
configVals = append(configVals, ConfigValue{
559
Class: "tls.cert_issuer",
560
Value: issuer,
caddyconfig/httpcaddyfile/options_test.go
+125
@@ -3,7 +3,9 @@ package httpcaddyfile
3
import (
4
"encoding/json"
5
"testing"
6
+ "time"
7
8
+ "github.com/caddyserver/caddy/v2"
9
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
10
"github.com/caddyserver/caddy/v2/modules/caddytls"
11
_ "github.com/caddyserver/caddy/v2/modules/logging"
@@ -166,3 +168,126 @@ func TestGlobalResolversOption(t *testing.T) {
168
})
169
}
170
}
171
+
172
+func TestGlobalCertIssuerAppliesToImplicitACMEIssuer(t *testing.T) {
173
+ adapter := caddyfile.Adapter{
174
+ ServerType: ServerType{},
175
+ }
176
+
177
+ input := `{
178
+ cert_issuer acme {
179
+ disable_tlsalpn_challenge
180
+ }
181
+ }
182
+ report.company.intern {
183
+ tls {
184
+ ca https://deglacme01.company.intern/acme/acme/directory
185
+ ca_root /etc/certs/company_root2.crt
186
+ }
187
+ respond "ok"
188
+ }`
189
+
190
+ out, _, err := adapter.Adapt([]byte(input), nil)
191
+ if err != nil {
192
+ t.Fatalf("adapting caddyfile: %v", err)
193
+ }
194
+
195
+ var config struct {
196
+ Apps struct {
197
+ TLS *caddytls.TLS `json:"tls"`
198
+ } `json:"apps"`
199
+ }
200
+ if err := json.Unmarshal(out, &config); err != nil {
201
+ t.Fatalf("unmarshaling adapted config: %v", err)
202
+ }
203
+ if config.Apps.TLS == nil || config.Apps.TLS.Automation == nil {
204
+ t.Fatal("expected tls automation config")
205
+ }
206
+
207
+ var subjectPolicy *caddytls.AutomationPolicy
208
+ for _, ap := range config.Apps.TLS.Automation.Policies {
209
+ if len(ap.SubjectsRaw) == 1 && ap.SubjectsRaw[0] == "report.company.intern" {
210
+ subjectPolicy = ap
211
+ break
212
+ }
213
+ }
214
+ if subjectPolicy == nil {
215
+ t.Fatal("expected subject-specific automation policy")
216
+ }
217
+ if len(subjectPolicy.IssuersRaw) != 1 {
218
+ t.Fatalf("expected one issuer for subject-specific policy, got %d", len(subjectPolicy.IssuersRaw))
219
+ }
220
+
221
+ var issuer caddytls.ACMEIssuer
222
+ if err := json.Unmarshal(subjectPolicy.IssuersRaw[0], &issuer); err != nil {
223
+ t.Fatalf("unmarshaling issuer: %v", err)
224
+ }
225
+ if issuer.CA != "https://deglacme01.company.intern/acme/acme/directory" {
226
+ t.Fatalf("expected custom ACME CA, got %q", issuer.CA)
227
+ }
228
+ if len(issuer.TrustedRootsPEMFiles) != 1 || issuer.TrustedRootsPEMFiles[0] != "/etc/certs/company_root2.crt" {
229
+ t.Fatalf("expected trusted roots to include site CA root, got %v", issuer.TrustedRootsPEMFiles)
230
+ }
231
+ if issuer.Challenges == nil || issuer.Challenges.TLSALPN == nil || !issuer.Challenges.TLSALPN.Disabled {
232
+ t.Fatalf("expected tls-alpn challenge to be disabled, got %#v", issuer.Challenges)
233
+ }
234
+}
235
+
236
+func TestMergeACMEIssuers(t *testing.T) {
237
+ base := &caddytls.ACMEIssuer{
238
+ Email: "ops@example.com",
239
+ Challenges: &caddytls.ChallengesConfig{
240
+ HTTP: &caddytls.HTTPChallengeConfig{
241
+ AlternatePort: 8080,
242
+ },
243
+ TLSALPN: &caddytls.TLSALPNChallengeConfig{
244
+ Disabled: true,
245
+ AlternatePort: 8443,
246
+ },
247
+ DNS: &caddytls.DNSChallengeConfig{
248
+ Resolvers: []string{"1.1.1.1"},
249
+ OverrideDomain: "_acme-challenge.example.net",
250
+ },
251
+ },
252
+ TrustedRootsPEMFiles: []string{"global.pem"},
253
+ }
254
+ overrides := &caddytls.ACMEIssuer{
255
+ CA: "https://deglacme01.company.intern/acme/acme/directory",
256
+ Challenges: &caddytls.ChallengesConfig{
257
+ HTTP: &caddytls.HTTPChallengeConfig{
258
+ Disabled: true,
259
+ },
260
+ DNS: &caddytls.DNSChallengeConfig{
261
+ PropagationTimeout: caddy.Duration(time.Minute),
262
+ },
263
+ },
264
+ TrustedRootsPEMFiles: []string{"site.pem"},
265
+ }
266
+
267
+ merged := mergeACMEIssuers(base, overrides)
268
+ if merged.CA != overrides.CA {
269
+ t.Fatalf("expected merged CA %q, got %q", overrides.CA, merged.CA)
270
+ }
271
+ if merged.Email != base.Email {
272
+ t.Fatalf("expected merged email %q, got %q", base.Email, merged.Email)
273
+ }
274
+ if len(merged.TrustedRootsPEMFiles) != 2 || merged.TrustedRootsPEMFiles[0] != "global.pem" || merged.TrustedRootsPEMFiles[1] != "site.pem" {
275
+ t.Fatalf("expected merged roots [global.pem site.pem], got %v", merged.TrustedRootsPEMFiles)
276
+ }
277
+ if merged.Challenges == nil || merged.Challenges.HTTP == nil || !merged.Challenges.HTTP.Disabled || merged.Challenges.HTTP.AlternatePort != 8080 {
278
+ t.Fatalf("expected merged HTTP challenge config to preserve alternate port and apply disable flag, got %#v", merged.Challenges)
279
+ }
280
+ if merged.Challenges.TLSALPN == nil || !merged.Challenges.TLSALPN.Disabled || merged.Challenges.TLSALPN.AlternatePort != 8443 {
281
+ t.Fatalf("expected merged TLS-ALPN challenge config to preserve global settings, got %#v", merged.Challenges)
282
+ }
283
+ 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" {
284
+ t.Fatalf("expected merged DNS challenge config to preserve global values and apply overrides, got %#v", merged.Challenges)
285
+ }
286
+
287
+ if base.CA != "" {
288
+ t.Fatalf("expected base issuer to remain unchanged, got CA %q", base.CA)
289
+ }
290
+ if len(base.TrustedRootsPEMFiles) != 1 || base.TrustedRootsPEMFiles[0] != "global.pem" {
291
+ t.Fatalf("expected base roots to remain unchanged, got %v", base.TrustedRootsPEMFiles)
292
+ }
293
+}
caddyconfig/httpcaddyfile/tlsapp.go
+283
@@ -612,6 +612,289 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
612
return nil
613
}
614
615
+// implicitACMEIssuers returns the issuers to use for ACME-related tls
616
+// shortcuts such as ca, ca_root, and dns. If any global cert_issuer options
617
+// configure ACME issuers, those become the templates for the local shortcut
618
+// configuration; otherwise, default ACME issuers are used.
619
+func implicitACMEIssuers(h Helper, acmeIssuer *caddytls.ACMEIssuer) []certmagic.Issuer {
620
+ globalIssuers, _ := h.Option("cert_issuer").([]certmagic.Issuer)
621
+
622
+ var implicitIssuers []certmagic.Issuer
623
+ for _, issuer := range globalIssuers {
624
+ acmeWrapper, ok := issuer.(acmeCapable)
625
+ if !ok {
626
+ continue
627
+ }
628
+ baseIssuer := acmeWrapper.GetACMEIssuer()
629
+ if baseIssuer == nil {
630
+ continue
631
+ }
632
+ implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
633
+ }
634
+ if len(implicitIssuers) > 0 {
635
+ return implicitIssuers
636
+ }
637
+
638
+ // If an ACME CA endpoint was set locally, the user expects to use only that
639
+ // CA rather than the usual default fallback issuers.
640
+ defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
641
+ if acmeIssuer.CA != "" {
642
+ defaultIssuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
643
+ }
644
+
645
+ implicitIssuers = make([]certmagic.Issuer, 0, len(defaultIssuers))
646
+ for _, issuer := range defaultIssuers {
647
+ acmeWrapper, ok := issuer.(acmeCapable)
648
+ if !ok {
649
+ implicitIssuers = append(implicitIssuers, issuer)
650
+ continue
651
+ }
652
+ baseIssuer := acmeWrapper.GetACMEIssuer()
653
+ if baseIssuer == nil {
654
+ implicitIssuers = append(implicitIssuers, issuer)
655
+ continue
656
+ }
657
+ implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
658
+ }
659
+ return implicitIssuers
660
+}
661
+
662
+func mergeACMEIssuers(base, overrides *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
663
+ if base == nil {
664
+ return cloneACMEIssuer(overrides)
665
+ }
666
+
667
+ merged := cloneACMEIssuer(base)
668
+ if overrides == nil {
669
+ return merged
670
+ }
671
+
672
+ if overrides.CA != "" {
673
+ merged.CA = overrides.CA
674
+ }
675
+ if overrides.TestCA != "" {
676
+ merged.TestCA = overrides.TestCA
677
+ }
678
+ if overrides.Email != "" {
679
+ merged.Email = overrides.Email
680
+ }
681
+ if overrides.Profile != "" {
682
+ merged.Profile = overrides.Profile
683
+ }
684
+ if overrides.AccountKey != "" {
685
+ merged.AccountKey = overrides.AccountKey
686
+ }
687
+ if overrides.ExternalAccount != nil {
688
+ merged.ExternalAccount = cloneACMEEAB(overrides.ExternalAccount)
689
+ }
690
+ if overrides.ACMETimeout != 0 {
691
+ merged.ACMETimeout = overrides.ACMETimeout
692
+ }
693
+ if len(overrides.TrustedRootsPEMFiles) > 0 {
694
+ merged.TrustedRootsPEMFiles = appendUniqueStrings(merged.TrustedRootsPEMFiles, overrides.TrustedRootsPEMFiles...)
695
+ }
696
+ if overrides.PreferredChains != nil {
697
+ merged.PreferredChains = cloneChainPreference(overrides.PreferredChains)
698
+ }
699
+ if overrides.CertificateLifetime != 0 {
700
+ merged.CertificateLifetime = overrides.CertificateLifetime
701
+ }
702
+ if len(overrides.NetworkProxyRaw) > 0 {
703
+ merged.NetworkProxyRaw = slices.Clone(overrides.NetworkProxyRaw)
704
+ }
705
+ merged.Challenges = mergeChallengesConfig(merged.Challenges, overrides.Challenges)
706
+
707
+ return merged
708
+}
709
+
710
+func mergeChallengesConfig(base, overrides *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
711
+ if base == nil {
712
+ return cloneChallengesConfig(overrides)
713
+ }
714
+ merged := cloneChallengesConfig(base)
715
+ if overrides == nil {
716
+ return merged
717
+ }
718
+
719
+ merged.HTTP = mergeHTTPChallengeConfig(merged.HTTP, overrides.HTTP)
720
+ merged.TLSALPN = mergeTLSALPNChallengeConfig(merged.TLSALPN, overrides.TLSALPN)
721
+ merged.DNS = mergeDNSChallengeConfig(merged.DNS, overrides.DNS)
722
+ if overrides.BindHost != "" {
723
+ merged.BindHost = overrides.BindHost
724
+ }
725
+ if overrides.Distributed != nil {
726
+ value := *overrides.Distributed
727
+ merged.Distributed = &value
728
+ }
729
+
730
+ return merged
731
+}
732
+
733
+func mergeHTTPChallengeConfig(base, overrides *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
734
+ if base == nil {
735
+ return cloneHTTPChallengeConfig(overrides)
736
+ }
737
+ merged := cloneHTTPChallengeConfig(base)
738
+ if overrides == nil {
739
+ return merged
740
+ }
741
+
742
+ if overrides.Disabled {
743
+ merged.Disabled = true
744
+ }
745
+ if overrides.AlternatePort != 0 {
746
+ merged.AlternatePort = overrides.AlternatePort
747
+ }
748
+
749
+ return merged
750
+}
751
+
752
+func mergeTLSALPNChallengeConfig(base, overrides *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
753
+ if base == nil {
754
+ return cloneTLSALPNChallengeConfig(overrides)
755
+ }
756
+ merged := cloneTLSALPNChallengeConfig(base)
757
+ if overrides == nil {
758
+ return merged
759
+ }
760
+
761
+ if overrides.Disabled {
762
+ merged.Disabled = true
763
+ }
764
+ if overrides.AlternatePort != 0 {
765
+ merged.AlternatePort = overrides.AlternatePort
766
+ }
767
+
768
+ return merged
769
+}
770
+
771
+func mergeDNSChallengeConfig(base, overrides *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
772
+ if base == nil {
773
+ return cloneDNSChallengeConfig(overrides)
774
+ }
775
+ merged := cloneDNSChallengeConfig(base)
776
+ if overrides == nil {
777
+ return merged
778
+ }
779
+
780
+ if len(overrides.ProviderRaw) > 0 {
781
+ merged.ProviderRaw = slices.Clone(overrides.ProviderRaw)
782
+ }
783
+ if overrides.PropagationDelay != 0 {
784
+ merged.PropagationDelay = overrides.PropagationDelay
785
+ }
786
+ if overrides.PropagationTimeout != 0 {
787
+ merged.PropagationTimeout = overrides.PropagationTimeout
788
+ }
789
+ if overrides.Resolvers != nil {
790
+ merged.Resolvers = slices.Clone(overrides.Resolvers)
791
+ }
792
+ if overrides.OverrideDomain != "" {
793
+ merged.OverrideDomain = overrides.OverrideDomain
794
+ }
795
+ if overrides.TTL != 0 {
796
+ merged.TTL = overrides.TTL
797
+ }
798
+
799
+ return merged
800
+}
801
+
802
+func cloneACMEIssuer(iss *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
803
+ if iss == nil {
804
+ return nil
805
+ }
806
+
807
+ cloned := *iss
808
+ cloned.Challenges = cloneChallengesConfig(iss.Challenges)
809
+ cloned.ExternalAccount = cloneACMEEAB(iss.ExternalAccount)
810
+ cloned.TrustedRootsPEMFiles = slices.Clone(iss.TrustedRootsPEMFiles)
811
+ cloned.PreferredChains = cloneChainPreference(iss.PreferredChains)
812
+ cloned.NetworkProxyRaw = slices.Clone(iss.NetworkProxyRaw)
813
+
814
+ return &cloned
815
+}
816
+
817
+func cloneChallengesConfig(cfg *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
818
+ if cfg == nil {
819
+ return nil
820
+ }
821
+
822
+ cloned := *cfg
823
+ cloned.HTTP = cloneHTTPChallengeConfig(cfg.HTTP)
824
+ cloned.TLSALPN = cloneTLSALPNChallengeConfig(cfg.TLSALPN)
825
+ cloned.DNS = cloneDNSChallengeConfig(cfg.DNS)
826
+ if cfg.Distributed != nil {
827
+ value := *cfg.Distributed
828
+ cloned.Distributed = &value
829
+ }
830
+
831
+ return &cloned
832
+}
833
+
834
+func cloneHTTPChallengeConfig(cfg *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
835
+ if cfg == nil {
836
+ return nil
837
+ }
838
+
839
+ cloned := *cfg
840
+ return &cloned
841
+}
842
+
843
+func cloneTLSALPNChallengeConfig(cfg *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
844
+ if cfg == nil {
845
+ return nil
846
+ }
847
+
848
+ cloned := *cfg
849
+ return &cloned
850
+}
851
+
852
+func cloneDNSChallengeConfig(cfg *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
853
+ if cfg == nil {
854
+ return nil
855
+ }
856
+
857
+ cloned := *cfg
858
+ cloned.ProviderRaw = slices.Clone(cfg.ProviderRaw)
859
+ cloned.Resolvers = slices.Clone(cfg.Resolvers)
860
+
861
+ return &cloned
862
+}
863
+
864
+func cloneACMEEAB(eab *acme.EAB) *acme.EAB {
865
+ if eab == nil {
866
+ return nil
867
+ }
868
+
869
+ cloned := *eab
870
+ return &cloned
871
+}
872
+
873
+func cloneChainPreference(pref *caddytls.ChainPreference) *caddytls.ChainPreference {
874
+ if pref == nil {
875
+ return nil
876
+ }
877
+
878
+ cloned := *pref
879
+ cloned.RootCommonName = slices.Clone(pref.RootCommonName)
880
+ cloned.AnyCommonName = slices.Clone(pref.AnyCommonName)
881
+ if pref.Smallest != nil {
882
+ value := *pref.Smallest
883
+ cloned.Smallest = &value
884
+ }
885
+
886
+ return &cloned
887
+}
888
+
889
+func appendUniqueStrings(existing []string, additions ...string) []string {
890
+ for _, value := range additions {
891
+ if !slices.Contains(existing, value) {
892
+ existing = append(existing, value)
893
+ }
894
+ }
895
+ return existing
896
+}
897
+
898
// newBaseAutomationPolicy returns a new TLS automation policy that gets
899
// its values from the global options map. It should be used as the base
900
// for any other automation policies. A nil policy (and no error) will be