mirror of
https://github.com/coredns/coredns.git
synced 2026-08-20 23:08:28 -04:00
* 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>
329 lines
11 KiB
Go
329 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|