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:
houyuwushang
2026-07-30 10:24:22 +08:00
committed by GitHub
parent 03260d5b67
commit 546fac83ea
13 changed files with 1369 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
package chaos
// Owners are all GitHub handlers of all maintainers.
var Owners = []string{"Tantalor93", "bradbeam", "chrisohaver", "darshanime", "dilyevsky", "ekleiner", "greenpau", "ihac", "inigohu", "isolus", "jameshartig", "johnbelamaric", "miekg", "mqasimsarfraz", "nchrisdk", "nitisht", "pmoroney", "rajansandeep", "rdrozhdzh", "rtreffer", "snebel29", "stp-ip", "superq", "thevilledev", "varyoo", "ykhr53", "yongtang", "zouyee"}
var Owners = []string{"Tantalor93", "bradbeam", "chrisohaver", "darshanime", "dilyevsky", "ekleiner", "greenpau", "houyuwushang", "ihac", "inigohu", "isolus", "jameshartig", "johnbelamaric", "miekg", "mqasimsarfraz", "nchrisdk", "nitisht", "pmoroney", "rajansandeep", "rdrozhdzh", "rtreffer", "snebel29", "stp-ip", "superq", "thevilledev", "varyoo", "ykhr53", "yongtang", "zouyee"}

View File

@@ -42,6 +42,40 @@ to decrypt TLS connections. It compromises security and should only be used for
CoreDNS sets the minimum TLS version to TLS 1.2. The maximum TLS version, TLS 1.2 cipher suites, and
key exchange mechanisms use the Go `crypto/tls` defaults.
Certificates can instead be obtained and renewed automatically with ACME:
~~~ txt
tls {
acme DOMAIN...
email EMAIL
ca URL
storage DIRECTORY
ca_root FILE
resolver ADDRESS
}
~~~
The `acme` property enables automatic certificate management for one or more domain names. CoreDNS
uses the DNS-01 challenge and answers the temporary `_acme-challenge` TXT queries on every DNS
listener in the same CoreDNS instance. The domains' authoritative DNS must therefore reach this
CoreDNS instance over port 53. HTTP-01 and TLS-ALPN-01 challenges are not used.
The remaining properties are optional:
* `email` sets the ACME account contact address.
* `ca` sets the ACME directory URL. It defaults to the Let's Encrypt production directory.
* `storage` sets the directory for ACME accounts, certificates, and private keys. It defaults to
`.coredns/acme` below the Corefile root.
* `ca_root` adds a PEM certificate bundle for connecting to a private ACME server.
* `resolver` sets the DNS resolver used to reach the ACME server and must use `HOST:PORT` syntax.
Certificate management starts in the background after all listeners are active. A new encrypted
listener can reject TLS handshakes until its first certificate has been obtained. Renewed certificates
are used without restarting CoreDNS.
The DNS-01 challenge state is local to one CoreDNS process. When authoritative DNS is served by
multiple replicas, validation queries must be routed to the replica performing the ACME operation.
## Examples
Start a DNS-over-TLS server that picks up incoming DNS-over-TLS queries on port 5553 and uses the
@@ -72,6 +106,23 @@ https://. {
}
~~~
Obtain and renew a certificate for a DoT server. The plain DNS server answers the DNS-01 challenge;
both server blocks must be in the same CoreDNS process.
~~~
.:53 {
file example.org
}
tls://.:853 {
tls {
acme dns.example.org
email hostmaster@example.org
}
forward . /etc/resolv.conf
}
~~~
Only Knot DNS' `kdig` supports DNS-over-TLS queries, no command line client supports gRPC making
debugging these transports harder than it should be.

582
plugin/tls/acme.go Normal file
View 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
View 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)
}
})
}
}

View File

@@ -2,6 +2,7 @@ package tls
import (
ctls "crypto/tls"
"fmt"
"os"
"path/filepath"
@@ -14,7 +15,10 @@ import (
var log = clog.NewWithPlugin("tls")
func init() { plugin.Register("tls", setup) }
func init() {
plugin.Register("tls", setup)
caddy.RegisterEventHook("tls-acme", acmeStartupHook)
}
func setup(c *caddy.Controller) error {
err := parseTLS(c)
@@ -33,76 +37,169 @@ func parseTLS(c *caddy.Controller) error {
for c.Next() {
args := c.RemainingArgs()
if len(args) < 2 || len(args) > 3 {
return plugin.Error("tls", c.ArgErr())
}
clientAuth := ctls.NoClientCert
var keyLog string
for c.NextBlock() {
switch c.Val() {
case "client_auth":
authTypeArgs := c.RemainingArgs()
if len(authTypeArgs) != 1 {
return c.ArgErr()
}
switch authTypeArgs[0] {
case "nocert":
clientAuth = ctls.NoClientCert
case "request":
clientAuth = ctls.RequestClientCert
case "require":
clientAuth = ctls.RequireAnyClientCert
case "verify_if_given":
clientAuth = ctls.VerifyClientCertIfGiven
case "require_and_verify":
clientAuth = ctls.RequireAndVerifyClientCert
default:
return c.Errf("unknown authentication type '%s'", authTypeArgs[0])
}
case "keylog":
args := c.RemainingArgs()
if len(args) != 1 {
return c.ArgErr()
}
keyLog = args[0]
if !filepath.IsAbs(keyLog) && config.Root != "" {
keyLog = filepath.Join(config.Root, keyLog)
}
default:
return c.Errf("unknown option '%s'", c.Val())
if len(args) == 0 {
tlsConfig, err := parseACMETLS(c, config)
if err != nil {
return err
}
config.TLSConfig = tlsConfig
continue
}
for i := range args {
if !filepath.IsAbs(args[i]) && config.Root != "" {
args[i] = filepath.Join(config.Root, args[i])
}
}
tls, err := tls.NewTLSConfigFromArgs(args...)
if err != nil {
if err := parseManualTLS(c, config, args); err != nil {
return err
}
tls.ClientAuth = clientAuth
// NewTLSConfigFromArgs only sets RootCAs, so we need to let ClientCAs refer to it.
tls.ClientCAs = tls.RootCAs
if len(keyLog) > 0 {
absKeyLog, err := filepath.Abs(keyLog)
if err != nil {
return c.Errf("unable to write TLS Key Log to %q: %s", keyLog, err)
}
f, err := os.OpenFile(absKeyLog, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
return c.Errf("unable to write TLS Key Log to %q: %s", absKeyLog, err)
}
c.OnShutdown(func() error {
f.Close()
return nil
})
tls.KeyLogWriter = f
log.Warningf("Writing TLS Key Log to %q\n", absKeyLog)
}
config.TLSConfig = tls
}
return nil
}
func parseManualTLS(c *caddy.Controller, config *dnsserver.Config, args []string) error {
if len(args) < 2 || len(args) > 3 {
return plugin.Error("tls", c.ArgErr())
}
clientAuth := ctls.NoClientCert
var keyLog string
for c.NextBlock() {
switch c.Val() {
case "client_auth":
authTypeArgs := c.RemainingArgs()
if len(authTypeArgs) != 1 {
return c.ArgErr()
}
switch authTypeArgs[0] {
case "nocert":
clientAuth = ctls.NoClientCert
case "request":
clientAuth = ctls.RequestClientCert
case "require":
clientAuth = ctls.RequireAnyClientCert
case "verify_if_given":
clientAuth = ctls.VerifyClientCertIfGiven
case "require_and_verify":
clientAuth = ctls.RequireAndVerifyClientCert
default:
return c.Errf("unknown authentication type '%s'", authTypeArgs[0])
}
case "keylog":
args := c.RemainingArgs()
if len(args) != 1 {
return c.ArgErr()
}
keyLog = args[0]
if !filepath.IsAbs(keyLog) && config.Root != "" {
keyLog = filepath.Join(config.Root, keyLog)
}
default:
return c.Errf("unknown option '%s'", c.Val())
}
}
for i := range args {
if !filepath.IsAbs(args[i]) && config.Root != "" {
args[i] = filepath.Join(config.Root, args[i])
}
}
tls, err := tls.NewTLSConfigFromArgs(args...)
if err != nil {
return err
}
tls.ClientAuth = clientAuth
// NewTLSConfigFromArgs only sets RootCAs, so we need to let ClientCAs refer to it.
tls.ClientCAs = tls.RootCAs
if len(keyLog) > 0 {
absKeyLog, err := filepath.Abs(keyLog)
if err != nil {
return c.Errf("unable to write TLS Key Log to %q: %s", keyLog, err)
}
f, err := os.OpenFile(absKeyLog, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0600)
if err != nil {
return c.Errf("unable to write TLS Key Log to %q: %s", absKeyLog, err)
}
c.OnShutdown(func() error {
f.Close()
return nil
})
tls.KeyLogWriter = f
log.Warningf("Writing TLS Key Log to %q\n", absKeyLog)
}
config.TLSConfig = tls
return nil
}
func parseACMETLS(c *caddy.Controller, config *dnsserver.Config) (*ctls.Config, error) {
options, err := defaultACMEOptions(config.Root)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
for c.NextBlock() {
name := c.Val()
args := c.RemainingArgs()
if seen[name] {
return nil, c.Errf("ACME option %q can only be specified once", name)
}
seen[name] = true
switch name {
case "acme":
if len(args) == 0 {
return nil, c.ArgErr()
}
options.domains = args
case "email":
if len(args) != 1 {
return nil, c.ArgErr()
}
options.email = args[0]
case "ca":
if len(args) != 1 {
return nil, c.ArgErr()
}
options.ca = args[0]
case "storage":
if len(args) != 1 {
return nil, c.ArgErr()
}
options.storage, err = resolveACMEPath(config.Root, args[0])
if err != nil {
return nil, err
}
case "ca_root":
if len(args) != 1 {
return nil, c.ArgErr()
}
options.caRoot, err = resolveACMEPath(config.Root, args[0])
if err != nil {
return nil, err
}
case "resolver":
if len(args) != 1 {
return nil, c.ArgErr()
}
options.resolver = args[0]
default:
return nil, c.Errf("unknown ACME option %q", name)
}
}
if err := validateACMEOptions(&options); err != nil {
return nil, err
}
runtime := getACMERuntime(c)
entry, err := runtime.add(options)
if err != nil {
return nil, err
}
runtime.installChallengeHandlers(c)
return entry.tlsConfig(), nil
}
func resolveACMEPath(root, path string) (string, error) {
if !filepath.IsAbs(path) && root != "" {
path = filepath.Join(root, path)
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolving ACME path %q: %w", path, err)
}
return abs, nil
}