diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go index dd35d13a3..52061331f 100644 --- a/caddyconfig/httpcaddyfile/options.go +++ b/caddyconfig/httpcaddyfile/options.go @@ -65,6 +65,7 @@ func init() { RegisterGlobalOption("persist_config", parseOptPersistConfig) RegisterGlobalOption("dns", parseOptDNS) RegisterGlobalOption("tls_resolvers", parseOptTLSResolvers) + RegisterGlobalOption("tls_automate_names", parseOptAutomateNames) RegisterGlobalOption("ech", parseOptECH) RegisterGlobalOption("renewal_window_ratio", parseOptRenewalWindowRatio) } @@ -320,6 +321,22 @@ func parseOptTLSResolvers(d *caddyfile.Dispenser, _ any) (any, error) { return resolvers, nil } +// parseOptAutomateNames parses the tls_automate_names global option, which +// names subjects to manage certificates for without serving them. Repeating +// the option appends to the list rather than replacing it, so a long list can +// be split over several lines. +func parseOptAutomateNames(d *caddyfile.Dispenser, existing any) (any, error) { + d.Next() // consume option name + names := d.RemainingArgs() + if len(names) == 0 { + return nil, d.ArgErr() + } + if previous, ok := existing.([]string); ok { + names = append(previous, names...) + } + return names, nil +} + func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) { d.Next() // consume option name diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go index 649c59fae..833586e8d 100644 --- a/caddyconfig/httpcaddyfile/tlsapp.go +++ b/caddyconfig/httpcaddyfile/tlsapp.go @@ -424,6 +424,58 @@ func (st ServerType) buildTLSApp( } al = append(al, name) } + // names from the tls_automate_names global option are managed without a + // site block of their own, so that asking for a certificate does not also + // mean serving the name; like force_automate, an explicitly listed name is + // managed even where auto-HTTPS would not have chosen it. Names that cannot + // get a public certificate are given the internal issuer, the same + // treatment they would get from a site block. + if automateNames, ok := options["tls_automate_names"].([]string); ok { + var publicNames []string + for _, name := range automateNames { + if slices.Contains(al, name) { + continue + } + al = append(al, name) + // a name that a site block already wrote a policy for keeps that + // policy: it is more specific than anything the global options can + // say, and a second policy naming the same subject is ambiguous -- + // adapting would fail outright. The name still belongs in the + // automate loader, since a site block served only over HTTP does + // not get its certificate managed by auto-HTTPS. + if automationPolicyExistsForSubject(tlsApp.Automation, name) { + continue + } + if certmagic.SubjectQualifiesForPublicCert(name) { + publicNames = append(publicNames, name) + } else { + internalAP.SubjectsRaw = append(internalAP.SubjectsRaw, name) + } + } + // the names still need an automation policy of their own, or they would + // miss the issuer configured by global options -- the catch-all policy + // that would otherwise carry it is dropped once every other policy + // names its subjects. Consolidation folds this back into an identical + // policy, so a name listed here ends up in the same place it would have + // had it been given a site block. + if len(publicNames) > 0 { + // only worth a policy if it would carry something: either global + // automation options, or ACME defaults filled in further below. + // Without either, the names are managed with the defaults anyway, + // and an empty policy would just be noise in the output. + ap, err := newBaseAutomationPolicy(options, warnings, hasGlobalACMEDefaults(options)) + if err != nil { + return nil, warnings, err + } + if ap != nil { + ap.SubjectsRaw = publicNames + if tlsApp.Automation == nil { + tlsApp.Automation = new(caddytls.AutomationConfig) + } + tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, ap) + } + } + } slices.Sort(al) // to stabilize the adapt output if len(al) > 0 { tlsApp.CertificatesRaw["automate"] = caddyconfig.JSON(al, &warnings) @@ -440,12 +492,7 @@ func (st ServerType) buildTLSApp( if tlsApp.Automation != nil { globalEmail := options["email"] globalACMECA := options["acme_ca"] - globalACMECARoot := options["acme_ca_root"] - _, globalACMEDNS := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set - globalACMEEAB := options["acme_eab"] - globalPreferredChains := options["preferred_chains"] - hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS || globalACMEEAB != nil || globalPreferredChains != nil - if hasGlobalACMEDefaults { + if hasGlobalACMEDefaults(options) { for i := range tlsApp.Automation.Policies { ap := tlsApp.Automation.Policies[i] if len(ap.Issuers) == 0 && automationPolicyHasAllPublicNames(ap) { @@ -895,6 +942,33 @@ func appendUniqueStrings(existing []string, additions ...string) []string { return existing } +// automationPolicyExistsForSubject reports whether some automation policy +// already names subject. Subjects are compared exactly, which is the same +// comparison the adapter uses to reject overlapping policies; a catch-all +// policy names no subjects and so never matches. +func automationPolicyExistsForSubject(automation *caddytls.AutomationConfig, subject string) bool { + if automation == nil { + return false + } + return slices.ContainsFunc(automation.Policies, func(ap *caddytls.AutomationPolicy) bool { + return slices.Contains(ap.SubjectsRaw, subject) + }) +} + +// hasGlobalACMEDefaults reports whether any global option is set that an +// automation policy without issuers of its own would later be filled in with. +// A policy is worth creating for a subject when this is true, even if the +// policy is otherwise empty at the time it is made. +func hasGlobalACMEDefaults(options map[string]any) bool { + _, hasACMEDNS := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set + return options["email"] != nil || + options["acme_ca"] != nil || + options["acme_ca_root"] != nil || + hasACMEDNS || + options["acme_eab"] != nil || + options["preferred_chains"] != nil +} + // 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 diff --git a/caddytest/integration/caddyfile_adapt/tls_automate_names.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_automate_names.caddyfiletest new file mode 100644 index 000000000..b5df6b475 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_automate_names.caddyfiletest @@ -0,0 +1,77 @@ +{ + email nobody@example.com + tls_automate_names *.example.com +} + +foo.example.com { + respond "Hello world" +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "foo.example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "body": "Hello world", + "handler": "static_response" + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "certificates": { + "automate": [ + "*.example.com" + ] + }, + "automation": { + "policies": [ + { + "subjects": [ + "foo.example.com", + "*.example.com" + ], + "issuers": [ + { + "email": "nobody@example.com", + "module": "acme" + }, + { + "ca": "https://acme.zerossl.com/v2/DV90", + "email": "nobody@example.com", + "module": "acme" + } + ] + } + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/tls_automate_names_auto_https_off.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_automate_names_auto_https_off.caddyfiletest new file mode 100644 index 000000000..410aeb44e --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_automate_names_auto_https_off.caddyfiletest @@ -0,0 +1,62 @@ +{ + auto_https off + tls_automate_names mail.example.com +} + +foo.example.com { + respond "hi" +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "foo.example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "body": "hi", + "handler": "static_response" + } + ] + } + ] + } + ], + "terminal": true + } + ], + "tls_connection_policies": [ + {} + ], + "automatic_https": { + "disable": true + } + } + } + }, + "tls": { + "certificates": { + "automate": [ + "mail.example.com" + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/tls_automate_names_existing_policy.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_automate_names_existing_policy.caddyfiletest new file mode 100644 index 000000000..71c399515 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_automate_names_existing_policy.caddyfiletest @@ -0,0 +1,92 @@ +{ + email nobody@example.com + tls_automate_names foo.example.com bar.example.com +} + +foo.example.com { + tls { + ca https://acme.example.test/directory + } + respond "served with its own issuer" +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "foo.example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "body": "served with its own issuer", + "handler": "static_response" + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "certificates": { + "automate": [ + "bar.example.com", + "foo.example.com" + ] + }, + "automation": { + "policies": [ + { + "subjects": [ + "foo.example.com" + ], + "issuers": [ + { + "ca": "https://acme.example.test/directory", + "email": "nobody@example.com", + "module": "acme" + } + ] + }, + { + "subjects": [ + "bar.example.com" + ], + "issuers": [ + { + "email": "nobody@example.com", + "module": "acme" + }, + { + "ca": "https://acme.zerossl.com/v2/DV90", + "email": "nobody@example.com", + "module": "acme" + } + ] + } + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/tls_automate_names_internal.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_automate_names_internal.caddyfiletest new file mode 100644 index 000000000..1fae9bf37 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_automate_names_internal.caddyfiletest @@ -0,0 +1,74 @@ +{ + tls_automate_names localhost 192.168.1.5 real.example.com + tls_automate_names second.example.com +} + +example.org { + respond "hi" +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.org" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "body": "hi", + "handler": "static_response" + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "certificates": { + "automate": [ + "192.168.1.5", + "localhost", + "real.example.com", + "second.example.com" + ] + }, + "automation": { + "policies": [ + { + "subjects": [ + "localhost", + "192.168.1.5" + ], + "issuers": [ + { + "module": "internal" + } + ] + } + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/tls_automate_names_without_site_block.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_automate_names_without_site_block.caddyfiletest new file mode 100644 index 000000000..a9ed52101 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_automate_names_without_site_block.caddyfiletest @@ -0,0 +1,16 @@ +{ + tls_automate_names mail.example.com xmpp.example.com +} +---------- +{ + "apps": { + "tls": { + "certificates": { + "automate": [ + "mail.example.com", + "xmpp.example.com" + ] + } + } + } +}