mirror of
https://github.com/coredns/coredns.git
synced 2026-08-20 23:08:28 -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:
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user