From 546fac83eac441ac1d3ef9549fda4579b1fc1a4c Mon Sep 17 00:00:00 2001 From: houyuwushang Date: Thu, 30 Jul 2026 10:24:22 +0800 Subject: [PATCH] plugin/tls: manage certificates with ACME DNS-01 (#8310) * plugin/tls: add automatic ACME certificates Signed-off-by: houyuwushang * chore: add houyuwushang to CODEOWNERS Signed-off-by: houyuwushang * chore: regenerate maintainer owners Signed-off-by: houyuwushang --------- Signed-off-by: houyuwushang --- CODEOWNERS | 4 +- core/dnsserver/config.go | 19 ++ core/dnsserver/config_test.go | 24 ++ core/dnsserver/zdirectives.go | 2 +- go.mod | 11 +- go.sum | 24 ++ plugin.cfg | 3 +- plugin/chaos/zowners.go | 2 +- plugin/tls/README.md | 51 +++ plugin/tls/acme.go | 582 ++++++++++++++++++++++++++++++++++ plugin/tls/acme_test.go | 328 +++++++++++++++++++ plugin/tls/tls.go | 231 ++++++++++---- test/acme_test.go | 161 ++++++++++ 13 files changed, 1369 insertions(+), 73 deletions(-) create mode 100644 plugin/tls/acme.go create mode 100644 plugin/tls/acme_test.go create mode 100644 test/acme_test.go diff --git a/CODEOWNERS b/CODEOWNERS index 16b7e845e..af95cdde6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,7 +5,7 @@ # @superq, term ends 2025-11-21 # @tantalor93, term ends 2025-11-21 -* @bradbeam @chrisohaver @dilyevsky @jameshartig @greenpau @isolus @johnbelamaric @miekg @pmoroney @rajansandeep @stp-ip @superq @yongtang @Tantalor93 @thevilledev +* @bradbeam @chrisohaver @dilyevsky @jameshartig @greenpau @isolus @johnbelamaric @miekg @pmoroney @rajansandeep @stp-ip @superq @yongtang @Tantalor93 @thevilledev @houyuwushang /.circleci/ @miekg @chrisohaver @rajansandeep /plugin/pkg/ @miekg @chrisohaver @johnbelamaric @yongtang @stp-ip @@ -55,7 +55,7 @@ go.mod @miekg @chrisohaver @johnbelamaric @yongtang @stp-ip /plugin/route53/ @yongtang @dilyevsky /plugin/secondary/ @bradbeam @miekg /plugin/template/ @rtreffer -/plugin/tls/ @johnbelamaric +/plugin/tls/ @johnbelamaric @houyuwushang /plugin/trace/ @johnbelamaric @zouyee @Tantalor93 /plugin/transfer/ @miekg @chrisohaver /plugin/tsig/ @chrisohaver diff --git a/core/dnsserver/config.go b/core/dnsserver/config.go index 6328c65e4..d27ab67d1 100644 --- a/core/dnsserver/config.go +++ b/core/dnsserver/config.go @@ -171,3 +171,22 @@ func GetConfig(c *caddy.Controller) *Config { ctx.saveConfig(key, &Config{ListenHosts: []string{""}}) return GetConfig(c) } + +// AddPluginToAllServerBlocks adds m once to every server block in c's +// instance. It is intended for directives that must handle traffic on a +// listener other than the one where the directive is configured. +func AddPluginToAllServerBlocks(c *caddy.Controller, m plugin.Plugin) { + ctx := c.Context().(*dnsContext) + seen := make(map[*Config]struct{}) + for _, cfg := range ctx.configs { + first := cfg.firstConfigInBlock + if first == nil { + first = cfg + } + if _, ok := seen[first]; ok { + continue + } + seen[first] = struct{}{} + first.AddPlugin(m) + } +} diff --git a/core/dnsserver/config_test.go b/core/dnsserver/config_test.go index a33545565..a445b97a3 100644 --- a/core/dnsserver/config_test.go +++ b/core/dnsserver/config_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin" ) func TestKeyForConfig(t *testing.T) { @@ -65,3 +66,26 @@ func TestGetConfig(t *testing.T) { } }) } + +func TestAddPluginToAllServerBlocks(t *testing.T) { + c := caddy.NewTestController("dns", "") + ctx := c.Context().(*dnsContext) + first := &Config{} + secondZone := &Config{firstConfigInBlock: first} + third := &Config{} + first.firstConfigInBlock = first + third.firstConfigInBlock = third + ctx.configs = []*Config{first, secondZone, third} + + AddPluginToAllServerBlocks(c, func(next plugin.Handler) plugin.Handler { return next }) + + if got := len(first.Plugin); got != 1 { + t.Fatalf("first server block has %d plugins, want 1", got) + } + if got := len(secondZone.Plugin); got != 0 { + t.Fatalf("secondary zone has %d plugins before propagation, want 0", got) + } + if got := len(third.Plugin); got != 1 { + t.Fatalf("second server block has %d plugins, want 1", got) + } +} diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index f6549ff75..2b2f5cf4c 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -14,7 +14,6 @@ var Directives = []string{ "metadata", "geoip", "cancel", - "tls", "proxyproto", "quic", "grpc_server", @@ -48,6 +47,7 @@ var Directives = []string{ "cache", "header", "dnssec", + "tls", "minimal", "template", "transfer", diff --git a/go.mod b/go.mod index 287f44fa7..8f97b7ca7 100644 --- a/go.mod +++ b/go.mod @@ -50,9 +50,13 @@ require ( ) require ( + github.com/caddyserver/certmagic v0.25.3 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/letsencrypt/pebble/v2 v2.10.1 + github.com/mholt/acmez/v3 v3.1.6 github.com/pires/go-proxyproto v0.15.0 github.com/prometheus/exporter-toolkit v0.17.1 + go.uber.org/zap v1.27.1 golang.org/x/net v0.57.0 ) @@ -99,6 +103,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // indirect @@ -112,6 +117,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -141,6 +147,8 @@ require ( github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect + github.com/letsencrypt/challtestsrv v1.4.2 // indirect + github.com/libdns/libdns v1.1.1 // indirect github.com/linkdata/deadlock v0.5.5 // indirect github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -174,6 +182,7 @@ require ( github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.13 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0 // indirect @@ -187,7 +196,7 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect diff --git a/go.sum b/go.sum index f070e6199..ce5f1fd04 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= @@ -112,6 +114,10 @@ github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A= +github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -156,6 +162,8 @@ github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjr github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -261,6 +269,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.1 h1:oKHx3lgN4e5Nno2LKTMrVx+b+NkDptkO9aDireiBDGE= +github.com/letsencrypt/pebble/v2 v2.10.1/go.mod h1:KtYhQ4YTjT5MtoCZ6RTCXlbrrz6cKyXROCuTpIUDJFY= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= @@ -271,6 +285,8 @@ github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18= github.com/mdlayher/vsock v1.3.0 h1:bqQfZ1OznI03y6YiXp2sze05RVdzLn/zsfjnjd4+ivI= github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQTzxxNY= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= @@ -397,6 +413,12 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.etcd.io/etcd/api/v3 v3.6.13 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0= go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk= go.etcd.io/etcd/client/pkg/v3 v3.6.13 h1:7QeMOisYByx8dBA7/CKcwCaPWfjb5C0xpmrIov/8WyY= @@ -472,6 +494,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/plugin.cfg b/plugin.cfg index b971b5e08..7e39166d4 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -23,7 +23,6 @@ root:root metadata:metadata geoip:geoip cancel:cancel -tls:tls proxyproto:proxyproto quic:quic grpc_server:grpc_server @@ -57,6 +56,8 @@ acl:acl cache:cache header:header dnssec:dnssec +# Keep tls here so dnssec can sign ACME challenge records before authoritative backends run. +tls:tls minimal:minimal template:template transfer:transfer diff --git a/plugin/chaos/zowners.go b/plugin/chaos/zowners.go index b9553f3c4..3721b991d 100644 --- a/plugin/chaos/zowners.go +++ b/plugin/chaos/zowners.go @@ -1,4 +1,4 @@ package chaos // Owners are all GitHub handlers of all maintainers. -var Owners = []string{"Tantalor93", "bradbeam", "chrisohaver", "darshanime", "dilyevsky", "ekleiner", "greenpau", "ihac", "inigohu", "isolus", "jameshartig", "johnbelamaric", "miekg", "mqasimsarfraz", "nchrisdk", "nitisht", "pmoroney", "rajansandeep", "rdrozhdzh", "rtreffer", "snebel29", "stp-ip", "superq", "thevilledev", "varyoo", "ykhr53", "yongtang", "zouyee"} +var Owners = []string{"Tantalor93", "bradbeam", "chrisohaver", "darshanime", "dilyevsky", "ekleiner", "greenpau", "houyuwushang", "ihac", "inigohu", "isolus", "jameshartig", "johnbelamaric", "miekg", "mqasimsarfraz", "nchrisdk", "nitisht", "pmoroney", "rajansandeep", "rdrozhdzh", "rtreffer", "snebel29", "stp-ip", "superq", "thevilledev", "varyoo", "ykhr53", "yongtang", "zouyee"} diff --git a/plugin/tls/README.md b/plugin/tls/README.md index 7fa470ef4..8d7d48dd4 100644 --- a/plugin/tls/README.md +++ b/plugin/tls/README.md @@ -42,6 +42,40 @@ to decrypt TLS connections. It compromises security and should only be used for CoreDNS sets the minimum TLS version to TLS 1.2. The maximum TLS version, TLS 1.2 cipher suites, and key exchange mechanisms use the Go `crypto/tls` defaults. +Certificates can instead be obtained and renewed automatically with ACME: + +~~~ txt +tls { + acme DOMAIN... + email EMAIL + ca URL + storage DIRECTORY + ca_root FILE + resolver ADDRESS +} +~~~ + +The `acme` property enables automatic certificate management for one or more domain names. CoreDNS +uses the DNS-01 challenge and answers the temporary `_acme-challenge` TXT queries on every DNS +listener in the same CoreDNS instance. The domains' authoritative DNS must therefore reach this +CoreDNS instance over port 53. HTTP-01 and TLS-ALPN-01 challenges are not used. + +The remaining properties are optional: + +* `email` sets the ACME account contact address. +* `ca` sets the ACME directory URL. It defaults to the Let's Encrypt production directory. +* `storage` sets the directory for ACME accounts, certificates, and private keys. It defaults to + `.coredns/acme` below the Corefile root. +* `ca_root` adds a PEM certificate bundle for connecting to a private ACME server. +* `resolver` sets the DNS resolver used to reach the ACME server and must use `HOST:PORT` syntax. + +Certificate management starts in the background after all listeners are active. A new encrypted +listener can reject TLS handshakes until its first certificate has been obtained. Renewed certificates +are used without restarting CoreDNS. + +The DNS-01 challenge state is local to one CoreDNS process. When authoritative DNS is served by +multiple replicas, validation queries must be routed to the replica performing the ACME operation. + ## Examples Start a DNS-over-TLS server that picks up incoming DNS-over-TLS queries on port 5553 and uses the @@ -72,6 +106,23 @@ https://. { } ~~~ +Obtain and renew a certificate for a DoT server. The plain DNS server answers the DNS-01 challenge; +both server blocks must be in the same CoreDNS process. + +~~~ +.:53 { + file example.org +} + +tls://.:853 { + tls { + acme dns.example.org + email hostmaster@example.org + } + forward . /etc/resolv.conf +} +~~~ + Only Knot DNS' `kdig` supports DNS-over-TLS queries, no command line client supports gRPC making debugging these transports harder than it should be. diff --git a/plugin/tls/acme.go b/plugin/tls/acme.go new file mode 100644 index 000000000..41133c5b4 --- /dev/null +++ b/plugin/tls/acme.go @@ -0,0 +1,582 @@ +package tls + +import ( + "context" + ctls "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/fs" + "net" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + + "github.com/caddyserver/certmagic" + "github.com/mholt/acmez/v3/acme" + "github.com/miekg/dns" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "golang.org/x/net/idna" +) + +var ( + errACMENotReady = errors.New("ACME certificate is not ready") + errACMENameNotManaged = errors.New("server name is not managed by this ACME configuration") +) + +type acmeOptions struct { + domains []string + email string + ca string + storage string + caRoot string + resolver string +} + +type acmeConfigKey struct { + domains string + email string + ca string + storage string + caRoot string + resolver string +} + +func (o acmeOptions) key() acmeConfigKey { + domains := append([]string(nil), o.domains...) + sort.Strings(domains) + return acmeConfigKey{ + domains: strings.Join(domains, "\x00"), + email: o.email, + ca: o.ca, + storage: o.storage, + caRoot: o.caRoot, + resolver: o.resolver, + } +} + +func defaultACMEOptions(root string) (acmeOptions, error) { + if root == "" { + root = "." + } + storage, err := filepath.Abs(filepath.Join(root, ".coredns", "acme")) + if err != nil { + return acmeOptions{}, fmt.Errorf("resolving ACME storage directory: %w", err) + } + return acmeOptions{ca: certmagic.DefaultACME.CA, storage: storage}, nil +} + +func normalizeACMEDomain(domain string) (string, error) { + domain = strings.ToLower(strings.TrimSuffix(domain, ".")) + wildcard := strings.HasPrefix(domain, "*.") + check := strings.TrimPrefix(domain, "*.") + if domain == "" || check == "" || strings.Contains(check, "*") || net.ParseIP(check) != nil { + return "", fmt.Errorf("invalid ACME domain %q", domain) + } + check, err := idna.Lookup.ToASCII(check) + if err != nil { + return "", fmt.Errorf("invalid ACME domain %q: %w", domain, err) + } + if _, ok := dns.IsDomainName(check); !ok { + return "", fmt.Errorf("invalid ACME domain %q", domain) + } + if wildcard { + return "*." + check, nil + } + return check, nil +} + +func validateACMEOptions(o *acmeOptions) error { + if len(o.domains) == 0 { + return errors.New("ACME requires at least one domain") + } + + seen := make(map[string]struct{}, len(o.domains)) + domains := o.domains[:0] + for _, domain := range o.domains { + normalized, err := normalizeACMEDomain(domain) + if err != nil { + return err + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + domains = append(domains, normalized) + } + o.domains = domains + + u, err := url.Parse(o.ca) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return fmt.Errorf("invalid ACME CA URL %q", o.ca) + } + if o.resolver != "" { + host, port, err := net.SplitHostPort(o.resolver) + if err != nil { + return fmt.Errorf("invalid ACME resolver %q: %w", o.resolver, err) + } + if host == "" { + return fmt.Errorf("invalid ACME resolver %q: host is empty", o.resolver) + } + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return fmt.Errorf("invalid ACME resolver port %q", port) + } + } + if _, err := loadACMETrustedRoots(o.caRoot); err != nil { + return err + } + return nil +} + +type acmeDNS01Solver struct { + mu sync.RWMutex + records map[string]map[string]int +} + +func newACMEDNS01Solver() *acmeDNS01Solver { + return &acmeDNS01Solver{records: make(map[string]map[string]int)} +} + +func (s *acmeDNS01Solver) Present(ctx context.Context, challenge acme.Challenge) error { + if err := ctx.Err(); err != nil { + return err + } + name := normalizeChallengeName(challenge.DNS01TXTRecordName()) + value := challenge.DNS01KeyAuthorization() + + s.mu.Lock() + defer s.mu.Unlock() + if s.records[name] == nil { + s.records[name] = make(map[string]int) + } + s.records[name][value]++ + return nil +} + +func (s *acmeDNS01Solver) CleanUp(_ context.Context, challenge acme.Challenge) error { + name := normalizeChallengeName(challenge.DNS01TXTRecordName()) + value := challenge.DNS01KeyAuthorization() + + s.mu.Lock() + defer s.mu.Unlock() + values := s.records[name] + if values[value] <= 1 { + delete(values, value) + } else { + values[value]-- + } + if len(values) == 0 { + delete(s.records, name) + } + return nil +} + +func (s *acmeDNS01Solver) values(name string) []string { + s.mu.RLock() + defer s.mu.RUnlock() + values := s.records[normalizeChallengeName(name)] + answer := make([]string, 0, len(values)) + for value := range values { + answer = append(answer, value) + } + sort.Strings(answer) + return answer +} + +func normalizeChallengeName(name string) string { + return strings.ToLower(dns.Fqdn(name)) +} + +type acmeChallengeHandler struct { + Next plugin.Handler + solver *acmeDNS01Solver +} + +func (h *acmeChallengeHandler) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + if len(r.Question) != 1 || r.Question[0].Qtype != dns.TypeTXT || r.Question[0].Qclass != dns.ClassINET { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + + question := r.Question[0] + values := h.solver.values(question.Name) + if len(values) == 0 { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + + response := new(dns.Msg) + response.SetReply(r) + response.Authoritative = true + for _, value := range values { + response.Answer = append(response.Answer, &dns.TXT{ + Hdr: dns.RR_Header{Name: question.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET}, + Txt: []string{value}, + }) + } + if err := w.WriteMsg(response); err != nil { + return dns.RcodeServerFailure, err + } + return dns.RcodeSuccess, nil +} + +func (*acmeChallengeHandler) Name() string { return "tls" } + +type certificateManager interface { + LoadManaged(context.Context, []string) error + ManageAsync(context.Context, []string) error + GetCertificate(*ctls.ClientHelloInfo) (*ctls.Certificate, error) +} + +type acmeBackend interface { + Manager(acmeConfigKey) certificateManager + Stop() +} + +type acmeBackendFactory func([]*acmeEntry, *acmeDNS01Solver) (acmeBackend, error) + +type acmeEntry struct { + options acmeOptions + key acmeConfigKey + + mu sync.RWMutex + manager certificateManager +} + +func (e *acmeEntry) setManager(manager certificateManager) { + e.mu.Lock() + e.manager = manager + e.mu.Unlock() +} + +func (e *acmeEntry) getCertificate(hello *ctls.ClientHelloInfo) (*ctls.Certificate, error) { + if hello != nil && hello.ServerName != "" && !e.manages(hello.ServerName) { + return nil, fmt.Errorf("%w: %q", errACMENameNotManaged, hello.ServerName) + } + e.mu.RLock() + manager := e.manager + e.mu.RUnlock() + if manager == nil { + return nil, fmt.Errorf("%w for %q", errACMENotReady, e.options.domains) + } + return manager.GetCertificate(hello) +} + +func (e *acmeEntry) manages(serverName string) bool { + serverName, err := normalizeACMEDomain(serverName) + if err != nil { + return false + } + for _, domain := range e.options.domains { + if certmagic.MatchWildcard(serverName, domain) { + return true + } + } + return false +} + +func (e *acmeEntry) tlsConfig() *ctls.Config { + return &ctls.Config{ + MinVersion: ctls.VersionTLS12, + GetCertificate: e.getCertificate, + } +} + +type acmeRuntime struct { + mu sync.Mutex + + entries map[acmeConfigKey]*acmeEntry + domainOwners map[string]acmeConfigKey + solver *acmeDNS01Solver + backendFactory acmeBackendFactory + backend acmeBackend + cancel context.CancelFunc + handlersInstalled bool + started bool + stopped bool +} + +func newACMERuntime(factory acmeBackendFactory) *acmeRuntime { + return &acmeRuntime{ + entries: make(map[acmeConfigKey]*acmeEntry), + domainOwners: make(map[string]acmeConfigKey), + solver: newACMEDNS01Solver(), + backendFactory: factory, + } +} + +func (r *acmeRuntime) add(options acmeOptions) (*acmeEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return nil, errors.New("cannot add ACME configuration after startup") + } + + key := options.key() + if entry := r.entries[key]; entry != nil { + return entry, nil + } + for _, domain := range options.domains { + if owner, ok := r.domainOwners[domain]; ok && owner != key { + return nil, fmt.Errorf("ACME domain %q is configured with conflicting options", domain) + } + } + + entry := &acmeEntry{options: options, key: key} + r.entries[key] = entry + for _, domain := range options.domains { + r.domainOwners[domain] = key + } + return entry, nil +} + +func (r *acmeRuntime) installChallengeHandlers(c *caddy.Controller) { + r.mu.Lock() + defer r.mu.Unlock() + if r.handlersInstalled { + return + } + r.handlersInstalled = true + dnsserver.AddPluginToAllServerBlocks(c, func(next plugin.Handler) plugin.Handler { + return &acmeChallengeHandler{Next: next, solver: r.solver} + }) +} + +func (r *acmeRuntime) start() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.started || r.stopped { + return nil + } + entries := make([]*acmeEntry, 0, len(r.entries)) + for _, entry := range r.entries { + entries = append(entries, entry) + } + backend, err := r.backendFactory(entries, r.solver) + if err != nil { + return err + } + ctx, cancel := context.WithCancel(context.Background()) + for _, entry := range entries { + manager := backend.Manager(entry.key) + if manager == nil { + cancel() + backend.Stop() + for _, configured := range entries { + configured.setManager(nil) + } + return fmt.Errorf("no ACME certificate manager for %q", entry.options.domains) + } + entry.setManager(manager) + if err := manager.LoadManaged(ctx, entry.options.domains); err != nil { + cancel() + backend.Stop() + for _, configured := range entries { + configured.setManager(nil) + } + return fmt.Errorf("loading managed certificates for %q: %w", entry.options.domains, err) + } + } + r.backend = backend + r.cancel = cancel + r.started = true + var errs []error + for _, entry := range entries { + entry.mu.RLock() + manager := entry.manager + entry.mu.RUnlock() + if err := manager.ManageAsync(ctx, entry.options.domains); err != nil { + errs = append(errs, fmt.Errorf("starting ACME management for %q: %w", entry.options.domains, err)) + } + } + return errors.Join(errs...) +} + +func (r *acmeRuntime) stop() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.stopped { + return nil + } + r.stopped = true + if r.cancel != nil { + r.cancel() + } + if r.backend != nil { + r.backend.Stop() + } + return nil +} + +type certmagicBackend struct { + cache *certmagic.Cache + managers map[acmeConfigKey]*certmagicManager +} + +type certmagicManager struct{ *certmagic.Config } + +func (m *certmagicManager) LoadManaged(ctx context.Context, domains []string) error { + for _, domain := range domains { + if _, err := m.CacheManagedCertificate(ctx, domain); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + } + return nil +} + +func newCertmagicBackend(entries []*acmeEntry, solver *acmeDNS01Solver) (acmeBackend, error) { + var configsMu sync.RWMutex + configsByDomain := make(map[string]*certmagic.Config) + logger := newACMELogger() + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) { + configsMu.RLock() + defer configsMu.RUnlock() + for _, name := range cert.Names { + if cfg := configsByDomain[strings.ToLower(strings.TrimSuffix(name, "."))]; cfg != nil { + return cfg, nil + } + } + return nil, fmt.Errorf("no ACME configuration for certificate names %q", cert.Names) + }, + Logger: logger, + }) + + backend := &certmagicBackend{cache: cache, managers: make(map[acmeConfigKey]*certmagicManager)} + for _, entry := range entries { + roots, err := loadACMETrustedRoots(entry.options.caRoot) + if err != nil { + cache.Stop() + return nil, err + } + cfg := certmagic.New(cache, certmagic.Config{ + DefaultServerName: entry.options.domains[0], + Storage: &certmagic.FileStorage{Path: entry.options.storage}, + Logger: logger, + }) + issuer := certmagic.NewACMEIssuer(cfg, certmagic.ACMEIssuer{ + CA: entry.options.ca, + Email: entry.options.email, + Agreed: true, + DisableHTTPChallenge: true, + DisableTLSALPNChallenge: true, + DisableDistributedSolvers: true, + DNS01Solver: solver, + TrustedRoots: roots, + Resolver: entry.options.resolver, + Logger: logger, + }) + cfg.Issuers = []certmagic.Issuer{issuer} + backend.managers[entry.key] = &certmagicManager{Config: cfg} + + configsMu.Lock() + for _, domain := range entry.options.domains { + configsByDomain[domain] = cfg + } + configsMu.Unlock() + } + return backend, nil +} + +func (b *certmagicBackend) Manager(key acmeConfigKey) certificateManager { return b.managers[key] } +func (b *certmagicBackend) Stop() { b.cache.Stop() } + +func loadACMETrustedRoots(path string) (*x509.CertPool, error) { + if path == "" { + return nil, nil + } + pem, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading ACME CA root %q: %w", path, err) + } + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ACME CA root %q contains no certificates", path) + } + return roots, nil +} + +func newACMELogger() *zap.Logger { + encoder := zapcore.NewConsoleEncoder(zapcore.EncoderConfig{ + MessageKey: "message", + NameKey: "logger", + EncodeName: zapcore.FullNameEncoder, + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.LowercaseLevelEncoder, + }) + cores := make([]zapcore.Core, 0, 4) + for _, level := range []zapcore.Level{zapcore.DebugLevel, zapcore.InfoLevel, zapcore.WarnLevel, zapcore.ErrorLevel} { + selected := level + cores = append(cores, zapcore.NewCore( + encoder, + zapcore.AddSync(acmeLogWriter{level: selected}), + zap.LevelEnablerFunc(func(candidate zapcore.Level) bool { + if selected == zapcore.ErrorLevel { + return candidate >= selected + } + return candidate == selected + }), + )) + } + return zap.New(zapcore.NewTee(cores...)) +} + +type acmeLogWriter struct{ level zapcore.Level } + +func (w acmeLogWriter) Write(message []byte) (int, error) { + text := strings.TrimSpace(string(message)) + switch w.level { + case zapcore.DebugLevel: + log.Debug(text) + case zapcore.InfoLevel: + log.Info(text) + case zapcore.WarnLevel: + log.Warning(text) + default: + log.Error(text) + } + return len(message), nil +} + +func (acmeLogWriter) Sync() error { return nil } + +type acmeRuntimeStorageKey struct{} + +func getACMERuntime(c *caddy.Controller) *acmeRuntime { + key := acmeRuntimeStorageKey{} + if value := c.Get(key); value != nil { + return value.(*acmeRuntime) + } + runtime := newACMERuntime(newCertmagicBackend) + c.Set(key, runtime) + c.OnShutdown(runtime.stop) + return runtime +} + +func acmeStartupHook(event caddy.EventName, info any) error { + if event != caddy.InstanceStartupEvent { + return nil + } + instance, ok := info.(*caddy.Instance) + if !ok { + return fmt.Errorf("unexpected ACME startup event payload %T", info) + } + instance.StorageMu.RLock() + value := instance.Storage[acmeRuntimeStorageKey{}] + instance.StorageMu.RUnlock() + if value == nil { + return nil + } + return value.(*acmeRuntime).start() +} diff --git a/plugin/tls/acme_test.go b/plugin/tls/acme_test.go new file mode 100644 index 000000000..f40011956 --- /dev/null +++ b/plugin/tls/acme_test.go @@ -0,0 +1,328 @@ +package tls + +import ( + "context" + ctls "crypto/tls" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/coredns/caddy" + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/mholt/acmez/v3/acme" + "github.com/miekg/dns" +) + +func TestParseACMETLS(t *testing.T) { + root := t.TempDir() + rootPEM, err := os.ReadFile("test_ca.pem") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "roots.pem"), rootPEM, 0600); err != nil { + t.Fatal(err) + } + c := caddy.NewTestController("dns", `tls { + acme DNS.Example. dns.example + email admin@example.org + ca https://ca.example/directory + storage certs + ca_root roots.pem + resolver 1.1.1.1:53 + }`) + cfg := dnsserver.GetConfig(c) + cfg.Root = root + + if err := setup(c); err != nil { + t.Fatalf("setup failed: %v", err) + } + if cfg.TLSConfig == nil { + t.Fatal("TLS config was not set") + } + if cfg.TLSConfig.MinVersion != ctls.VersionTLS12 { + t.Fatalf("minimum TLS version is %d, want TLS 1.2", cfg.TLSConfig.MinVersion) + } + if _, err := cfg.TLSConfig.GetCertificate(&ctls.ClientHelloInfo{ServerName: "dns.example"}); !errors.Is(err, errACMENotReady) { + t.Fatalf("GetCertificate error = %v, want %v", err, errACMENotReady) + } + if got := len(cfg.Plugin); got != 1 { + t.Fatalf("installed %d challenge handlers, want 1", got) + } + + runtime := c.Get(acmeRuntimeStorageKey{}).(*acmeRuntime) + if got := len(runtime.entries); got != 1 { + t.Fatalf("runtime has %d entries, want 1", got) + } + for _, entry := range runtime.entries { + if len(entry.options.domains) != 1 || entry.options.domains[0] != "dns.example" { + t.Fatalf("domains = %v, want [dns.example]", entry.options.domains) + } + if want := filepath.Join(root, "certs"); entry.options.storage != want { + t.Fatalf("storage = %q, want %q", entry.options.storage, want) + } + if want := filepath.Join(root, "roots.pem"); entry.options.caRoot != want { + t.Fatalf("CA root = %q, want %q", entry.options.caRoot, want) + } + } +} + +func TestACMEDirectiveOrder(t *testing.T) { + indexes := make(map[string]int) + for i, directive := range dnsserver.Directives { + indexes[directive] = i + } + if indexes["tls"] != indexes["dnssec"]+1 { + t.Fatalf("tls directive index = %d, want immediately after dnssec at %d", indexes["tls"], indexes["dnssec"]) + } +} + +func TestParseACMETLSErrors(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"missing domain", "tls", "at least one domain"}, + {"missing acme arguments", "tls {\nacme\n}", "Wrong argument"}, + {"IP identifier", "tls {\nacme 192.0.2.1\n}", "invalid ACME domain"}, + {"invalid CA", "tls {\nacme dns.example\nca ftp://ca.example\n}", "invalid ACME CA URL"}, + {"invalid resolver", "tls {\nacme dns.example\nresolver 1.1.1.1\n}", "invalid ACME resolver"}, + {"invalid resolver port", "tls {\nacme dns.example\nresolver 1.1.1.1:dns\n}", "invalid ACME resolver port"}, + {"invalid wildcard", "tls {\nacme *.*.example\n}", "invalid ACME domain"}, + {"missing CA root", "tls {\nacme dns.example\nca_root missing.pem\n}", "reading ACME CA root"}, + {"duplicate option", "tls {\nacme dns.example\nacme other.example\n}", "only be specified once"}, + {"unknown option", "tls {\nacme dns.example\nprovider example\n}", "unknown ACME option"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + c := caddy.NewTestController("dns", tc.input) + err := setup(c) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("setup error = %v, want substring %q", err, tc.want) + } + }) + } +} + +func TestACMEDNS01ChallengeHandler(t *testing.T) { + solver := newACMEDNS01Solver() + challenge := acme.Challenge{ + Identifier: acme.Identifier{Type: "dns", Value: "dns.example"}, + KeyAuthorization: "token.thumbprint", + } + if err := solver.Present(context.Background(), challenge); err != nil { + t.Fatal(err) + } + if err := solver.Present(context.Background(), challenge); err != nil { + t.Fatal(err) + } + + nextCalled := false + handler := &acmeChallengeHandler{ + solver: solver, + Next: plugin.HandlerFunc(func(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) { + nextCalled = true + return dns.RcodeNameError, nil + }), + } + request := new(dns.Msg) + request.SetQuestion("_ACME-CHALLENGE.DNS.EXAMPLE.", dns.TypeTXT) + recorder := dnstest.NewRecorder(&test.ResponseWriter{}) + rcode, err := handler.ServeDNS(context.Background(), recorder, request) + if err != nil || rcode != dns.RcodeSuccess { + t.Fatalf("ServeDNS = (%d, %v), want success", rcode, err) + } + if nextCalled { + t.Fatal("challenge query was forwarded to the next plugin") + } + if len(recorder.Msg.Answer) != 1 { + t.Fatalf("answer count = %d, want 1", len(recorder.Msg.Answer)) + } + txt := recorder.Msg.Answer[0].(*dns.TXT) + if got, want := txt.Txt[0], challenge.DNS01KeyAuthorization(); got != want { + t.Fatalf("TXT value = %q, want %q", got, want) + } + + if err := solver.CleanUp(context.Background(), challenge); err != nil { + t.Fatal(err) + } + if got := len(solver.values(request.Question[0].Name)); got != 1 { + t.Fatalf("record count after first cleanup = %d, want 1", got) + } + if err := solver.CleanUp(context.Background(), challenge); err != nil { + t.Fatal(err) + } + recorder = dnstest.NewRecorder(&test.ResponseWriter{}) + rcode, err = handler.ServeDNS(context.Background(), recorder, request) + if err != nil || rcode != dns.RcodeNameError || !nextCalled { + t.Fatalf("ServeDNS after cleanup = (%d, %v), nextCalled=%v", rcode, err, nextCalled) + } +} + +type fakeCertificateManager struct { + loadCalls int + manageCalls int + ctx context.Context + certificate *ctls.Certificate + loadErr error + manageErr error +} + +func (m *fakeCertificateManager) LoadManaged(context.Context, []string) error { + m.loadCalls++ + return m.loadErr +} + +func (m *fakeCertificateManager) ManageAsync(ctx context.Context, _ []string) error { + m.manageCalls++ + m.ctx = ctx + return m.manageErr +} + +func (m *fakeCertificateManager) GetCertificate(*ctls.ClientHelloInfo) (*ctls.Certificate, error) { + return m.certificate, nil +} + +type fakeACMEBackend struct { + managers map[acmeConfigKey]certificateManager + stops int +} + +func (b *fakeACMEBackend) Manager(key acmeConfigKey) certificateManager { return b.managers[key] } +func (b *fakeACMEBackend) Stop() { b.stops++ } + +func TestACMERuntimeLifecycle(t *testing.T) { + options := acmeOptions{ + domains: []string{"dns.example"}, + ca: "https://ca.example/directory", + storage: t.TempDir(), + } + manager := &fakeCertificateManager{certificate: &ctls.Certificate{}} + backend := &fakeACMEBackend{} + runtime := newACMERuntime(func(entries []*acmeEntry, _ *acmeDNS01Solver) (acmeBackend, error) { + backend.managers = make(map[acmeConfigKey]certificateManager, len(entries)) + for _, entry := range entries { + backend.managers[entry.key] = manager + } + return backend, nil + }) + entry, err := runtime.add(options) + if err != nil { + t.Fatal(err) + } + if _, err := entry.getCertificate(&ctls.ClientHelloInfo{}); !errors.Is(err, errACMENotReady) { + t.Fatalf("pre-start GetCertificate error = %v", err) + } + if err := runtime.start(); err != nil { + t.Fatal(err) + } + if manager.loadCalls != 1 { + t.Fatalf("LoadManaged calls = %d, want 1", manager.loadCalls) + } + if manager.manageCalls != 1 { + t.Fatalf("ManageAsync calls = %d, want 1", manager.manageCalls) + } + if cert, err := entry.getCertificate(&ctls.ClientHelloInfo{}); err != nil || cert != manager.certificate { + t.Fatalf("GetCertificate = (%p, %v), want %p", cert, err, manager.certificate) + } + if _, err := entry.getCertificate(&ctls.ClientHelloInfo{ServerName: "other.example"}); !errors.Is(err, errACMENameNotManaged) { + t.Fatalf("unmanaged GetCertificate error = %v, want %v", err, errACMENameNotManaged) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + select { + case <-manager.ctx.Done(): + default: + t.Fatal("management context was not canceled") + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + if backend.stops != 1 { + t.Fatalf("backend stops = %d, want 1", backend.stops) + } +} + +func TestACMEEntryManagedNames(t *testing.T) { + entry := &acmeEntry{options: acmeOptions{domains: []string{"dns.example", "*.wild.example"}}} + for _, tc := range []struct { + name string + want bool + }{ + {"DNS.EXAMPLE.", true}, + {"one.wild.example", true}, + {"two.one.wild.example", false}, + {"other.example", false}, + } { + if got := entry.manages(tc.name); got != tc.want { + t.Errorf("manages(%q) = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestACMERuntimeDeduplicatesAndRejectsConflicts(t *testing.T) { + runtime := newACMERuntime(nil) + first := acmeOptions{domains: []string{"a.example", "b.example"}, ca: "https://ca.example", storage: "one"} + entry, err := runtime.add(first) + if err != nil { + t.Fatal(err) + } + duplicate, err := runtime.add(acmeOptions{domains: []string{"b.example", "a.example"}, ca: first.ca, storage: first.storage}) + if err != nil { + t.Fatal(err) + } + if duplicate != entry { + t.Fatal("equivalent ACME configurations were not deduplicated") + } + _, err = runtime.add(acmeOptions{domains: []string{"a.example"}, ca: "https://other-ca.example", storage: "two"}) + if err == nil || !strings.Contains(err.Error(), "conflicting options") { + t.Fatalf("conflicting configuration error = %v", err) + } +} + +func TestACMERuntimeStartupFailures(t *testing.T) { + options := acmeOptions{domains: []string{"dns.example"}, ca: "https://ca.example", storage: t.TempDir()} + for _, tc := range []struct { + name string + manager *fakeCertificateManager + available bool + }{ + {"load failure releases backend", &fakeCertificateManager{loadErr: errors.New("load failed")}, false}, + {"management failure keeps loaded certificate", &fakeCertificateManager{certificate: &ctls.Certificate{}, manageErr: errors.New("manage failed")}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + backend := &fakeACMEBackend{} + runtime := newACMERuntime(func(entries []*acmeEntry, _ *acmeDNS01Solver) (acmeBackend, error) { + backend.managers = map[acmeConfigKey]certificateManager{entries[0].key: tc.manager} + return backend, nil + }) + entry, err := runtime.add(options) + if err != nil { + t.Fatal(err) + } + if err := runtime.start(); err == nil { + t.Fatal("startup succeeded, want error") + } + _, err = entry.getCertificate(&ctls.ClientHelloInfo{}) + if tc.available && err != nil { + t.Fatalf("loaded certificate became unavailable: %v", err) + } + if !tc.available && !errors.Is(err, errACMENotReady) { + t.Fatalf("GetCertificate error = %v, want %v", err, errACMENotReady) + } + if err := runtime.stop(); err != nil { + t.Fatal(err) + } + if backend.stops != 1 { + t.Fatalf("backend stops = %d, want 1", backend.stops) + } + }) + } +} diff --git a/plugin/tls/tls.go b/plugin/tls/tls.go index 9bbe7ead9..929511834 100644 --- a/plugin/tls/tls.go +++ b/plugin/tls/tls.go @@ -2,6 +2,7 @@ package tls import ( ctls "crypto/tls" + "fmt" "os" "path/filepath" @@ -14,7 +15,10 @@ import ( var log = clog.NewWithPlugin("tls") -func init() { plugin.Register("tls", setup) } +func init() { + plugin.Register("tls", setup) + caddy.RegisterEventHook("tls-acme", acmeStartupHook) +} func setup(c *caddy.Controller) error { err := parseTLS(c) @@ -33,76 +37,169 @@ func parseTLS(c *caddy.Controller) error { for c.Next() { args := c.RemainingArgs() - if len(args) < 2 || len(args) > 3 { - return plugin.Error("tls", c.ArgErr()) - } - clientAuth := ctls.NoClientCert - var keyLog string - for c.NextBlock() { - switch c.Val() { - case "client_auth": - authTypeArgs := c.RemainingArgs() - if len(authTypeArgs) != 1 { - return c.ArgErr() - } - switch authTypeArgs[0] { - case "nocert": - clientAuth = ctls.NoClientCert - case "request": - clientAuth = ctls.RequestClientCert - case "require": - clientAuth = ctls.RequireAnyClientCert - case "verify_if_given": - clientAuth = ctls.VerifyClientCertIfGiven - case "require_and_verify": - clientAuth = ctls.RequireAndVerifyClientCert - default: - return c.Errf("unknown authentication type '%s'", authTypeArgs[0]) - } - case "keylog": - args := c.RemainingArgs() - if len(args) != 1 { - return c.ArgErr() - } - keyLog = args[0] - if !filepath.IsAbs(keyLog) && config.Root != "" { - keyLog = filepath.Join(config.Root, keyLog) - } - default: - return c.Errf("unknown option '%s'", c.Val()) + if len(args) == 0 { + tlsConfig, err := parseACMETLS(c, config) + if err != nil { + return err } + config.TLSConfig = tlsConfig + continue } - for i := range args { - if !filepath.IsAbs(args[i]) && config.Root != "" { - args[i] = filepath.Join(config.Root, args[i]) - } - } - tls, err := tls.NewTLSConfigFromArgs(args...) - if err != nil { + if err := parseManualTLS(c, config, args); err != nil { return err } - tls.ClientAuth = clientAuth - // NewTLSConfigFromArgs only sets RootCAs, so we need to let ClientCAs refer to it. - tls.ClientCAs = tls.RootCAs - - if len(keyLog) > 0 { - absKeyLog, err := filepath.Abs(keyLog) - if err != nil { - return c.Errf("unable to write TLS Key Log to %q: %s", keyLog, err) - } - f, err := os.OpenFile(absKeyLog, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) - if err != nil { - return c.Errf("unable to write TLS Key Log to %q: %s", absKeyLog, err) - } - c.OnShutdown(func() error { - f.Close() - return nil - }) - tls.KeyLogWriter = f - log.Warningf("Writing TLS Key Log to %q\n", absKeyLog) - } - - config.TLSConfig = tls } return nil } + +func parseManualTLS(c *caddy.Controller, config *dnsserver.Config, args []string) error { + if len(args) < 2 || len(args) > 3 { + return plugin.Error("tls", c.ArgErr()) + } + clientAuth := ctls.NoClientCert + var keyLog string + for c.NextBlock() { + switch c.Val() { + case "client_auth": + authTypeArgs := c.RemainingArgs() + if len(authTypeArgs) != 1 { + return c.ArgErr() + } + switch authTypeArgs[0] { + case "nocert": + clientAuth = ctls.NoClientCert + case "request": + clientAuth = ctls.RequestClientCert + case "require": + clientAuth = ctls.RequireAnyClientCert + case "verify_if_given": + clientAuth = ctls.VerifyClientCertIfGiven + case "require_and_verify": + clientAuth = ctls.RequireAndVerifyClientCert + default: + return c.Errf("unknown authentication type '%s'", authTypeArgs[0]) + } + case "keylog": + args := c.RemainingArgs() + if len(args) != 1 { + return c.ArgErr() + } + keyLog = args[0] + if !filepath.IsAbs(keyLog) && config.Root != "" { + keyLog = filepath.Join(config.Root, keyLog) + } + default: + return c.Errf("unknown option '%s'", c.Val()) + } + } + for i := range args { + if !filepath.IsAbs(args[i]) && config.Root != "" { + args[i] = filepath.Join(config.Root, args[i]) + } + } + tls, err := tls.NewTLSConfigFromArgs(args...) + if err != nil { + return err + } + tls.ClientAuth = clientAuth + // NewTLSConfigFromArgs only sets RootCAs, so we need to let ClientCAs refer to it. + tls.ClientCAs = tls.RootCAs + + if len(keyLog) > 0 { + absKeyLog, err := filepath.Abs(keyLog) + if err != nil { + return c.Errf("unable to write TLS Key Log to %q: %s", keyLog, err) + } + f, err := os.OpenFile(absKeyLog, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600) + if err != nil { + return c.Errf("unable to write TLS Key Log to %q: %s", absKeyLog, err) + } + c.OnShutdown(func() error { + f.Close() + return nil + }) + tls.KeyLogWriter = f + log.Warningf("Writing TLS Key Log to %q\n", absKeyLog) + } + + config.TLSConfig = tls + return nil +} + +func parseACMETLS(c *caddy.Controller, config *dnsserver.Config) (*ctls.Config, error) { + options, err := defaultACMEOptions(config.Root) + if err != nil { + return nil, err + } + seen := make(map[string]bool) + for c.NextBlock() { + name := c.Val() + args := c.RemainingArgs() + if seen[name] { + return nil, c.Errf("ACME option %q can only be specified once", name) + } + seen[name] = true + switch name { + case "acme": + if len(args) == 0 { + return nil, c.ArgErr() + } + options.domains = args + case "email": + if len(args) != 1 { + return nil, c.ArgErr() + } + options.email = args[0] + case "ca": + if len(args) != 1 { + return nil, c.ArgErr() + } + options.ca = args[0] + case "storage": + if len(args) != 1 { + return nil, c.ArgErr() + } + options.storage, err = resolveACMEPath(config.Root, args[0]) + if err != nil { + return nil, err + } + case "ca_root": + if len(args) != 1 { + return nil, c.ArgErr() + } + options.caRoot, err = resolveACMEPath(config.Root, args[0]) + if err != nil { + return nil, err + } + case "resolver": + if len(args) != 1 { + return nil, c.ArgErr() + } + options.resolver = args[0] + default: + return nil, c.Errf("unknown ACME option %q", name) + } + } + if err := validateACMEOptions(&options); err != nil { + return nil, err + } + + runtime := getACMERuntime(c) + entry, err := runtime.add(options) + if err != nil { + return nil, err + } + runtime.installChallengeHandlers(c) + return entry.tlsConfig(), nil +} + +func resolveACMEPath(root, path string) (string, error) { + if !filepath.IsAbs(path) && root != "" { + path = filepath.Join(root, path) + } + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolving ACME path %q: %w", path, err) + } + return abs, nil +} diff --git a/test/acme_test.go b/test/acme_test.go new file mode 100644 index 000000000..03828127e --- /dev/null +++ b/test/acme_test.go @@ -0,0 +1,161 @@ +package test + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "log" + "net" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/letsencrypt/pebble/v2/ca" + "github.com/letsencrypt/pebble/v2/db" + "github.com/letsencrypt/pebble/v2/va" + "github.com/letsencrypt/pebble/v2/wfe" + "github.com/miekg/dns" +) + +func TestACMEDNS01CertificateManagement(t *testing.T) { + t.Setenv("PEBBLE_VA_NOSLEEP", "1") + t.Setenv("PEBBLE_WFE_NONCEREJECT", "0") + t.Setenv("PEBBLE_AUTHZREUSE", "0") + + dnsPort := freeTCPUDPPort(t) + dotPort := freeTCPPort(t) + resolver := net.JoinHostPort("127.0.0.1", fmt.Sprint(dnsPort)) + + logger := log.New(io.Discard, "", 0) + store := db.NewMemoryStore() + pebbleCA := ca.New(logger, store, "", "ecdsa", 0, 1, map[string]ca.Profile{ + "default": {Description: "default", ValidityPeriod: 3600}, + }) + validator := va.New(logger, 5002, 5001, false, resolver, store) + frontend := wfe.New(logger, store, validator, pebbleCA, []string{"pebble"}, false, false, 0, 0) + acmeServer := httptest.NewTLSServer(frontend.Handler()) + defer acmeServer.Close() + + tempDir := t.TempDir() + serverRoot := filepath.Join(tempDir, "acme-server-root.pem") + serverCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: acmeServer.Certificate().Raw}) + if err := os.WriteFile(serverRoot, serverCert, 0600); err != nil { + t.Fatal(err) + } + + const domain = "dns.example" + corefile := fmt.Sprintf(`dns://.:%d { + bind 127.0.0.1 + whoami +} + +tls://.:%d { + bind 127.0.0.1 + tls { + acme %s + ca %s%s + ca_root %s + storage %s + resolver 127.0.0.1:%d + } + whoami +}`, dnsPort, dotPort, domain, acmeServer.URL, wfe.DirectoryPath, serverRoot, filepath.Join(tempDir, "certificates"), dnsPort) + + instance, err := CoreDNSServer(corefile) + if err != nil { + t.Fatalf("starting CoreDNS: %v", err) + } + defer func() { + instance.ShutdownCallbacks() + instance.Stop() + }() + + address := net.JoinHostPort("127.0.0.1", fmt.Sprint(dotPort)) + var peerCertificates []*x509.Certificate + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: time.Second}, "tcp", address, &tls.Config{ + ServerName: domain, + InsecureSkipVerify: true, // The issued chain is verified explicitly below. + MinVersion: tls.VersionTLS12, + }) + if err == nil { + peerCertificates = conn.ConnectionState().PeerCertificates + conn.Close() + break + } + time.Sleep(100 * time.Millisecond) + } + if len(peerCertificates) == 0 { + t.Fatal("CoreDNS did not obtain and serve an ACME certificate before the deadline") + } + + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(pebbleCA.GetRootCert(0).PEM()) { + t.Fatal("could not add Pebble root certificate") + } + intermediates := x509.NewCertPool() + for _, certificate := range peerCertificates[1:] { + intermediates.AddCert(certificate) + } + if _, err := peerCertificates[0].Verify(x509.VerifyOptions{ + DNSName: domain, + Roots: roots, + Intermediates: intermediates, + }); err != nil { + t.Fatalf("verifying managed certificate: %v", err) + } + + request := new(dns.Msg) + request.SetQuestion("whoami.example.", dns.TypeA) + client := &dns.Client{ + Net: "tcp-tls", + TLSConfig: &tls.Config{ + ServerName: domain, + RootCAs: roots, + MinVersion: tls.VersionTLS12, + }, + Timeout: 2 * time.Second, + } + response, _, err := client.Exchange(request, address) + if err != nil { + t.Fatalf("querying CoreDNS with the managed certificate: %v", err) + } + if response.Rcode != dns.RcodeSuccess { + t.Fatalf("DoT response code = %d, want success", response.Rcode) + } +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +func freeTCPUDPPort(t *testing.T) int { + t.Helper() + for range 10 { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + packet, err := net.ListenPacket("udp", net.JoinHostPort("127.0.0.1", fmt.Sprint(port))) + if err == nil { + packet.Close() + listener.Close() + return port + } + listener.Close() + } + t.Fatal("could not find a free TCP/UDP port") + return 0 +}