@marcel91200 / caddy Mirror · read-only / commits / c1918ff1
Fork 0

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