mirror of
https://github.com/coredns/coredns.git
synced 2026-08-27 23:27:05 -04:00
plugin/tls: manage certificates with ACME DNS-01 (#8310)
* plugin/tls: add automatic ACME certificates Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com> * chore: add houyuwushang to CODEOWNERS Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com> * chore: regenerate maintainer owners Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com> --------- Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
# @superq, term ends 2025-11-21
|
# @superq, term ends 2025-11-21
|
||||||
# @tantalor93, 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
|
/.circleci/ @miekg @chrisohaver @rajansandeep
|
||||||
/plugin/pkg/ @miekg @chrisohaver @johnbelamaric @yongtang @stp-ip
|
/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/route53/ @yongtang @dilyevsky
|
||||||
/plugin/secondary/ @bradbeam @miekg
|
/plugin/secondary/ @bradbeam @miekg
|
||||||
/plugin/template/ @rtreffer
|
/plugin/template/ @rtreffer
|
||||||
/plugin/tls/ @johnbelamaric
|
/plugin/tls/ @johnbelamaric @houyuwushang
|
||||||
/plugin/trace/ @johnbelamaric @zouyee @Tantalor93
|
/plugin/trace/ @johnbelamaric @zouyee @Tantalor93
|
||||||
/plugin/transfer/ @miekg @chrisohaver
|
/plugin/transfer/ @miekg @chrisohaver
|
||||||
/plugin/tsig/ @chrisohaver
|
/plugin/tsig/ @chrisohaver
|
||||||
|
|||||||
@@ -171,3 +171,22 @@ func GetConfig(c *caddy.Controller) *Config {
|
|||||||
ctx.saveConfig(key, &Config{ListenHosts: []string{""}})
|
ctx.saveConfig(key, &Config{ListenHosts: []string{""}})
|
||||||
return GetConfig(c)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/coredns/caddy"
|
"github.com/coredns/caddy"
|
||||||
|
"github.com/coredns/coredns/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestKeyForConfig(t *testing.T) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ var Directives = []string{
|
|||||||
"metadata",
|
"metadata",
|
||||||
"geoip",
|
"geoip",
|
||||||
"cancel",
|
"cancel",
|
||||||
"tls",
|
|
||||||
"proxyproto",
|
"proxyproto",
|
||||||
"quic",
|
"quic",
|
||||||
"grpc_server",
|
"grpc_server",
|
||||||
@@ -48,6 +47,7 @@ var Directives = []string{
|
|||||||
"cache",
|
"cache",
|
||||||
"header",
|
"header",
|
||||||
"dnssec",
|
"dnssec",
|
||||||
|
"tls",
|
||||||
"minimal",
|
"minimal",
|
||||||
"template",
|
"template",
|
||||||
"transfer",
|
"transfer",
|
||||||
|
|||||||
11
go.mod
11
go.mod
@@ -50,9 +50,13 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/caddyserver/certmagic v0.25.3
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
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/pires/go-proxyproto v0.15.0
|
||||||
github.com/prometheus/exporter-toolkit v0.17.1
|
github.com/prometheus/exporter-toolkit v0.17.1
|
||||||
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/net v0.57.0
|
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/aws-sdk-go-v2/service/sts v1.44.1 // indirect
|
||||||
github.com/aws/smithy-go v1.27.3 // indirect
|
github.com/aws/smithy-go v1.27.3 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // 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/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cihub/seelog v0.0.0-20170130134532-f561c5e57575 // 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/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
|
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // 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-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.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/compress v1.19.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.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/linkdata/deadlock v0.5.5 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||||
github.com/mailru/easyjson v0.7.7 // 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/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.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.etcd.io/etcd/client/pkg/v3 v3.6.13 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/collector/component v1.51.1-0.20260205185216-81bc641f26c0 // 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.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.uber.org/multierr 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/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
|
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
|
||||||
|
|||||||
24
go.sum
24
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/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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
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 h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
|
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=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
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=
|
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/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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
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.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 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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/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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
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 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo=
|
||||||
github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0=
|
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=
|
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/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 h1:bqQfZ1OznI03y6YiXp2sze05RVdzLn/zsfjnjd4+ivI=
|
||||||
github.com/mdlayher/vsock v1.3.0/go.mod h1:WsuksavOvwCnV5UqGHUkvAvCy+Dqy81y4goKQTzxxNY=
|
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.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
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/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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 h1:AvHPZv15LYEe7tZDyFglv7xnbiuF6GMZpZqKpIzXTt0=
|
||||||
go.etcd.io/etcd/api/v3 v3.6.13/go.mod h1:X9+3gaKwzjlOxzo6TZ2u3b7HcHBcAL+Ph7EBPjI/VWk=
|
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=
|
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/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 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ root:root
|
|||||||
metadata:metadata
|
metadata:metadata
|
||||||
geoip:geoip
|
geoip:geoip
|
||||||
cancel:cancel
|
cancel:cancel
|
||||||
tls:tls
|
|
||||||
proxyproto:proxyproto
|
proxyproto:proxyproto
|
||||||
quic:quic
|
quic:quic
|
||||||
grpc_server:grpc_server
|
grpc_server:grpc_server
|
||||||
@@ -57,6 +56,8 @@ acl:acl
|
|||||||
cache:cache
|
cache:cache
|
||||||
header:header
|
header:header
|
||||||
dnssec:dnssec
|
dnssec:dnssec
|
||||||
|
# Keep tls here so dnssec can sign ACME challenge records before authoritative backends run.
|
||||||
|
tls:tls
|
||||||
minimal:minimal
|
minimal:minimal
|
||||||
template:template
|
template:template
|
||||||
transfer:transfer
|
transfer:transfer
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package chaos
|
package chaos
|
||||||
|
|
||||||
// Owners are all GitHub handlers of all maintainers.
|
// 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"}
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Examples
|
||||||
|
|
||||||
Start a DNS-over-TLS server that picks up incoming DNS-over-TLS queries on port 5553 and uses the
|
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
|
Only Knot DNS' `kdig` supports DNS-over-TLS queries, no command line client supports gRPC making
|
||||||
debugging these transports harder than it should be.
|
debugging these transports harder than it should be.
|
||||||
|
|
||||||
|
|||||||
582
plugin/tls/acme.go
Normal file
582
plugin/tls/acme.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
328
plugin/tls/acme_test.go
Normal file
328
plugin/tls/acme_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package tls
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
ctls "crypto/tls"
|
ctls "crypto/tls"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ import (
|
|||||||
|
|
||||||
var log = clog.NewWithPlugin("tls")
|
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 {
|
func setup(c *caddy.Controller) error {
|
||||||
err := parseTLS(c)
|
err := parseTLS(c)
|
||||||
@@ -33,76 +37,169 @@ func parseTLS(c *caddy.Controller) error {
|
|||||||
|
|
||||||
for c.Next() {
|
for c.Next() {
|
||||||
args := c.RemainingArgs()
|
args := c.RemainingArgs()
|
||||||
if len(args) < 2 || len(args) > 3 {
|
if len(args) == 0 {
|
||||||
return plugin.Error("tls", c.ArgErr())
|
tlsConfig, err := parseACMETLS(c, config)
|
||||||
}
|
if err != nil {
|
||||||
clientAuth := ctls.NoClientCert
|
return err
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
config.TLSConfig = tlsConfig
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
for i := range args {
|
if err := parseManualTLS(c, config, args); err != nil {
|
||||||
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
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
161
test/acme_test.go
Normal file
161
test/acme_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user