Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
3.6 KiB
Go
129 lines
3.6 KiB
Go
package certs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// resolver asks recursive resolvers (acme.resolvers: public ones by
|
|
// default), to see a domain as the CA does rather than as this server's
|
|
// own views answer it.
|
|
type resolver struct {
|
|
servers []string
|
|
timeout time.Duration
|
|
}
|
|
|
|
func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
|
if len(r.servers) == 0 {
|
|
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
|
}
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(name), qtype)
|
|
m.SetEdns0(1232, false)
|
|
m.RecursionDesired = true
|
|
timeout := r.timeout
|
|
if timeout == 0 {
|
|
timeout = 5 * time.Second
|
|
}
|
|
var errs []error
|
|
for _, s := range r.servers {
|
|
c := &dns.Client{Timeout: timeout}
|
|
in, _, err := c.ExchangeContext(ctx, m, s)
|
|
if err == nil && in.Truncated {
|
|
c.Net = "tcp"
|
|
in, _, err = c.ExchangeContext(ctx, m, s)
|
|
}
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("%s: %w", s, err))
|
|
continue
|
|
}
|
|
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
|
|
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
|
|
continue
|
|
}
|
|
return in, nil
|
|
}
|
|
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
|
|
}
|
|
|
|
// zoneOf returns the zone that name is in on the internet (the owner of
|
|
// the SOA record that answers for it).
|
|
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
|
|
name = dns.Fqdn(strings.ToLower(name))
|
|
for cand := name; ; {
|
|
in, err := r.query(ctx, cand, dns.TypeSOA)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
|
|
for _, rr := range sec {
|
|
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
|
|
return strings.ToLower(soa.Hdr.Name), nil
|
|
}
|
|
}
|
|
}
|
|
i, end := dns.NextLabel(cand, 0)
|
|
if end {
|
|
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
|
|
}
|
|
cand = cand[i:]
|
|
}
|
|
}
|
|
|
|
// nameServers returns the zone's name servers on the internet (lowercase,
|
|
// without trailing dots, sorted).
|
|
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
|
|
in, err := r.query(ctx, zone, dns.TypeNS)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, rr := range in.Answer {
|
|
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
|
|
out = append(out, normName(ns.Ns))
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
|
|
}
|
|
slices.Sort(out)
|
|
return slices.Compact(out), nil
|
|
}
|
|
|
|
// follow returns where name leads after its CNAME records (name itself
|
|
// when it has none).
|
|
func (r resolver) follow(ctx context.Context, name string) (string, error) {
|
|
name = dns.Fqdn(strings.ToLower(name))
|
|
for range 10 {
|
|
in, err := r.query(ctx, name, dns.TypeCNAME)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
next := ""
|
|
for _, rr := range in.Answer {
|
|
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
|
|
next = strings.ToLower(c.Target)
|
|
}
|
|
}
|
|
if next == "" {
|
|
return name, nil
|
|
}
|
|
name = next
|
|
}
|
|
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
|
|
}
|
|
|
|
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
|
|
|
// challengeName is the DNS-01 record name for a certificate name (a
|
|
// wildcard shares its base name's).
|
|
func challengeName(domain string) string {
|
|
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
|
|
}
|