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>
583 lines
15 KiB
Go
583 lines
15 KiB
Go
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()
|
|
}
|