diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7394d4445..5f3e98db6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,22 +22,18 @@ jobs:
# Default is true, cancels jobs for other platforms in the matrix if one fails
fail-fast: false
matrix:
- os:
+ os:
- linux
- mac
- windows
- go:
- - '1.23'
+ go:
- '1.24'
include:
# Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }}
- - go: '1.23'
- GO_SEMVER: '~1.23.6'
-
- go: '1.24'
- GO_SEMVER: '~1.24.0'
+ GO_SEMVER: '~1.24.1'
# Set some variables per OS, usable via ${{ matrix.VAR }}
# OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
diff --git a/.github/workflows/cross-build.yml b/.github/workflows/cross-build.yml
index 3e9860ac5..372cc7652 100644
--- a/.github/workflows/cross-build.yml
+++ b/.github/workflows/cross-build.yml
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- goos:
+ goos:
- 'aix'
- 'linux'
- 'solaris'
@@ -30,18 +30,14 @@ jobs:
- 'windows'
- 'darwin'
- 'netbsd'
- go:
- - '1.23'
+ go:
- '1.24'
include:
# Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }}
- - go: '1.23'
- GO_SEMVER: '~1.23.6'
-
- go: '1.24'
- GO_SEMVER: '~1.24.0'
+ GO_SEMVER: '~1.24.1'
runs-on: ubuntu-latest
continue-on-error: true
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index f91ae8188..c5c89b502 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -67,5 +67,5 @@ jobs:
- name: govulncheck
uses: golang/govulncheck-action@v1
with:
- go-version-input: '~1.24.0'
+ go-version-input: '~1.24.1'
check-latest: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f636d06f1..b508ba468 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,7 +23,7 @@ jobs:
# Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }}
- go: '1.24'
- GO_SEMVER: '~1.24.0'
+ GO_SEMVER: '~1.24.1'
runs-on: ${{ matrix.os }}
# https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
diff --git a/README.md b/README.md
index 3f071e6f0..4bebaafdb 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
-
+
@@ -67,6 +67,7 @@
- Fully-managed local CA for internal names & IPs
- Can coordinate with other Caddy instances in a cluster
- Multi-issuer fallback
+ - Encrypted ClientHello (ECH) support
- **Stays up when other servers go down** due to TLS/OCSP/certificate-related issues
- **Production-ready** after serving trillions of requests and managing millions of TLS certificates
- **Scales to hundreds of thousands of sites** as proven in production
@@ -87,7 +88,7 @@ See [our online documentation](https://caddyserver.com/docs/install) for other i
Requirements:
-- [Go 1.22.3 or newer](https://golang.org/dl/)
+- [Go 1.24.0 or newer](https://golang.org/dl/)
### For development
@@ -192,8 +193,8 @@ Matthew Holt began developing Caddy in 2014 while studying computer science at B
**The name "Caddy" is trademarked.** The name of the software is "Caddy", not "Caddy Server" or "CaddyServer". Please call it "Caddy" or, if you wish to clarify, "the Caddy web server". Caddy is a registered trademark of Stack Holdings GmbH.
-- _Project on Twitter: [@caddyserver](https://twitter.com/caddyserver)_
-- _Author on Twitter: [@mholt6](https://twitter.com/mholt6)_
+- _Project on X: [@caddyserver](https://x.com/caddyserver)_
+- _Author on X: [@mholt6](https://x.com/mholt6)_
Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company.
diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go
index 45570d016..3fc08b2c8 100644
--- a/caddyconfig/httpcaddyfile/builtins.go
+++ b/caddyconfig/httpcaddyfile/builtins.go
@@ -99,7 +99,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// ca
// ca_root
// key_type [ed25519|p256|p384|rsa2048|rsa4096]
-// dns [...]
+// dns [ [...]] (required, though, if DNS is not configured as global option)
// propagation_delay
// propagation_timeout
// resolvers
@@ -312,10 +312,6 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
certManagers = append(certManagers, certManager)
case "dns":
- if !h.NextArg() {
- return nil, h.ArgErr()
- }
- provName := h.Val()
if acmeIssuer == nil {
acmeIssuer = new(caddytls.ACMEIssuer)
}
@@ -325,12 +321,19 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
}
- modID := "dns.providers." + provName
- unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
- if err != nil {
- return nil, err
+ // DNS provider configuration optional, since it may be configured globally via the TLS app with global options
+ if h.NextArg() {
+ provName := h.Val()
+ modID := "dns.providers." + provName
+ unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
+ if err != nil {
+ return nil, err
+ }
+ acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
+ } else if h.Option("dns") == nil {
+ // if DNS is omitted locally, it needs to be configured globally
+ return nil, h.ArgErr()
}
- acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
case "resolvers":
args := h.RemainingArgs()
diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go
index 37a6f6b23..69d88df0c 100644
--- a/caddyconfig/httpcaddyfile/httptype.go
+++ b/caddyconfig/httpcaddyfile/httptype.go
@@ -1121,6 +1121,12 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
return nil, fmt.Errorf("two policies with same match criteria have conflicting default SNI: %s vs. %s",
cps[i].DefaultSNI, cps[j].DefaultSNI)
}
+ if cps[i].FallbackSNI != "" &&
+ cps[j].FallbackSNI != "" &&
+ cps[i].FallbackSNI != cps[j].FallbackSNI {
+ return nil, fmt.Errorf("two policies with same match criteria have conflicting fallback SNI: %s vs. %s",
+ cps[i].FallbackSNI, cps[j].FallbackSNI)
+ }
if cps[i].ProtocolMin != "" &&
cps[j].ProtocolMin != "" &&
cps[i].ProtocolMin != cps[j].ProtocolMin {
@@ -1161,6 +1167,9 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
if cps[i].DefaultSNI == "" && cps[j].DefaultSNI != "" {
cps[i].DefaultSNI = cps[j].DefaultSNI
}
+ if cps[i].FallbackSNI == "" && cps[j].FallbackSNI != "" {
+ cps[i].FallbackSNI = cps[j].FallbackSNI
+ }
if cps[i].ProtocolMin == "" && cps[j].ProtocolMin != "" {
cps[i].ProtocolMin = cps[j].ProtocolMin
}
diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go
index d4a424624..e48a52577 100644
--- a/caddyconfig/httpcaddyfile/options.go
+++ b/caddyconfig/httpcaddyfile/options.go
@@ -19,6 +19,7 @@ import (
"strconv"
"github.com/caddyserver/certmagic"
+ "github.com/libdns/libdns"
"github.com/mholt/acmez/v3/acme"
"github.com/caddyserver/caddy/v2"
@@ -45,7 +46,7 @@ func init() {
RegisterGlobalOption("ocsp_interval", parseOptDuration)
RegisterGlobalOption("acme_ca", parseOptSingleString)
RegisterGlobalOption("acme_ca_root", parseOptSingleString)
- RegisterGlobalOption("acme_dns", parseOptACMEDNS)
+ RegisterGlobalOption("acme_dns", parseOptDNS)
RegisterGlobalOption("acme_eab", parseOptACMEEAB)
RegisterGlobalOption("cert_issuer", parseOptCertIssuer)
RegisterGlobalOption("skip_install_trust", parseOptTrue)
@@ -62,6 +63,8 @@ func init() {
RegisterGlobalOption("log", parseLogOptions)
RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
RegisterGlobalOption("persist_config", parseOptPersistConfig)
+ RegisterGlobalOption("dns", parseOptDNS)
+ RegisterGlobalOption("ech", parseOptECH)
}
func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil }
@@ -238,25 +241,6 @@ func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
return caddy.Duration(dur), nil
}
-func parseOptACMEDNS(d *caddyfile.Dispenser, _ any) (any, error) {
- if !d.Next() { // consume option name
- return nil, d.ArgErr()
- }
- if !d.Next() { // get DNS module name
- return nil, d.ArgErr()
- }
- modID := "dns.providers." + d.Val()
- unm, err := caddyfile.UnmarshalModule(d, modID)
- if err != nil {
- return nil, err
- }
- prov, ok := unm.(certmagic.DNSProvider)
- if !ok {
- return nil, d.Errf("module %s (%T) is not a certmagic.DNSProvider", modID, unm)
- }
- return prov, nil
-}
-
func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) {
eab := new(acme.EAB)
d.Next() // consume option name
@@ -570,3 +554,68 @@ func parseOptPreferredChains(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next()
return caddytls.ParseCaddyfilePreferredChainsOptions(d)
}
+
+func parseOptDNS(d *caddyfile.Dispenser, _ any) (any, error) {
+ d.Next() // consume option name
+
+ if !d.Next() { // get DNS module name
+ return nil, d.ArgErr()
+ }
+ modID := "dns.providers." + d.Val()
+ unm, err := caddyfile.UnmarshalModule(d, modID)
+ if err != nil {
+ return nil, err
+ }
+ switch unm.(type) {
+ case libdns.RecordGetter,
+ libdns.RecordSetter,
+ libdns.RecordAppender,
+ libdns.RecordDeleter:
+ default:
+ return nil, d.Errf("module %s (%T) is not a libdns provider", modID, unm)
+ }
+ return unm, nil
+}
+
+func parseOptECH(d *caddyfile.Dispenser, _ any) (any, error) {
+ d.Next() // consume option name
+
+ ech := new(caddytls.ECH)
+
+ publicNames := d.RemainingArgs()
+ for _, publicName := range publicNames {
+ ech.Configs = append(ech.Configs, caddytls.ECHConfiguration{
+ PublicName: publicName,
+ })
+ }
+ if len(ech.Configs) == 0 {
+ return nil, d.ArgErr()
+ }
+
+ for nesting := d.Nesting(); d.NextBlock(nesting); {
+ switch d.Val() {
+ case "dns":
+ if !d.Next() {
+ return nil, d.ArgErr()
+ }
+ providerName := d.Val()
+ modID := "dns.providers." + providerName
+ unm, err := caddyfile.UnmarshalModule(d, modID)
+ if err != nil {
+ return nil, err
+ }
+ ech.Publication = append(ech.Publication, &caddytls.ECHPublication{
+ Configs: publicNames,
+ PublishersRaw: caddy.ModuleMap{
+ "dns": caddyconfig.JSON(caddytls.ECHDNSPublisher{
+ ProviderRaw: caddyconfig.JSONModuleObject(unm, "name", providerName, nil),
+ }, nil),
+ },
+ })
+ default:
+ return nil, d.Errf("ech: unrecognized subdirective '%s'", d.Val())
+ }
+ }
+
+ return ech, nil
+}
diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go
index 71b524926..8a21ca038 100644
--- a/caddyconfig/httpcaddyfile/tlsapp.go
+++ b/caddyconfig/httpcaddyfile/tlsapp.go
@@ -359,6 +359,30 @@ func (st ServerType) buildTLSApp(
tlsApp.Automation.OnDemand = onDemand
}
+ // set up "global" (to the TLS app) DNS provider config
+ if globalDNS, ok := options["dns"]; ok && globalDNS != nil {
+ tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
+ }
+
+ // set up ECH from Caddyfile options
+ if ech, ok := options["ech"].(*caddytls.ECH); ok {
+ tlsApp.EncryptedClientHello = ech
+
+ // outer server names will need certificates, so make sure they're included
+ // in an automation policy for them that applies any global options
+ ap, err := newBaseAutomationPolicy(options, warnings, true)
+ if err != nil {
+ return nil, warnings, err
+ }
+ for _, cfg := range ech.Configs {
+ ap.SubjectsRaw = append(ap.SubjectsRaw, cfg.PublicName)
+ }
+ if tlsApp.Automation == nil {
+ tlsApp.Automation = new(caddytls.AutomationConfig)
+ }
+ tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, ap)
+ }
+
// if the storage clean interval is a boolean, then it's "off" to disable cleaning
if sc, ok := options["storage_check"].(string); ok && sc == "off" {
tlsApp.DisableStorageCheck = true
@@ -553,7 +577,8 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
if globalPreferredChains != nil && acmeIssuer.PreferredChains == nil {
acmeIssuer.PreferredChains = globalPreferredChains.(*caddytls.ChainPreference)
}
- if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
+ // only configure alt HTTP and TLS-ALPN ports if the DNS challenge is not enabled (wouldn't hurt, but isn't necessary since the DNS challenge is exclusive of others)
+ if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
}
@@ -562,7 +587,7 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
}
acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int)
}
- if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
+ if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
}
diff --git a/caddytest/caddytest.go b/caddytest/caddytest.go
index 05aa1e3f5..623c45e5e 100644
--- a/caddytest/caddytest.go
+++ b/caddytest/caddytest.go
@@ -31,8 +31,8 @@ import (
_ "github.com/caddyserver/caddy/v2/modules/standard"
)
-// Defaults store any configuration required to make the tests run
-type Defaults struct {
+// Config store any configuration required to make the tests run
+type Config struct {
// Port we expect caddy to listening on
AdminPort int
// Certificates we expect to be loaded before attempting to run the tests
@@ -44,7 +44,7 @@ type Defaults struct {
}
// Default testing values
-var Default = Defaults{
+var Default = Config{
AdminPort: 2999, // different from what a real server also running on a developer's machine might be
Certificates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
TestRequestTimeout: 5 * time.Second,
@@ -61,6 +61,7 @@ type Tester struct {
Client *http.Client
configLoaded bool
t testing.TB
+ config Config
}
// NewTester will create a new testing client with an attached cookie jar
@@ -78,9 +79,29 @@ func NewTester(t testing.TB) *Tester {
},
configLoaded: false,
t: t,
+ config: Default,
}
}
+// WithDefaultOverrides this will override the default test configuration with the provided values.
+func (tc *Tester) WithDefaultOverrides(overrides Config) *Tester {
+ if overrides.AdminPort != 0 {
+ tc.config.AdminPort = overrides.AdminPort
+ }
+ if len(overrides.Certificates) > 0 {
+ tc.config.Certificates = overrides.Certificates
+ }
+ if overrides.TestRequestTimeout != 0 {
+ tc.config.TestRequestTimeout = overrides.TestRequestTimeout
+ tc.Client.Timeout = overrides.TestRequestTimeout
+ }
+ if overrides.LoadRequestTimeout != 0 {
+ tc.config.LoadRequestTimeout = overrides.LoadRequestTimeout
+ }
+
+ return tc
+}
+
type configLoadError struct {
Response string
}
@@ -113,7 +134,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
return nil
}
- err := validateTestPrerequisites(tc.t)
+ err := validateTestPrerequisites(tc)
if err != nil {
tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err)
return nil
@@ -121,7 +142,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
tc.t.Cleanup(func() {
if tc.t.Failed() && tc.configLoaded {
- res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
+ res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
if err != nil {
tc.t.Log("unable to read the current config")
return
@@ -151,10 +172,10 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
tc.t.Logf("After: %s", rawConfig)
}
client := &http.Client{
- Timeout: Default.LoadRequestTimeout,
+ Timeout: tc.config.LoadRequestTimeout,
}
start := time.Now()
- req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", Default.AdminPort), strings.NewReader(rawConfig))
+ req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.config.AdminPort), strings.NewReader(rawConfig))
if err != nil {
tc.t.Errorf("failed to create request. %s", err)
return err
@@ -205,11 +226,11 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
}
client := &http.Client{
- Timeout: Default.LoadRequestTimeout,
+ Timeout: tc.config.LoadRequestTimeout,
}
fetchConfig := func(client *http.Client) any {
- resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
+ resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
if err != nil {
return nil
}
@@ -237,30 +258,30 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
}
const initConfig = `{
- admin localhost:2999
+ admin localhost:%d
}
`
// validateTestPrerequisites ensures the certificates are available in the
// designated path and Caddy sub-process is running.
-func validateTestPrerequisites(t testing.TB) error {
+func validateTestPrerequisites(tc *Tester) error {
// check certificates are found
- for _, certName := range Default.Certificates {
+ for _, certName := range tc.config.Certificates {
if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
}
}
- if isCaddyAdminRunning() != nil {
+ if isCaddyAdminRunning(tc) != nil {
// setup the init config file, and set the cleanup afterwards
f, err := os.CreateTemp("", "")
if err != nil {
return err
}
- t.Cleanup(func() {
+ tc.t.Cleanup(func() {
os.Remove(f.Name())
})
- if _, err := f.WriteString(initConfig); err != nil {
+ if _, err := f.WriteString(fmt.Sprintf(initConfig, tc.config.AdminPort)); err != nil {
return err
}
@@ -271,23 +292,23 @@ func validateTestPrerequisites(t testing.TB) error {
}()
// wait for caddy to start serving the initial config
- for retries := 10; retries > 0 && isCaddyAdminRunning() != nil; retries-- {
+ for retries := 10; retries > 0 && isCaddyAdminRunning(tc) != nil; retries-- {
time.Sleep(1 * time.Second)
}
}
// one more time to return the error
- return isCaddyAdminRunning()
+ return isCaddyAdminRunning(tc)
}
-func isCaddyAdminRunning() error {
+func isCaddyAdminRunning(tc *Tester) error {
// assert that caddy is running
client := &http.Client{
- Timeout: Default.LoadRequestTimeout,
+ Timeout: tc.config.LoadRequestTimeout,
}
- resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
+ resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
if err != nil {
- return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", Default.AdminPort)
+ return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.config.AdminPort)
}
resp.Body.Close()
diff --git a/context.go b/context.go
index d4d7afacf..94623df72 100644
--- a/context.go
+++ b/context.go
@@ -385,6 +385,17 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (any, error
return nil, fmt.Errorf("module value cannot be null")
}
+ // if this is an app module, keep a reference to it,
+ // since submodules may need to reference it during
+ // provisioning (even though the parent app module
+ // may not be fully provisioned yet; this is the case
+ // with the tls app's automation policies, which may
+ // refer to the tls app to check if a global DNS
+ // module has been configured for DNS challenges)
+ if appModule, ok := val.(App); ok {
+ ctx.cfg.apps[id] = appModule
+ }
+
ctx.ancestry = append(ctx.ancestry, val)
if prov, ok := val.(Provisioner); ok {
@@ -471,7 +482,6 @@ func (ctx Context) App(name string) (any, error) {
if appRaw != nil {
ctx.cfg.AppsRaw[name] = nil // allow GC to deallocate
}
- ctx.cfg.apps[name] = modVal.(App)
return modVal, nil
}
diff --git a/go.mod b/go.mod
index deff08696..70f7f1483 100644
--- a/go.mod
+++ b/go.mod
@@ -8,8 +8,9 @@ require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/alecthomas/chroma/v2 v2.14.0
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b
- github.com/caddyserver/certmagic v0.21.7
+ github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d
github.com/caddyserver/zerossl v0.1.3
+ github.com/cloudflare/circl v1.3.7
github.com/dustin/go-humanize v1.0.1
github.com/go-chi/chi/v5 v5.0.12
github.com/google/cel-go v0.21.0
@@ -22,8 +23,8 @@ require (
github.com/smallstep/certificates v0.26.1
github.com/smallstep/nosql v0.6.1
github.com/smallstep/truststore v0.13.0
- github.com/spf13/cobra v1.8.1
- github.com/spf13/pflag v1.0.5
+ github.com/spf13/cobra v1.9.1
+ github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.9.0
github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53
github.com/yuin/goldmark v1.7.8
@@ -36,11 +37,11 @@ require (
go.uber.org/automaxprocs v1.6.0
go.uber.org/zap v1.27.0
go.uber.org/zap/exp v0.3.0
- golang.org/x/crypto v0.31.0
+ golang.org/x/crypto v0.33.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9
golang.org/x/net v0.33.0
- golang.org/x/sync v0.10.0
- golang.org/x/term v0.27.0
+ golang.org/x/sync v0.11.0
+ golang.org/x/term v0.29.0
golang.org/x/time v0.7.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -53,7 +54,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
- github.com/go-jose/go-jose/v3 v3.0.3 // indirect
+ github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect
github.com/google/go-tpm v0.9.0 // indirect
@@ -89,7 +90,7 @@ require (
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0
github.com/chzyer/readline v1.5.1 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/dgraph-io/badger v1.6.2 // indirect
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
github.com/dgraph-io/ristretto v0.2.0 // indirect
@@ -114,7 +115,7 @@ require (
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.14.0 // indirect
github.com/jackc/pgx/v4 v4.18.3 // indirect
- github.com/libdns/libdns v0.2.2
+ github.com/libdns/libdns v0.2.3
github.com/manifoldco/promptui v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -148,7 +149,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sys v0.30.0
- golang.org/x/text v0.21.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.1 // indirect
diff --git a/go.sum b/go.sum
index 27b97f31e..15641dc4a 100644
--- a/go.sum
+++ b/go.sum
@@ -91,8 +91,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
-github.com/caddyserver/certmagic v0.21.7 h1:66KJioPFJwttL43KYSWk7ErSmE6LfaJgCQuhm8Sg6fg=
-github.com/caddyserver/certmagic v0.21.7/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI=
+github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d h1:9zdfQHH838+rS8pmJ73/RSjpbfHGAyxRX1E79F+1zso=
+github.com/caddyserver/certmagic v0.21.8-0.20250220203412-a7894dd6992d/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -111,6 +111,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
+github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@@ -121,8 +123,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -160,8 +162,8 @@ github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aev
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
-github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
-github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
+github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
+github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.13.0 h1:OoneCcHKHQ03LfBpoQCUfCluwd2Vt3ohz+kvbJneZAU=
github.com/go-kit/kit v0.13.0/go.mod h1:phqEHMMUbyrCFCTgH48JueqrM3md2HcAZ8N3XE4FKDg=
@@ -323,8 +325,8 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
-github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
+github.com/libdns/libdns v0.2.3 h1:ba30K4ObwMGB/QTmqUxf3H4/GmUrCAIkMWejeGl12v8=
+github.com/libdns/libdns v0.2.3/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -469,12 +471,12 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
@@ -596,8 +598,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
-golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9 h1:4cEcP5+OjGppY79LCQ5Go2B1Boix2x0v6pvA01P3FoA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20241104001025-71ed71b4faf9/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -645,8 +647,8 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
-golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
+golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -683,8 +685,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
-golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
+golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@@ -695,8 +697,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
-golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go
index 4449e1f4d..dce21a721 100644
--- a/modules/caddyhttp/autohttps.go
+++ b/modules/caddyhttp/autohttps.go
@@ -205,6 +205,7 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
// for all the hostnames we found, filter them so we have
// a deduplicated list of names for which to obtain certs
// (only if cert management not disabled for this server)
+ var echDomains []string
if srv.AutoHTTPS.DisableCerts {
logger.Warn("skipping automated certificate management for server because it is disabled", zap.String("server_name", srvName))
} else {
@@ -231,10 +232,14 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er
}
uniqueDomainsForCerts[d] = struct{}{}
+ echDomains = append(echDomains, d)
}
}
}
+ // let the TLS server know we have some hostnames that could be protected behind ECH
+ app.tlsApp.RegisterServerNames(echDomains)
+
// tell the server to use TLS if it is not already doing so
if srv.TLSConnPolicies == nil {
srv.TLSConnPolicies = caddytls.ConnectionPolicies{new(caddytls.ConnectionPolicy)}
diff --git a/modules/caddytls/acmeissuer.go b/modules/caddytls/acmeissuer.go
index 2fe5eec97..c28790fe9 100644
--- a/modules/caddytls/acmeissuer.go
+++ b/modules/caddytls/acmeissuer.go
@@ -146,15 +146,30 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error {
iss.AccountKey = accountKey
}
- // DNS providers
- if iss.Challenges != nil && iss.Challenges.DNS != nil && iss.Challenges.DNS.ProviderRaw != nil {
- val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw")
- if err != nil {
- return fmt.Errorf("loading DNS provider module: %v", err)
+ // DNS challenge provider
+ if iss.Challenges != nil && iss.Challenges.DNS != nil {
+ var prov certmagic.DNSProvider
+ if iss.Challenges.DNS.ProviderRaw != nil {
+ // a challenge provider has been locally configured - use it
+ val, err := ctx.LoadModule(iss.Challenges.DNS, "ProviderRaw")
+ if err != nil {
+ return fmt.Errorf("loading DNS provider module: %v", err)
+ }
+ prov = val.(certmagic.DNSProvider)
+ } else if tlsAppIface, err := ctx.AppIfConfigured("tls"); err == nil {
+ // no locally configured DNS challenge provider, but if there is
+ // a global DNS module configured with the TLS app, use that
+ tlsApp := tlsAppIface.(*TLS)
+ if tlsApp.dns != nil {
+ prov = tlsApp.dns.(certmagic.DNSProvider)
+ }
+ }
+ if prov == nil {
+ return fmt.Errorf("DNS challenge enabled, but no DNS provider configured")
}
iss.Challenges.DNS.solver = &certmagic.DNS01Solver{
DNSManager: certmagic.DNSManager{
- DNSProvider: val.(certmagic.DNSProvider),
+ DNSProvider: prov,
TTL: time.Duration(iss.Challenges.DNS.TTL),
PropagationDelay: time.Duration(iss.Challenges.DNS.PropagationDelay),
PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout),
diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go
index 727afaa08..b686090e6 100644
--- a/modules/caddytls/connpolicy.go
+++ b/modules/caddytls/connpolicy.go
@@ -93,7 +93,7 @@ func (cp ConnectionPolicies) Provision(ctx caddy.Context) error {
// TLSConfig returns a standard-lib-compatible TLS configuration which
// selects the first matching policy based on the ClientHello.
-func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
+func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config {
// using ServerName to match policies is extremely common, especially in configs
// with lots and lots of different policies; we can fast-track those by indexing
// them by SNI, so we don't have to iterate potentially thousands of policies
@@ -104,6 +104,7 @@ func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
for _, m := range p.matchers {
if sni, ok := m.(MatchServerName); ok {
for _, sniName := range sni {
+ // index for fast lookups during handshakes
indexedBySNI[sniName] = append(indexedBySNI[sniName], p)
}
}
@@ -111,32 +112,79 @@ func (cp ConnectionPolicies) TLSConfig(_ caddy.Context) *tls.Config {
}
}
- return &tls.Config{
- MinVersion: tls.VersionTLS12,
- GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
- // filter policies by SNI first, if possible, to speed things up
- // when there may be lots of policies
- possiblePolicies := cp
- if indexedPolicies, ok := indexedBySNI[hello.ServerName]; ok {
- possiblePolicies = indexedPolicies
- }
+ getConfigForClient := func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
+ // filter policies by SNI first, if possible, to speed things up
+ // when there may be lots of policies
+ possiblePolicies := cp
+ if indexedPolicies, ok := indexedBySNI[hello.ServerName]; ok {
+ possiblePolicies = indexedPolicies
+ }
- policyLoop:
- for _, pol := range possiblePolicies {
- for _, matcher := range pol.matchers {
- if !matcher.Match(hello) {
- continue policyLoop
+ policyLoop:
+ for _, pol := range possiblePolicies {
+ for _, matcher := range pol.matchers {
+ if !matcher.Match(hello) {
+ continue policyLoop
+ }
+ }
+ if pol.Drop {
+ return nil, fmt.Errorf("dropping connection")
+ }
+ return pol.TLSConfig, nil
+ }
+
+ return nil, fmt.Errorf("no server TLS configuration available for ClientHello: %+v", hello)
+ }
+
+ tlsCfg := &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ GetConfigForClient: getConfigForClient,
+ }
+
+ // enable ECH, if configured
+ if tlsAppIface, err := ctx.AppIfConfigured("tls"); err == nil {
+ tlsApp := tlsAppIface.(*TLS)
+
+ if tlsApp.EncryptedClientHello != nil && len(tlsApp.EncryptedClientHello.configs) > 0 {
+ // if no publication was configured, we apply ECH to all server names by default,
+ // but the TLS app needs to know what they are in this case, since they don't appear
+ // in its config (remember, TLS connection policies are used by *other* apps to
+ // run TLS servers) -- we skip names with placeholders
+ if tlsApp.EncryptedClientHello.Publication == nil {
+ var echNames []string
+ repl := caddy.NewReplacer()
+ for _, p := range cp {
+ for _, m := range p.matchers {
+ if sni, ok := m.(MatchServerName); ok {
+ for _, name := range sni {
+ finalName := strings.ToLower(repl.ReplaceAll(name, ""))
+ echNames = append(echNames, finalName)
+ }
+ }
}
}
- if pol.Drop {
- return nil, fmt.Errorf("dropping connection")
- }
- return pol.TLSConfig, nil
+ tlsApp.RegisterServerNames(echNames)
}
- return nil, fmt.Errorf("no server TLS configuration available for ClientHello: %+v", hello)
- },
+ // TODO: Ideally, ECH keys should be rotated. However, as of Go 1.24, the std lib implementation
+ // does not support safely modifying the tls.Config's EncryptedClientHelloKeys field.
+ // So, we implement static ECH keys temporarily. See https://github.com/golang/go/issues/71920.
+ // Revisit this after Go 1.25 is released and implement key rotation.
+ var stdECHKeys []tls.EncryptedClientHelloKey
+ for _, echConfigs := range tlsApp.EncryptedClientHello.configs {
+ for _, c := range echConfigs {
+ stdECHKeys = append(stdECHKeys, tls.EncryptedClientHelloKey{
+ Config: c.configBin,
+ PrivateKey: c.privKeyBin,
+ SendAsRetry: c.sendAsRetry,
+ })
+ }
+ }
+ tlsCfg.EncryptedClientHelloKeys = stdECHKeys
+ }
}
+
+ return tlsCfg
}
// ConnectionPolicy specifies the logic for handling a TLS handshake.
@@ -409,6 +457,7 @@ func (p ConnectionPolicy) SettingsEmpty() bool {
p.ProtocolMax == "" &&
p.ClientAuthentication == nil &&
p.DefaultSNI == "" &&
+ p.FallbackSNI == "" &&
p.InsecureSecretsLog == ""
}
diff --git a/modules/caddytls/ech.go b/modules/caddytls/ech.go
new file mode 100644
index 000000000..25b7a6923
--- /dev/null
+++ b/modules/caddytls/ech.go
@@ -0,0 +1,1107 @@
+package caddytls
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ weakrand "math/rand/v2"
+ "path"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/caddyserver/certmagic"
+ "github.com/cloudflare/circl/hpke"
+ "github.com/cloudflare/circl/kem"
+ "github.com/libdns/libdns"
+ "go.uber.org/zap"
+ "golang.org/x/crypto/cryptobyte"
+
+ "github.com/caddyserver/caddy/v2"
+)
+
+func init() {
+ caddy.RegisterModule(ECHDNSPublisher{})
+}
+
+// ECH enables Encrypted ClientHello (ECH) and configures its management.
+//
+// ECH helps protect site names (also called "server names" or "domain names"
+// or "SNI"), which are normally sent over plaintext when establishing a TLS
+// connection. With ECH, the true ClientHello is encrypted and wrapped by an
+// "outer" ClientHello that uses a more generic, shared server name that is
+// publicly known.
+//
+// Clients need to know which public name (and other parameters) to use when
+// connecting to a site with ECH, and the methods for this vary; however,
+// major browsers support reading ECH configurations from DNS records (which
+// is typically only secure when DNS-over-HTTPS or DNS-over-TLS is enabled in
+// the client). Caddy has the ability to automatically publish ECH configs to
+// DNS records if a DNS provider is configured either in the TLS app or with
+// each individual publication config object. (Requires a custom build with a
+// DNS provider module.)
+//
+// Note that, as of Caddy 2.10.0 (~March 2025), ECH keys are not automatically
+// rotated due to a limitation in the Go standard library (see
+// https://github.com/golang/go/issues/71920). This should be resolved when
+// Go 1.25 is released (~Aug. 2025), and Caddy will be updated to automatically
+// rotate ECH keys/configs at that point.
+//
+// EXPERIMENTAL: Subject to change.
+type ECH struct {
+ // The list of ECH configurations for which to automatically generate
+ // and rotate keys. At least one is required to enable ECH.
+ //
+ // It is strongly recommended to use as few ECH configs as possible
+ // to maximize the size of your anonymity set (see the ECH specification
+ // for a definition). Typically, each server should have only one public
+ // name, i.e. one config in this list.
+ Configs []ECHConfiguration `json:"configs,omitempty"`
+
+ // Publication describes ways to publish ECH configs for clients to
+ // discover and use. Without publication, most clients will not use
+ // ECH at all, and those that do will suffer degraded performance.
+ //
+ // Most major browsers support ECH by way of publication to HTTPS
+ // DNS RRs. (This also typically requires that they use DoH or DoT.)
+ Publication []*ECHPublication `json:"publication,omitempty"`
+
+ // map of public_name to list of configs
+ configs map[string][]echConfig
+}
+
+// Provision loads or creates ECH configs and returns outer names (for certificate
+// management), but does not publish any ECH configs. The DNS module is used as
+// a default for later publishing if needed.
+func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) {
+ logger := ctx.Logger().Named("ech")
+
+ // set up publication modules before we need to obtain a lock in storage,
+ // since this is strictly internal and doesn't require synchronization
+ for i, pub := range ech.Publication {
+ mods, err := ctx.LoadModule(pub, "PublishersRaw")
+ if err != nil {
+ return nil, fmt.Errorf("loading ECH publication modules: %v", err)
+ }
+ for _, modIface := range mods.(map[string]any) {
+ ech.Publication[i].publishers = append(ech.Publication[i].publishers, modIface.(ECHPublisher))
+ }
+ }
+
+ // the rest of provisioning needs an exclusive lock so that instances aren't
+ // stepping on each other when setting up ECH configs
+ storage := ctx.Storage()
+ const echLockName = "ech_provision"
+ if err := storage.Lock(ctx, echLockName); err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err := storage.Unlock(ctx, echLockName); err != nil {
+ logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
+ }
+ }()
+
+ var outerNames []string //nolint:prealloc // (FALSE POSITIVE - see https://github.com/alexkohler/prealloc/issues/30)
+
+ // start by loading all the existing configs (even the older ones on the way out,
+ // since some clients may still be using them if they haven't yet picked up on the
+ // new configs)
+ cfgKeys, err := storage.List(ctx, echConfigsKey, false)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) { // OK if dir doesn't exist; it will be created
+ return nil, err
+ }
+ for _, cfgKey := range cfgKeys {
+ cfg, err := loadECHConfig(ctx, path.Base(cfgKey))
+ if err != nil {
+ return nil, err
+ }
+ // if any part of the config's folder was corrupted, the load function will
+ // clean it up and not return an error, since configs are immutable and
+ // fairly ephemeral... so just check that we actually got a populated config
+ if cfg.configBin == nil || cfg.privKeyBin == nil {
+ continue
+ }
+ logger.Debug("loaded ECH config",
+ zap.String("public_name", cfg.RawPublicName),
+ zap.Uint8("id", cfg.ConfigID))
+ ech.configs[cfg.RawPublicName] = append(ech.configs[cfg.RawPublicName], cfg)
+ outerNames = append(outerNames, cfg.RawPublicName)
+ }
+
+ // all existing configs are now loaded; see if we need to make any new ones
+ // based on the input configuration, and also mark the most recent one(s) as
+ // current/active, so they can be used for ECH retries
+
+ for _, cfg := range ech.Configs {
+ publicName := strings.ToLower(strings.TrimSpace(cfg.PublicName))
+
+ if list, ok := ech.configs[publicName]; ok && len(list) > 0 {
+ // at least one config with this public name was loaded, so find the
+ // most recent one and mark it as active to be used with retries
+ var mostRecentDate time.Time
+ var mostRecentIdx int
+ for i, c := range list {
+ if mostRecentDate.IsZero() || c.meta.Created.After(mostRecentDate) {
+ mostRecentDate = c.meta.Created
+ mostRecentIdx = i
+ }
+ }
+ list[mostRecentIdx].sendAsRetry = true
+ } else {
+ // no config with this public name was loaded, so create one
+ echCfg, err := generateAndStoreECHConfig(ctx, publicName)
+ if err != nil {
+ return nil, err
+ }
+ logger.Debug("generated new ECH config",
+ zap.String("public_name", echCfg.RawPublicName),
+ zap.Uint8("id", echCfg.ConfigID))
+ ech.configs[publicName] = append(ech.configs[publicName], echCfg)
+ outerNames = append(outerNames, publicName)
+ }
+ }
+
+ return outerNames, nil
+}
+
+func (t *TLS) publishECHConfigs() error {
+ logger := t.logger.Named("ech")
+
+ // make publication exclusive, since we don't need to repeat this unnecessarily
+ storage := t.ctx.Storage()
+ const echLockName = "ech_publish"
+ if err := storage.Lock(t.ctx, echLockName); err != nil {
+ return err
+ }
+ defer func() {
+ if err := storage.Unlock(t.ctx, echLockName); err != nil {
+ logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err))
+ }
+ }()
+
+ // get the publication config, or use a default if not specified
+ // (the default publication config should be to publish all ECH
+ // configs to the app-global DNS provider; if no DNS provider is
+ // configured, then this whole function is basically a no-op)
+ publicationList := t.EncryptedClientHello.Publication
+ if publicationList == nil {
+ if dnsProv, ok := t.dns.(ECHDNSProvider); ok {
+ publicationList = []*ECHPublication{
+ {
+ publishers: []ECHPublisher{
+ &ECHDNSPublisher{
+ provider: dnsProv,
+ logger: t.logger,
+ },
+ },
+ },
+ }
+ }
+ }
+
+ // for each publication config, build the list of ECH configs to
+ // publish with it, and figure out which inner names to publish
+ // to/for, then publish
+ for _, publication := range publicationList {
+ // this publication is either configured for specific ECH configs,
+ // or we just use an implied default of all ECH configs
+ var echCfgList echConfigList
+ var configIDs []uint8 // TODO: use IDs or the outer names?
+ if publication.Configs == nil {
+ // by default, publish all configs
+ for _, configs := range t.EncryptedClientHello.configs {
+ echCfgList = append(echCfgList, configs...)
+ for _, c := range configs {
+ configIDs = append(configIDs, c.ConfigID)
+ }
+ }
+ } else {
+ for _, cfgOuterName := range publication.Configs {
+ if cfgList, ok := t.EncryptedClientHello.configs[cfgOuterName]; ok {
+ echCfgList = append(echCfgList, cfgList...)
+ for _, c := range cfgList {
+ configIDs = append(configIDs, c.ConfigID)
+ }
+ }
+ }
+ }
+
+ // marshal the ECH config list as binary for publication
+ echCfgListBin, err := echCfgList.MarshalBinary()
+ if err != nil {
+ return fmt.Errorf("marshaling ECH config list: %v", err)
+ }
+
+ // now we have our list of ECH configs to publish and the inner names
+ // to publish for (i.e. the names being protected); iterate each publisher
+ // and do the publish for any config+name that needs a publish
+ for _, publisher := range publication.publishers {
+ publisherKey := publisher.PublisherKey()
+
+ // by default, publish for all (non-outer) server names, unless
+ // a specific list of names is configured
+ var serverNamesSet map[string]struct{}
+ if publication.Domains == nil {
+ serverNamesSet = make(map[string]struct{}, len(t.serverNames))
+ for name := range t.serverNames {
+ serverNamesSet[name] = struct{}{}
+ }
+ } else {
+ serverNamesSet = make(map[string]struct{}, len(publication.Domains))
+ for _, name := range publication.Domains {
+ serverNamesSet[name] = struct{}{}
+ }
+ }
+
+ // remove any domains from the set which have already had all configs in the
+ // list published by this publisher, to avoid always re-publishing unnecessarily
+ for configuredInnerName := range serverNamesSet {
+ allConfigsPublished := true
+ for _, cfg := range echCfgList {
+ // TODO: Potentially utilize the timestamp (map value) for recent-enough publication, instead of just checking for existence
+ if _, ok := cfg.meta.Publications[publisherKey][configuredInnerName]; !ok {
+ allConfigsPublished = false
+ break
+ }
+ }
+ if allConfigsPublished {
+ delete(serverNamesSet, configuredInnerName)
+ }
+ }
+
+ // if all the (inner) domains have had this ECH config list published
+ // by this publisher, then try the next publication config
+ if len(serverNamesSet) == 0 {
+ logger.Debug("ECH config list already published by publisher for associated domains",
+ zap.Uint8s("config_ids", configIDs),
+ zap.String("publisher", publisherKey))
+ continue
+ }
+
+ // convert the set of names to a slice
+ dnsNamesToPublish := make([]string, 0, len(serverNamesSet))
+ for name := range serverNamesSet {
+ dnsNamesToPublish = append(dnsNamesToPublish, name)
+ }
+
+ logger.Debug("publishing ECH config list",
+ zap.Strings("domains", dnsNamesToPublish),
+ zap.Uint8s("config_ids", configIDs))
+
+ // publish this ECH config list with this publisher
+ pubTime := time.Now()
+ err := publisher.PublishECHConfigList(t.ctx, dnsNamesToPublish, echCfgListBin)
+ if err != nil {
+ t.logger.Error("publishing ECH configuration list",
+ zap.Strings("for_domains", publication.Domains),
+ zap.Error(err))
+ }
+
+ // update publication history, so that we don't unnecessarily republish every time
+ for _, cfg := range echCfgList {
+ if cfg.meta.Publications == nil {
+ cfg.meta.Publications = make(publicationHistory)
+ }
+ if _, ok := cfg.meta.Publications[publisherKey]; !ok {
+ cfg.meta.Publications[publisherKey] = make(map[string]time.Time)
+ }
+ for _, name := range dnsNamesToPublish {
+ cfg.meta.Publications[publisherKey][name] = pubTime
+ }
+ metaBytes, err := json.Marshal(cfg.meta)
+ if err != nil {
+ return fmt.Errorf("marshaling ECH config metadata: %v", err)
+ }
+ metaKey := path.Join(echConfigsKey, strconv.Itoa(int(cfg.ConfigID)), "meta.json")
+ if err := t.ctx.Storage().Store(t.ctx, metaKey, metaBytes); err != nil {
+ return fmt.Errorf("storing updated ECH config metadata: %v", err)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// loadECHConfig loads the config from storage with the given configID.
+// An error is not actually returned in some cases the config fails to
+// load because in some cases it just means the config ID folder has
+// been cleaned up in storage, maybe due to an incomplete set of keys
+// or corrupted contents; in any case, the only rectification is to
+// delete it and make new keys (an error IS returned if deleting the
+// corrupted keys fails, for example). Check the returned echConfig for
+// non-nil privKeyBin and configBin values before using.
+func loadECHConfig(ctx caddy.Context, configID string) (echConfig, error) {
+ storage := ctx.Storage()
+ logger := ctx.Logger()
+
+ cfgIDKey := path.Join(echConfigsKey, configID)
+ keyKey := path.Join(cfgIDKey, "key.bin")
+ configKey := path.Join(cfgIDKey, "config.bin")
+ metaKey := path.Join(cfgIDKey, "meta.json")
+
+ // if loading anything fails, might as well delete this folder and free up
+ // the config ID; spec is designed to rotate configs frequently anyway
+ // (I consider it a more serious error if we can't clean up the folder,
+ // since leaving stray storage keys is confusing)
+ privKeyBytes, err := storage.Load(ctx, keyKey)
+ if err != nil {
+ delErr := storage.Delete(ctx, cfgIDKey)
+ if delErr != nil {
+ return echConfig{}, fmt.Errorf("error loading private key (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
+ }
+ logger.Warn("could not load ECH private key; deleting its config folder",
+ zap.String("config_id", configID),
+ zap.Error(err))
+ return echConfig{}, nil
+ }
+ echConfigBytes, err := storage.Load(ctx, configKey)
+ if err != nil {
+ delErr := storage.Delete(ctx, cfgIDKey)
+ if delErr != nil {
+ return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
+ }
+ logger.Warn("could not load ECH config; deleting its config folder",
+ zap.String("config_id", configID),
+ zap.Error(err))
+ return echConfig{}, nil
+ }
+ var cfg echConfig
+ if err := cfg.UnmarshalBinary(echConfigBytes); err != nil {
+ delErr := storage.Delete(ctx, cfgIDKey)
+ if delErr != nil {
+ return echConfig{}, fmt.Errorf("error loading ECH config (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
+ }
+ logger.Warn("could not load ECH config; deleted its config folder",
+ zap.String("config_id", configID),
+ zap.Error(err))
+ return echConfig{}, nil
+ }
+ metaBytes, err := storage.Load(ctx, metaKey)
+ if err != nil {
+ delErr := storage.Delete(ctx, cfgIDKey)
+ if delErr != nil {
+ return echConfig{}, fmt.Errorf("error loading ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
+ }
+ logger.Warn("could not load ECH metadata; deleted its config folder",
+ zap.String("config_id", configID),
+ zap.Error(err))
+ return echConfig{}, nil
+ }
+ var meta echConfigMeta
+ if err := json.Unmarshal(metaBytes, &meta); err != nil {
+ // even though it's just metadata, reset the whole config since we can't reliably maintain it
+ delErr := storage.Delete(ctx, cfgIDKey)
+ if delErr != nil {
+ return echConfig{}, fmt.Errorf("error decoding ECH metadata (%v) and cleaning up parent storage key %s: %v", err, cfgIDKey, delErr)
+ }
+ logger.Warn("could not JSON-decode ECH metadata; deleted its config folder",
+ zap.String("config_id", configID),
+ zap.Error(err))
+ return echConfig{}, nil
+ }
+
+ cfg.privKeyBin = privKeyBytes
+ cfg.configBin = echConfigBytes
+ cfg.meta = meta
+
+ return cfg, nil
+}
+
+func generateAndStoreECHConfig(ctx caddy.Context, publicName string) (echConfig, error) {
+ // Go currently has very strict requirements for server-side ECH configs,
+ // to quote the Go 1.24 godoc (with typos of AEAD IDs corrected):
+ //
+ // "Config should be a marshalled ECHConfig associated with PrivateKey. This
+ // must match the config provided to clients byte-for-byte. The config
+ // should only specify the DHKEM(X25519, HKDF-SHA256) KEM ID (0x0020), the
+ // HKDF-SHA256 KDF ID (0x0001), and a subset of the following AEAD IDs:
+ // AES-128-GCM (0x0001), AES-256-GCM (0x0002), ChaCha20Poly1305 (0x0003)."
+ //
+ // So we need to be sure we generate a config within these parameters
+ // so the Go TLS server can use it.
+
+ // generate a key pair
+ const kemChoice = hpke.KEM_X25519_HKDF_SHA256
+ publicKey, privateKey, err := kemChoice.Scheme().GenerateKeyPair()
+ if err != nil {
+ return echConfig{}, err
+ }
+
+ // find an available config ID
+ configID, err := newECHConfigID(ctx)
+ if err != nil {
+ return echConfig{}, fmt.Errorf("generating unique config ID: %v", err)
+ }
+
+ echCfg := echConfig{
+ PublicKey: publicKey,
+ Version: draftTLSESNI22,
+ ConfigID: configID,
+ RawPublicName: publicName,
+ KEMID: kemChoice,
+ CipherSuites: []hpkeSymmetricCipherSuite{
+ {
+ KDFID: hpke.KDF_HKDF_SHA256,
+ AEADID: hpke.AEAD_AES128GCM,
+ },
+ {
+ KDFID: hpke.KDF_HKDF_SHA256,
+ AEADID: hpke.AEAD_AES256GCM,
+ },
+ {
+ KDFID: hpke.KDF_HKDF_SHA256,
+ AEADID: hpke.AEAD_ChaCha20Poly1305,
+ },
+ },
+ sendAsRetry: true,
+ }
+ meta := echConfigMeta{
+ Created: time.Now(),
+ }
+
+ privKeyBytes, err := privateKey.MarshalBinary()
+ if err != nil {
+ return echConfig{}, fmt.Errorf("marshaling ECH private key: %v", err)
+ }
+ echConfigBytes, err := echCfg.MarshalBinary()
+ if err != nil {
+ return echConfig{}, fmt.Errorf("marshaling ECH config: %v", err)
+ }
+ metaBytes, err := json.Marshal(meta)
+ if err != nil {
+ return echConfig{}, fmt.Errorf("marshaling ECH config metadata: %v", err)
+ }
+
+ parentKey := path.Join(echConfigsKey, strconv.Itoa(int(configID)))
+ keyKey := path.Join(parentKey, "key.bin")
+ configKey := path.Join(parentKey, "config.bin")
+ metaKey := path.Join(parentKey, "meta.json")
+
+ if err := ctx.Storage().Store(ctx, keyKey, privKeyBytes); err != nil {
+ return echConfig{}, fmt.Errorf("storing ECH private key: %v", err)
+ }
+ if err := ctx.Storage().Store(ctx, configKey, echConfigBytes); err != nil {
+ return echConfig{}, fmt.Errorf("storing ECH config: %v", err)
+ }
+ if err := ctx.Storage().Store(ctx, metaKey, metaBytes); err != nil {
+ return echConfig{}, fmt.Errorf("storing ECH config metadata: %v", err)
+ }
+
+ echCfg.privKeyBin = privKeyBytes
+ echCfg.configBin = echConfigBytes // this contains the public key
+ echCfg.meta = meta
+
+ return echCfg, nil
+}
+
+// ECH represents an Encrypted ClientHello configuration.
+//
+// EXPERIMENTAL: Subject to change.
+type ECHConfiguration struct {
+ // The public server name (SNI) that will be used in the outer ClientHello.
+ // This should be a domain name for which this server is authoritative,
+ // because Caddy will try to provision a certificate for this name. As an
+ // outer SNI, it is never used for application data (HTTPS, etc.), but it
+ // is necessary for enabling clients to connect securely in some cases.
+ // If this field is empty or missing, or if Caddy cannot get a certificate
+ // for this domain (e.g. the domain's DNS records do not point to this server),
+ // client reliability becomes brittle, and you risk coercing clients to expose
+ // true server names in plaintext, which compromises both the privacy of the
+ // server and makes clients more vulnerable.
+ PublicName string `json:"public_name"`
+}
+
+// ECHPublication configures publication of ECH config(s). It pairs a list
+// of ECH configs with the list of domains they are assigned to protect, and
+// describes how to publish those configs for those domains.
+//
+// Most servers will have only a single publication config, unless their
+// domains are spread across multiple DNS providers or require different
+// methods of publication.
+//
+// EXPERIMENTAL: Subject to change.
+type ECHPublication struct {
+ // The list of ECH configurations to publish, identified by public name.
+ // If not set, all configs will be included for publication by default.
+ //
+ // It is generally advised to maximize the size of your anonymity set,
+ // which implies using as few public names as possible for your sites.
+ // Usually, only a single public name is used to protect all the sites
+ // for a server
+ //
+ // EXPERIMENTAL: This field may be renamed or have its structure changed.
+ Configs []string `json:"configs,omitempty"`
+
+ // The list of ("inner") domain names which are protected with the associated
+ // ECH configurations.
+ //
+ // If not set, all server names registered with the TLS module will be
+ // added to this list implicitly. (This registration is done automatically
+ // by other Caddy apps that use the TLS module. They should register their
+ // configured server names for this purpose. For example, the HTTP server
+ // registers the hostnames for which it applies automatic HTTPS. This is
+ // not something you, the user, have to do.) Most servers
+ //
+ // Names in this list should not appear in any other publication config
+ // object with the same publishers, since the publications will likely
+ // overwrite each other.
+ //
+ // NOTE: In order to publish ECH configs for domains configured for
+ // On-Demand TLS that are not explicitly enumerated elsewhere in the
+ // config, those domain names will have to be listed here. The only
+ // time Caddy knows which domains it is serving with On-Demand TLS is
+ // handshake-time, which is too late for publishing ECH configs; it
+ // means the first connections would not protect the server names,
+ // revealing that information to observers, and thus defeating the
+ // purpose of ECH. Hence the need to list them here so Caddy can
+ // proactively publish ECH configs before clients connect with those
+ // server names in plaintext.
+ Domains []string `json:"domains,omitempty"`
+
+ // How to publish the ECH configurations so clients can know to use
+ // ECH to connect more securely to the server.
+ PublishersRaw caddy.ModuleMap `json:"publishers,omitempty" caddy:"namespace=tls.ech.publishers"`
+ publishers []ECHPublisher
+}
+
+// ECHDNSProvider can service DNS entries for ECH purposes.
+type ECHDNSProvider interface {
+ libdns.RecordGetter
+ libdns.RecordSetter
+}
+
+// ECHDNSPublisher configures how to publish an ECH configuration to
+// DNS records for the specified domains.
+//
+// EXPERIMENTAL: Subject to change.
+type ECHDNSPublisher struct {
+ // The DNS provider module which will establish the HTTPS record(s).
+ ProviderRaw json.RawMessage `json:"provider,omitempty" caddy:"namespace=dns.providers inline_key=name"`
+ provider ECHDNSProvider
+
+ logger *zap.Logger
+}
+
+// CaddyModule returns the Caddy module information.
+func (ECHDNSPublisher) CaddyModule() caddy.ModuleInfo {
+ return caddy.ModuleInfo{
+ ID: "tls.ech.publishers.dns",
+ New: func() caddy.Module { return new(ECHDNSPublisher) },
+ }
+}
+
+func (dnsPub ECHDNSPublisher) Provision(ctx caddy.Context) error {
+ dnsProvMod, err := ctx.LoadModule(dnsPub, "ProviderRaw")
+ if err != nil {
+ return fmt.Errorf("loading ECH DNS provider module: %v", err)
+ }
+ prov, ok := dnsProvMod.(ECHDNSProvider)
+ if !ok {
+ return fmt.Errorf("ECH DNS provider module is not an ECH DNS Provider: %v", err)
+ }
+ dnsPub.provider = prov
+ dnsPub.logger = ctx.Logger()
+ return nil
+}
+
+// PublisherKey returns the name of the DNS provider module.
+// We intentionally omit specific provider configuration (or a hash thereof,
+// since the config is likely sensitive, potentially containing an API key)
+// because it is unlikely that specific configuration, such as an API key,
+// is relevant to unique key use as an ECH config publisher.
+func (dnsPub ECHDNSPublisher) PublisherKey() string {
+ return string(dnsPub.provider.(caddy.Module).CaddyModule().ID)
+}
+
+// PublishECHConfigList publishes the given ECH config list to the given DNS names.
+func (dnsPub *ECHDNSPublisher) PublishECHConfigList(ctx context.Context, innerNames []string, configListBin []byte) error {
+ nameservers := certmagic.RecursiveNameservers(nil) // TODO: we could make resolvers configurable
+
+ for _, domain := range innerNames {
+ zone, err := certmagic.FindZoneByFQDN(ctx, dnsPub.logger, domain, nameservers)
+ if err != nil {
+ dnsPub.logger.Error("could not determine zone for domain",
+ zap.String("domain", domain),
+ zap.Error(err))
+ continue
+ }
+
+ // get any existing HTTPS record for this domain, and augment
+ // our ech SvcParamKey with any other existing SvcParams
+ recs, err := dnsPub.provider.GetRecords(ctx, zone)
+ if err != nil {
+ dnsPub.logger.Error("unable to get existing DNS records to publish ECH data to HTTPS DNS record",
+ zap.String("domain", domain),
+ zap.Error(err))
+ continue
+ }
+ relName := libdns.RelativeName(domain+".", zone)
+ var httpsRec libdns.Record
+ for _, rec := range recs {
+ if rec.Name == relName && rec.Type == "HTTPS" && (rec.Target == "" || rec.Target == ".") {
+ httpsRec = rec
+ }
+ }
+ params := make(svcParams)
+ if httpsRec.Value != "" {
+ params, err = parseSvcParams(httpsRec.Value)
+ if err != nil {
+ dnsPub.logger.Error("unable to parse existing DNS record to publish ECH data to HTTPS DNS record",
+ zap.String("domain", domain),
+ zap.String("https_rec_value", httpsRec.Value),
+ zap.Error(err))
+ continue
+ }
+ }
+
+ // overwrite only the ech SvcParamKey
+ params["ech"] = []string{base64.StdEncoding.EncodeToString(configListBin)}
+
+ // publish record
+ _, err = dnsPub.provider.SetRecords(ctx, zone, []libdns.Record{
+ {
+ // HTTPS and SVCB RRs: RFC 9460 (https://www.rfc-editor.org/rfc/rfc9460)
+ Type: "HTTPS",
+ Name: relName,
+ Priority: 2, // allows a manual override with priority 1
+ Target: ".",
+ Value: params.String(),
+ TTL: 1 * time.Minute, // TODO: for testing only
+ },
+ })
+ if err != nil {
+ dnsPub.logger.Error("unable to publish ECH data to HTTPS DNS record",
+ zap.String("domain", domain),
+ zap.Error(err))
+ continue
+ }
+ }
+
+ return nil
+}
+
+// echConfig represents an ECHConfig from the specification,
+// [draft-ietf-tls-esni-22](https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html).
+type echConfig struct {
+ // "The version of ECH for which this configuration is used.
+ // The version is the same as the code point for the
+ // encrypted_client_hello extension. Clients MUST ignore any
+ // ECHConfig structure with a version they do not support."
+ Version uint16
+
+ // The "length" and "contents" fields defined next in the
+ // structure are implicitly taken care of by cryptobyte
+ // when encoding the following fields:
+
+ // HpkeKeyConfig fields:
+ ConfigID uint8
+ KEMID hpke.KEM
+ PublicKey kem.PublicKey
+ CipherSuites []hpkeSymmetricCipherSuite
+
+ // ECHConfigContents fields:
+ MaxNameLength uint8
+ RawPublicName string
+ RawExtensions []byte
+
+ // these fields are not part of the spec, but are here for
+ // our use when setting up TLS servers or maintenance
+ configBin []byte
+ privKeyBin []byte
+ meta echConfigMeta
+ sendAsRetry bool
+}
+
+func (echCfg echConfig) MarshalBinary() ([]byte, error) {
+ var b cryptobyte.Builder
+ if err := echCfg.marshalBinary(&b); err != nil {
+ return nil, err
+ }
+ return b.Bytes()
+}
+
+// UnmarshalBinary decodes the data back into an ECH config.
+//
+// Borrowed from github.com/OmarTariq612/goech with modifications.
+// Original code: Copyright (c) 2023 Omar Tariq AbdEl-Raziq
+func (echCfg *echConfig) UnmarshalBinary(data []byte) error {
+ var content cryptobyte.String
+ b := cryptobyte.String(data)
+
+ if !b.ReadUint16(&echCfg.Version) {
+ return errInvalidLen
+ }
+ if echCfg.Version != draftTLSESNI22 {
+ return fmt.Errorf("supported version must be %d: got %d", draftTLSESNI22, echCfg.Version)
+ }
+
+ if !b.ReadUint16LengthPrefixed(&content) || !b.Empty() {
+ return errInvalidLen
+ }
+
+ var t cryptobyte.String
+ var pk []byte
+
+ if !content.ReadUint8(&echCfg.ConfigID) ||
+ !content.ReadUint16((*uint16)(&echCfg.KEMID)) ||
+ !content.ReadUint16LengthPrefixed(&t) ||
+ !t.ReadBytes(&pk, len(t)) ||
+ !content.ReadUint16LengthPrefixed(&t) ||
+ len(t)%4 != 0 /* the length of (KDFs and AEADs) must be divisible by 4 */ {
+ return errInvalidLen
+ }
+
+ if !echCfg.KEMID.IsValid() {
+ return fmt.Errorf("invalid KEM ID: %d", echCfg.KEMID)
+ }
+
+ var err error
+ if echCfg.PublicKey, err = echCfg.KEMID.Scheme().UnmarshalBinaryPublicKey(pk); err != nil {
+ return fmt.Errorf("parsing public_key: %w", err)
+ }
+
+ echCfg.CipherSuites = echCfg.CipherSuites[:0]
+
+ for !t.Empty() {
+ var hpkeKDF, hpkeAEAD uint16
+ if !t.ReadUint16(&hpkeKDF) || !t.ReadUint16(&hpkeAEAD) {
+ // we have already checked that the length is divisible by 4
+ panic("this must not happen")
+ }
+ if !hpke.KDF(hpkeKDF).IsValid() {
+ return fmt.Errorf("invalid KDF ID: %d", hpkeKDF)
+ }
+ if !hpke.AEAD(hpkeAEAD).IsValid() {
+ return fmt.Errorf("invalid AEAD ID: %d", hpkeAEAD)
+ }
+ echCfg.CipherSuites = append(echCfg.CipherSuites, hpkeSymmetricCipherSuite{
+ KDFID: hpke.KDF(hpkeKDF),
+ AEADID: hpke.AEAD(hpkeAEAD),
+ })
+ }
+
+ var rawPublicName []byte
+ if !content.ReadUint8(&echCfg.MaxNameLength) ||
+ !content.ReadUint8LengthPrefixed(&t) ||
+ !t.ReadBytes(&rawPublicName, len(t)) ||
+ !content.ReadUint16LengthPrefixed(&t) ||
+ !t.ReadBytes(&echCfg.RawExtensions, len(t)) ||
+ !content.Empty() {
+ return errInvalidLen
+ }
+ echCfg.RawPublicName = string(rawPublicName)
+
+ return nil
+}
+
+var errInvalidLen = errors.New("invalid length")
+
+// marshalBinary writes this config to the cryptobyte builder. If there is an error,
+// it will occur before any writes have happened.
+func (echCfg echConfig) marshalBinary(b *cryptobyte.Builder) error {
+ pk, err := echCfg.PublicKey.MarshalBinary()
+ if err != nil {
+ return err
+ }
+ if l := len(echCfg.RawPublicName); l == 0 || l > 255 {
+ return fmt.Errorf("public name length (%d) must be in the range 1-255", l)
+ }
+
+ b.AddUint16(echCfg.Version)
+ b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) { // "length" field
+ b.AddUint8(echCfg.ConfigID)
+ b.AddUint16(uint16(echCfg.KEMID))
+ b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
+ b.AddBytes(pk)
+ })
+ b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
+ for _, cs := range echCfg.CipherSuites {
+ b.AddUint16(uint16(cs.KDFID))
+ b.AddUint16(uint16(cs.AEADID))
+ }
+ })
+ b.AddUint8(uint8(min(len(echCfg.RawPublicName)+16, 255)))
+ b.AddUint8LengthPrefixed(func(b *cryptobyte.Builder) {
+ b.AddBytes([]byte(echCfg.RawPublicName))
+ })
+ b.AddUint16LengthPrefixed(func(child *cryptobyte.Builder) {
+ child.AddBytes(echCfg.RawExtensions)
+ })
+ })
+
+ return nil
+}
+
+type hpkeSymmetricCipherSuite struct {
+ KDFID hpke.KDF
+ AEADID hpke.AEAD
+}
+
+type echConfigList []echConfig
+
+func (cl echConfigList) MarshalBinary() ([]byte, error) {
+ var b cryptobyte.Builder
+ var err error
+
+ // the list's length prefixes the list, as with most opaque values
+ b.AddUint16LengthPrefixed(func(b *cryptobyte.Builder) {
+ for _, cfg := range cl {
+ if err = cfg.marshalBinary(b); err != nil {
+ break
+ }
+ }
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return b.Bytes()
+}
+
+func newECHConfigID(ctx caddy.Context) (uint8, error) {
+ // uint8 can be 0-255 inclusive
+ const uint8Range = 256
+
+ // avoid repeating storage checks
+ tried := make([]bool, uint8Range)
+
+ // Try to find an available number with random rejection sampling;
+ // i.e. choose a random number and see if it's already taken.
+ // The hard limit on how many times we try to find an available
+ // number is flexible... in theory, assuming uniform distribution,
+ // 256 attempts should make each possible value show up exactly
+ // once, but obviously that won't be the case. We can try more
+ // times to try to ensure that every number gets a chance, which
+ // is especially useful if few are available, or we can lower it
+ // if we assume we should have found an available value by then
+ // and want to limit runtime; for now I choose the middle ground
+ // and just try as many times as there are possible values.
+ for i := 0; i < uint8Range && ctx.Err() == nil; i++ {
+ num := uint8(weakrand.N(uint8Range)) //nolint:gosec
+
+ // don't try the same number a second time
+ if tried[num] {
+ continue
+ }
+ tried[num] = true
+
+ // check to see if any of the subkeys use this config ID
+ numStr := strconv.Itoa(int(num))
+ trialPath := path.Join(echConfigsKey, numStr)
+ if ctx.Storage().Exists(ctx, trialPath) {
+ continue
+ }
+
+ return num, nil
+ }
+
+ if err := ctx.Err(); err != nil {
+ return 0, err
+ }
+
+ return 0, fmt.Errorf("depleted attempts to find an available config_id")
+}
+
+// svcParams represents SvcParamKey and SvcParamValue pairs as
+// described in https://www.rfc-editor.org/rfc/rfc9460 (section 2.1).
+type svcParams map[string][]string
+
+// parseSvcParams parses service parameters into a structured type
+// for safer manipulation.
+func parseSvcParams(input string) (svcParams, error) {
+ if len(input) > 4096 {
+ return nil, fmt.Errorf("input too long: %d", len(input))
+ }
+
+ params := make(svcParams)
+ input = strings.TrimSpace(input) + " "
+
+ for cursor := 0; cursor < len(input); cursor++ {
+ var key, rawVal string
+
+ keyValPair:
+ for i := cursor; i < len(input); i++ {
+ switch input[i] {
+ case '=':
+ key = strings.ToLower(strings.TrimSpace(input[cursor:i]))
+ i++
+ cursor = i
+
+ var quoted bool
+ if input[cursor] == '"' {
+ quoted = true
+ i++
+ cursor = i
+ }
+
+ var escaped bool
+
+ for j := cursor; j < len(input); j++ {
+ switch input[j] {
+ case '"':
+ if !quoted {
+ return nil, fmt.Errorf("illegal DQUOTE at position %d", j)
+ }
+ if !escaped {
+ // end of quoted value
+ rawVal = input[cursor:j]
+ j++
+ cursor = j
+ break keyValPair
+ }
+ case '\\':
+ escaped = true
+ case ' ', '\t', '\n', '\r':
+ if !quoted {
+ // end of unquoted value
+ rawVal = input[cursor:j]
+ cursor = j
+ break keyValPair
+ }
+ default:
+ escaped = false
+ }
+ }
+
+ case ' ', '\t', '\n', '\r':
+ // key with no value (flag)
+ key = input[cursor:i]
+ params[key] = []string{}
+ cursor = i
+ break keyValPair
+ }
+ }
+
+ if rawVal == "" {
+ continue
+ }
+
+ var sb strings.Builder
+
+ var escape int // start of escape sequence (after \, so 0 is never a valid start)
+ for i := 0; i < len(rawVal); i++ {
+ ch := rawVal[i]
+ if escape > 0 {
+ // validate escape sequence
+ // (RFC 9460 Appendix A)
+ // escaped: "\" ( non-digit / dec-octet )
+ // non-digit: "%x21-2F / %x3A-7E"
+ // dec-octet: "0-255 as a 3-digit decimal number"
+ if ch >= '0' && ch <= '9' {
+ // advance to end of decimal octet, which must be 3 digits
+ i += 2
+ if i > len(rawVal) {
+ return nil, fmt.Errorf("value ends with incomplete escape sequence: %s", rawVal[escape:])
+ }
+ decOctet, err := strconv.Atoi(rawVal[escape : i+1])
+ if err != nil {
+ return nil, err
+ }
+ if decOctet < 0 || decOctet > 255 {
+ return nil, fmt.Errorf("invalid decimal octet in escape sequence: %s (%d)", rawVal[escape:i], decOctet)
+ }
+ sb.WriteRune(rune(decOctet))
+ escape = 0
+ continue
+ } else if (ch < 0x21 || ch > 0x2F) && (ch < 0x3A && ch > 0x7E) {
+ return nil, fmt.Errorf("illegal escape sequence %s", rawVal[escape:i])
+ }
+ }
+ switch ch {
+ case ';', '(', ')':
+ // RFC 9460 Appendix A:
+ // > contiguous = 1*( non-special / escaped )
+ // > non-special is VCHAR minus DQUOTE, ";", "(", ")", and "\".
+ return nil, fmt.Errorf("illegal character in value %q at position %d: %s", rawVal, i, string(ch))
+ case '\\':
+ escape = i + 1
+ default:
+ sb.WriteByte(ch)
+ escape = 0
+ }
+ }
+
+ params[key] = strings.Split(sb.String(), ",")
+ }
+
+ return params, nil
+}
+
+// String serializes svcParams into zone presentation format.
+func (params svcParams) String() string {
+ var sb strings.Builder
+ for key, vals := range params {
+ if sb.Len() > 0 {
+ sb.WriteRune(' ')
+ }
+ sb.WriteString(key)
+ var hasVal, needsQuotes bool
+ for _, val := range vals {
+ if len(val) > 0 {
+ hasVal = true
+ }
+ if strings.ContainsAny(val, `" `) {
+ needsQuotes = true
+ }
+ if hasVal && needsQuotes {
+ break
+ }
+ }
+ if hasVal {
+ sb.WriteRune('=')
+ }
+ if needsQuotes {
+ sb.WriteRune('"')
+ }
+ for i, val := range vals {
+ if i > 0 {
+ sb.WriteRune(',')
+ }
+ val = strings.ReplaceAll(val, `"`, `\"`)
+ val = strings.ReplaceAll(val, `,`, `\,`)
+ sb.WriteString(val)
+ }
+ if needsQuotes {
+ sb.WriteRune('"')
+ }
+ }
+ return sb.String()
+}
+
+// ECHPublisher is an interface for publishing ECHConfigList values
+// so that they can be used by clients.
+type ECHPublisher interface {
+ // Returns a key that is unique to this publisher and its configuration.
+ // A publisher's ID combined with its config is a valid key.
+ // It is used to prevent duplicating publications.
+ PublisherKey() string
+
+ // Publishes the ECH config list for the given innerNames. Some publishers
+ // may not need a list of inner/protected names, and can ignore the argument;
+ // most, however, will want to use it to know which inner names are to be
+ // associated with the given ECH config list.
+ PublishECHConfigList(ctx context.Context, innerNames []string, echConfigList []byte) error
+}
+
+type echConfigMeta struct {
+ Created time.Time `json:"created"`
+ Publications publicationHistory `json:"publications"`
+}
+
+// publicationHistory is a map of publisher key to
+// map of inner name to timestamp
+type publicationHistory map[string]map[string]time.Time
+
+// The key prefix when putting ECH configs in storage. After this
+// comes the config ID.
+const echConfigsKey = "ech/configs"
+
+// https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html
+const draftTLSESNI22 = 0xfe0d
+
+// Interface guard
+var _ ECHPublisher = (*ECHDNSPublisher)(nil)
diff --git a/modules/caddytls/ech_test.go b/modules/caddytls/ech_test.go
new file mode 100644
index 000000000..b722d2fbf
--- /dev/null
+++ b/modules/caddytls/ech_test.go
@@ -0,0 +1,129 @@
+package caddytls
+
+import (
+ "reflect"
+ "testing"
+)
+
+func TestParseSvcParams(t *testing.T) {
+ for i, test := range []struct {
+ input string
+ expect svcParams
+ shouldErr bool
+ }{
+ {
+ input: `alpn="h2,h3" no-default-alpn ipv6hint=2001:db8::1 port=443`,
+ expect: svcParams{
+ "alpn": {"h2", "h3"},
+ "no-default-alpn": {},
+ "ipv6hint": {"2001:db8::1"},
+ "port": {"443"},
+ },
+ },
+ {
+ input: `key=value quoted="some string" flag`,
+ expect: svcParams{
+ "key": {"value"},
+ "quoted": {"some string"},
+ "flag": {},
+ },
+ },
+ {
+ input: `key="nested \"quoted\" value,foobar"`,
+ expect: svcParams{
+ "key": {`nested "quoted" value`, "foobar"},
+ },
+ },
+ {
+ input: `alpn=h3,h2 tls-supported-groups=29,23 no-default-alpn ech="foobar"`,
+ expect: svcParams{
+ "alpn": {"h3", "h2"},
+ "tls-supported-groups": {"29", "23"},
+ "no-default-alpn": {},
+ "ech": {"foobar"},
+ },
+ },
+ {
+ input: `escape=\097`,
+ expect: svcParams{
+ "escape": {"a"},
+ },
+ },
+ {
+ input: `escapes=\097\098c`,
+ expect: svcParams{
+ "escapes": {"abc"},
+ },
+ },
+ } {
+ actual, err := parseSvcParams(test.input)
+ if err != nil && !test.shouldErr {
+ t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test.input)
+ continue
+ } else if err == nil && test.shouldErr {
+ t.Errorf("Test %d: Expected an error, but got no error (input=%q)", i, test.input)
+ continue
+ }
+ if !reflect.DeepEqual(test.expect, actual) {
+ t.Errorf("Test %d: Expected %v, got %v (input=%q)", i, test.expect, actual, test.input)
+ continue
+ }
+ }
+}
+
+func TestSvcParamsString(t *testing.T) {
+ // this test relies on the parser also working
+ // because we can't just compare string outputs
+ // since map iteration is unordered
+ for i, test := range []svcParams{
+
+ {
+ "alpn": {"h2", "h3"},
+ "no-default-alpn": {},
+ "ipv6hint": {"2001:db8::1"},
+ "port": {"443"},
+ },
+
+ {
+ "key": {"value"},
+ "quoted": {"some string"},
+ "flag": {},
+ },
+ {
+ "key": {`nested "quoted" value`, "foobar"},
+ },
+ {
+ "alpn": {"h3", "h2"},
+ "tls-supported-groups": {"29", "23"},
+ "no-default-alpn": {},
+ "ech": {"foobar"},
+ },
+ } {
+ combined := test.String()
+ parsed, err := parseSvcParams(combined)
+ if err != nil {
+ t.Errorf("Test %d: Expected no error, but got: %v (input=%q)", i, err, test)
+ continue
+ }
+ if len(parsed) != len(test) {
+ t.Errorf("Test %d: Expected %d keys, but got %d", i, len(test), len(parsed))
+ continue
+ }
+ for key, expectedVals := range test {
+ if expected, actual := len(expectedVals), len(parsed[key]); expected != actual {
+ t.Errorf("Test %d: Expected key %s to have %d values, but had %d", i, key, expected, actual)
+ continue
+ }
+ for j, expected := range expectedVals {
+ if actual := parsed[key][j]; actual != expected {
+ t.Errorf("Test %d key %q value %d: Expected '%s' but got '%s'", i, key, j, expected, actual)
+ continue
+ }
+ }
+ }
+ if !reflect.DeepEqual(parsed, test) {
+ t.Errorf("Test %d: Expected %#v, got %#v", i, test, combined)
+ continue
+ }
+ }
+}
diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go
index abb519eb7..423a2f9a9 100644
--- a/modules/caddytls/tls.go
+++ b/modules/caddytls/tls.go
@@ -20,12 +20,15 @@ import (
"encoding/json"
"fmt"
"log"
+ "net"
"net/http"
"runtime/debug"
+ "strings"
"sync"
"time"
"github.com/caddyserver/certmagic"
+ "github.com/libdns/libdns"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@@ -79,6 +82,7 @@ type TLS struct {
// Disabling OCSP stapling puts clients at greater risk, reduces their
// privacy, and usually lowers client performance. It is NOT recommended
// to disable this unless you are able to justify the costs.
+ //
// EXPERIMENTAL. Subject to change.
DisableOCSPStapling bool `json:"disable_ocsp_stapling,omitempty"`
@@ -89,6 +93,7 @@ type TLS struct {
//
// Disabling these checks should only be done when the storage
// can be trusted to have enough capacity and no other problems.
+ //
// EXPERIMENTAL. Subject to change.
DisableStorageCheck bool `json:"disable_storage_check,omitempty"`
@@ -100,9 +105,23 @@ type TLS struct {
// The instance.uuid file is used to identify the instance of Caddy
// in a cluster. The last_clean.json file is used to store the last
// time the storage was cleaned.
+ //
// EXPERIMENTAL. Subject to change.
DisableStorageClean bool `json:"disable_storage_clean,omitempty"`
+ // Enable Encrypted ClientHello (ECH). ECH protects the server name
+ // (SNI) and other sensitive parameters of a normally-plaintext TLS
+ // ClientHello during a handshake.
+ //
+ // EXPERIMENTAL: Subject to change.
+ EncryptedClientHello *ECH `json:"encrypted_client_hello,omitempty"`
+
+ // The default DNS provider module to use when a DNS module is needed.
+ //
+ // EXPERIMENTAL: Subject to change.
+ DNSRaw json.RawMessage `json:"dns,omitempty" caddy:"namespace=dns.providers inline_key=name"`
+ dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.)
+
certificateLoaders []CertificateLoader
automateNames []string
ctx caddy.Context
@@ -111,6 +130,9 @@ type TLS struct {
logger *zap.Logger
events *caddyevents.App
+ serverNames map[string]struct{}
+ serverNamesMu *sync.Mutex
+
// set of subjects with managed certificates,
// and hashes of manually-loaded certificates
// (managing's value is an optional issuer key, for distinction)
@@ -136,6 +158,40 @@ func (t *TLS) Provision(ctx caddy.Context) error {
t.logger = ctx.Logger()
repl := caddy.NewReplacer()
t.managing, t.loaded = make(map[string]string), make(map[string]string)
+ t.serverNames = make(map[string]struct{})
+ t.serverNamesMu = new(sync.Mutex)
+
+ // set up default DNS module, if any, and make sure it implements all the
+ // common libdns interfaces, since it could be used for a variety of things
+ // (do this before provisioning other modules, since they may rely on this)
+ if len(t.DNSRaw) > 0 {
+ dnsMod, err := ctx.LoadModule(t, "DNSRaw")
+ if err != nil {
+ return fmt.Errorf("loading overall DNS provider module: %v", err)
+ }
+ switch dnsMod.(type) {
+ case interface {
+ libdns.RecordAppender
+ libdns.RecordDeleter
+ libdns.RecordGetter
+ libdns.RecordSetter
+ }:
+ default:
+ return fmt.Errorf("DNS module does not implement the most common libdns interfaces: %T", dnsMod)
+ }
+ t.dns = dnsMod
+ }
+
+ // ECH (Encrypted ClientHello) initialization
+ if t.EncryptedClientHello != nil {
+ t.EncryptedClientHello.configs = make(map[string][]echConfig)
+ outerNames, err := t.EncryptedClientHello.Provision(ctx)
+ if err != nil {
+ return fmt.Errorf("provisioning Encrypted ClientHello components: %v", err)
+ }
+ // outer names should have certificates to reduce client brittleness
+ t.automateNames = append(t.automateNames, outerNames...)
+ }
// set up a new certificate cache; this (re)loads all certificates
cacheOpts := certmagic.CacheOptions{
@@ -178,7 +234,7 @@ func (t *TLS) Provision(ctx caddy.Context) error {
for i, sub := range *automateNames {
subjects[i] = repl.ReplaceAll(sub, "")
}
- t.automateNames = subjects
+ t.automateNames = append(t.automateNames, subjects...)
} else {
return fmt.Errorf("loading certificates with 'automate' requires array of strings, got: %T", modIface)
}
@@ -339,6 +395,16 @@ func (t *TLS) Start() error {
return fmt.Errorf("automate: managing %v: %v", t.automateNames, err)
}
+ // publish ECH configs in the background; does not need to block
+ // server startup, as it could take a while
+ if t.EncryptedClientHello != nil {
+ go func() {
+ if err := t.publishECHConfigs(); err != nil {
+ t.logger.Named("ech").Error("publication(s) failed", zap.Error(err))
+ }
+ }()
+ }
+
if !t.DisableStorageClean {
// start the storage cleaner goroutine and ticker,
// which cleans out expired certificates and more
@@ -422,11 +488,16 @@ func (t *TLS) Cleanup() error {
}
}
} else {
- // no more TLS app running, so delete in-memory cert cache
- certCache.Stop()
- certCacheMu.Lock()
- certCache = nil
- certCacheMu.Unlock()
+ // no more TLS app running, so delete in-memory cert cache, if it was created yet
+ certCacheMu.RLock()
+ hasCache := certCache != nil
+ certCacheMu.RUnlock()
+ if hasCache {
+ certCache.Stop()
+ certCacheMu.Lock()
+ certCache = nil
+ certCacheMu.Unlock()
+ }
}
return nil
@@ -478,6 +549,29 @@ func (t *TLS) Manage(names []string) error {
return nil
}
+// RegisterServerNames registers the provided DNS names with the TLS app.
+// This is currently used to auto-publish Encrypted ClientHello (ECH)
+// configurations, if enabled. Use of this function by apps using the TLS
+// app removes the need for the user to redundantly specify domain names
+// in their configuration. This function separates hostname and port
+// (keeping only the hotsname) and filters IP addresses, which can't be
+// used with ECH.
+//
+// EXPERIMENTAL: This function and its behavior are subject to change.
+func (t *TLS) RegisterServerNames(dnsNames []string) {
+ t.serverNamesMu.Lock()
+ for _, name := range dnsNames {
+ host, _, err := net.SplitHostPort(name)
+ if err != nil {
+ host = name
+ }
+ if strings.TrimSpace(host) != "" && !certmagic.SubjectIsIP(host) {
+ t.serverNames[strings.ToLower(host)] = struct{}{}
+ }
+ }
+ t.serverNamesMu.Unlock()
+}
+
// HandleHTTPChallenge ensures that the ACME HTTP challenge or ZeroSSL HTTP
// validation request is handled for the certificate named by r.Host, if it
// is an HTTP challenge request. It requires that the automation policy for
diff --git a/modules/caddytls/values.go b/modules/caddytls/values.go
index 2f03d254e..3198ffa04 100644
--- a/modules/caddytls/values.go
+++ b/modules/caddytls/values.go
@@ -85,7 +85,7 @@ func getOptimalDefaultCipherSuites() []uint16 {
// or key exchange mechanisms ("curves" traditionally).
// https://golang.org/pkg/crypto/tls/#CurveID
var SupportedCurves = map[string]tls.CurveID{
- "X25519mlkem768": tls.X25519MLKEM768,
+ "x25519mlkem768": tls.X25519MLKEM768,
"x25519": tls.X25519,
"secp256r1": tls.CurveP256,
"secp384r1": tls.CurveP384,