tls.acme names the certificate and jarl: the Jarl server and a token that may only write the challenge record. Steward gets the certificate with DNS-01 (lego), writing the record through Jarl's API into every view that has the zone, renews it with a third of its lifetime left and swaps it in without a restart. A check runs before every order: Jarl, the zone, the token's permission, a test record, the zone's name servers, public resolvers, clean-up; each step says what to fix. Staging by default. With a certificate, each listener serves HTTPS and plain HTTP on one port (redirecting plain HTTP, except from the server itself). steward cert / cert check / cert renew, Server -> Certificate in the web UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md and docs/jarl.md (the Jarl user and the token's grant). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
11 KiB
Go
380 lines
11 KiB
Go
package certs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/jarl"
|
|
)
|
|
|
|
// Step and Check are api.CertStep and api.CertCheck.
|
|
type (
|
|
Step = api.CertStep
|
|
Check = api.CertCheck
|
|
)
|
|
|
|
// note records a query and its answer, replacing an earlier answer to
|
|
// the same question (the checks ask again until the record shows up).
|
|
func note(s *Step, q string) {
|
|
cmd, _, _ := strings.Cut(q, " → ")
|
|
for i, old := range s.Queries {
|
|
if prev, _, _ := strings.Cut(old, " → "); prev == cmd {
|
|
s.Queries[i] = q
|
|
return
|
|
}
|
|
}
|
|
s.Queries = append(s.Queries, q)
|
|
}
|
|
|
|
func add(c *Check, name, status, format string, args ...any) *Step {
|
|
c.Steps = append(c.Steps, Step{Name: name, Status: status, Detail: fmt.Sprintf(format, args...)})
|
|
return &c.Steps[len(c.Steps)-1]
|
|
}
|
|
|
|
// How long the check waits for the record to show up, and the name
|
|
// servers' port (tests change them).
|
|
var (
|
|
propagationWait = 90 * time.Second
|
|
pollEvery = 2 * time.Second
|
|
nsPort = "53"
|
|
)
|
|
|
|
// Preflight checks, without the CA, that a certificate can be obtained:
|
|
// Jarl answers and knows the token, a zone holds the challenge name, the
|
|
// token may write it, a test record can be written, the zone's name
|
|
// servers and public resolvers serve it, and it can be removed again.
|
|
func (m *Manager) Preflight(ctx context.Context) *Check {
|
|
c := &Check{Time: time.Now().UTC(), Domain: m.cfg.ACME.Domain}
|
|
defer func() {
|
|
c.OK = true
|
|
for _, s := range c.Steps {
|
|
if s.Status == "fail" {
|
|
c.OK = false
|
|
}
|
|
}
|
|
m.mu.Lock()
|
|
m.lastCheck = c
|
|
m.mu.Unlock()
|
|
}()
|
|
name := jarl.ChallengeName(m.cfg.ACME.Domain)
|
|
// A CNAME at the challenge name sends the challenge elsewhere (into a
|
|
// zone kept for challenges); the CA and lego follow it, so the check
|
|
// does too.
|
|
if target := m.cname(ctx, name); target != "" {
|
|
name = target
|
|
}
|
|
shown := strings.TrimSuffix(name, ".")
|
|
|
|
who, err := m.jarl.Whoami(ctx)
|
|
if err != nil {
|
|
add(c, "Jarl", "fail", "%s", explainJarl(err))
|
|
return c
|
|
}
|
|
add(c, "Jarl", "ok", "the token is %s's, on Jarl %s", who.User.Username, who.Build.Label)
|
|
|
|
all, err := m.jarl.Zones(ctx)
|
|
if err != nil {
|
|
add(c, "Zone", "fail", "listing the zones: %s", explainJarl(err))
|
|
return c
|
|
}
|
|
zones := jarl.ZonesFor(name, all, "")
|
|
if len(zones) == 0 {
|
|
add(c, "Zone", "fail", "no primary zone the token can see holds %s: add the zone to Jarl, or give the token's user access to it (zones=...)", shown)
|
|
return c
|
|
}
|
|
zone := strings.TrimSuffix(zones[0].Name, ".")
|
|
// Every view with the zone gets the record (linked zones through
|
|
// their link), so name them all.
|
|
var inViews []jarl.Zone
|
|
for _, z := range all {
|
|
if strings.EqualFold(z.Name, zones[0].Name) {
|
|
inViews = append(inViews, z)
|
|
}
|
|
}
|
|
add(c, "Zone", "ok", "%s%s", zone, viewsText(inViews))
|
|
|
|
value := "steward-check-" + auth.RandomString(8)
|
|
if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Steward's certificate check"); err != nil {
|
|
add(c, "Permission", "fail", "%s", explainWrite(err, zone, shown))
|
|
return c
|
|
}
|
|
add(c, "Permission", "ok", "the token may write TXT records at %s", shown)
|
|
|
|
if err := m.jarl.SetTXT(ctx, name, value, zones, true, false, "Steward's certificate check"); err != nil {
|
|
add(c, "Write", "fail", "writing a test record: %s", explainWrite(err, zone, shown))
|
|
return c
|
|
}
|
|
add(c, "Write", "ok", "wrote TXT %q at %s; Jarl has it in named", value, shown)
|
|
defer func() {
|
|
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
|
defer cancel()
|
|
if err := m.jarl.SetTXT(clean, name, value, zones, false, false, "Steward's certificate check"); err != nil {
|
|
add(c, "Clean up", "fail", "removing the test record: %s (remove it in Jarl by hand)", explainJarl(err))
|
|
return
|
|
}
|
|
add(c, "Clean up", "ok", "removed the test record")
|
|
}()
|
|
|
|
authOK := true
|
|
if m.cfg.ACME.CheckPropagation {
|
|
authOK = m.checkNameServers(ctx, c, zone, name, value)
|
|
} else {
|
|
add(c, "Name servers", "skip", "tls.acme.check_propagation is off")
|
|
}
|
|
m.checkResolvers(ctx, c, name, value, authOK && m.cfg.ACME.CheckPropagation)
|
|
return c
|
|
}
|
|
|
|
func viewsText(zs []jarl.Zone) string {
|
|
var views []string
|
|
for _, z := range zs {
|
|
if z.View != "" {
|
|
views = append(views, z.View)
|
|
}
|
|
}
|
|
switch len(views) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return " in view " + views[0]
|
|
}
|
|
return " in views " + strings.Join(views, ", ")
|
|
}
|
|
|
|
func explainJarl(err error) string {
|
|
var je *jarl.Error
|
|
if errors.As(err, &je) && je.Status == 401 {
|
|
return "Jarl refused the token (" + je.Message + "): check jarl.token or jarl.token_file"
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func explainWrite(err error, zone, name string) string {
|
|
var je *jarl.Error
|
|
if errors.As(err, &je) && je.Status == 403 {
|
|
return fmt.Sprintf("the token may not write the challenge record (%s); give it the grant: records:write zones=%s names=%s types=TXT", je.Message, zone, name)
|
|
}
|
|
return explainJarl(err)
|
|
}
|
|
|
|
// checkNameServers asks each of the zone's public name servers directly
|
|
// (as the CA's resolvers will) until all serve the value.
|
|
func (m *Manager) checkNameServers(ctx context.Context, c *Check, zone, name, value string) bool {
|
|
st := add(c, "Name servers", "fail", "")
|
|
ns, q, err := m.lookupNS(ctx, zone)
|
|
st.Queries = append(st.Queries, q...)
|
|
if err != nil || len(ns) == 0 {
|
|
st.Detail = fmt.Sprintf("the public resolvers know no name servers for %s, so the CA can't see it: is the zone delegated to your name servers? (%v)", zone, err)
|
|
return false
|
|
}
|
|
type server struct {
|
|
host string
|
|
addrs []string
|
|
}
|
|
var servers []server
|
|
for _, h := range ns {
|
|
addrs, q := m.lookupAddrs(ctx, h)
|
|
st.Queries = append(st.Queries, q...)
|
|
if len(addrs) == 0 {
|
|
st.Detail = fmt.Sprintf("name server %s has no address the public resolvers know", strings.TrimSuffix(h, "."))
|
|
return false
|
|
}
|
|
servers = append(servers, server{h, addrs})
|
|
}
|
|
// A server passes when any of its addresses serves the value (this
|
|
// machine may not reach its IPv6 address, for one).
|
|
deadline := time.Now().Add(propagationWait)
|
|
missing := servers
|
|
for {
|
|
var still []server
|
|
for _, s := range missing {
|
|
found := false
|
|
for _, a := range s.addrs {
|
|
ok, q := m.hasTXT(ctx, net.JoinHostPort(a, nsPort), name, value, false)
|
|
note(st, q)
|
|
if ok {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
still = append(still, s)
|
|
}
|
|
}
|
|
missing = still
|
|
if len(missing) == 0 {
|
|
st.Status, st.Detail = "ok", fmt.Sprintf("all %d name servers of %s serve it", len(servers), zone)
|
|
return true
|
|
}
|
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
|
var names []string
|
|
for _, s := range missing {
|
|
names = append(names, strings.TrimSuffix(s.host, ".")+" ("+strings.Join(s.addrs, ", ")+")")
|
|
}
|
|
st.Detail = fmt.Sprintf("after %s, %s still don't serve it: a secondary that doesn't get the zone's updates (check NOTIFY and transfers), or an address that doesn't reach your name server", propagationWait, strings.Join(names, ", "))
|
|
return false
|
|
}
|
|
sleep(ctx, pollEvery)
|
|
}
|
|
}
|
|
|
|
// checkResolvers asks the public resolvers until each returns the value.
|
|
// When the name servers already serve it, a resolver that doesn't is only
|
|
// a warning: it may be holding an earlier answer.
|
|
func (m *Manager) checkResolvers(ctx context.Context, c *Check, name, value string, soft bool) {
|
|
st := add(c, "Public resolvers", "fail", "")
|
|
if len(m.cfg.ACME.Resolvers) == 0 {
|
|
st.Status, st.Detail = "skip", "no tls.acme.resolvers"
|
|
return
|
|
}
|
|
deadline := time.Now().Add(propagationWait)
|
|
missing := append([]string(nil), m.cfg.ACME.Resolvers...)
|
|
for {
|
|
var still []string
|
|
for _, r := range missing {
|
|
ok, q := m.hasTXT(ctx, r, name, value, true)
|
|
note(st, q)
|
|
if !ok {
|
|
still = append(still, r)
|
|
}
|
|
}
|
|
missing = still
|
|
if len(missing) == 0 {
|
|
st.Status, st.Detail = "ok", "every resolver sees it"
|
|
return
|
|
}
|
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
|
st.Detail = fmt.Sprintf("after %s, %s don't see it", propagationWait, strings.Join(missing, ", "))
|
|
if soft {
|
|
st.Status = "warn"
|
|
st.Detail += "; the name servers do, so they are probably holding an earlier answer"
|
|
}
|
|
return
|
|
}
|
|
sleep(ctx, pollEvery)
|
|
}
|
|
}
|
|
|
|
func sleep(ctx context.Context, d time.Duration) {
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-time.After(d):
|
|
}
|
|
}
|
|
|
|
// exchange asks server (HOST:PORT) one question.
|
|
func exchange(ctx context.Context, server, name string, qtype uint16, recurse bool) (*dns.Msg, string, error) {
|
|
if _, _, err := net.SplitHostPort(server); err != nil {
|
|
server = net.JoinHostPort(server, "53")
|
|
}
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(name), qtype)
|
|
m.RecursionDesired = recurse
|
|
host, port, _ := net.SplitHostPort(server)
|
|
q := fmt.Sprintf("dig @%s -p %s %s %s", host, port, dns.TypeToString[qtype], strings.TrimSuffix(name, "."))
|
|
if !recurse {
|
|
q += " +norec"
|
|
}
|
|
c, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
cl := &dns.Client{Timeout: 5 * time.Second}
|
|
r, _, err := cl.ExchangeContext(c, m, server)
|
|
if err == nil && r.Truncated {
|
|
cl.Net = "tcp"
|
|
r, _, err = cl.ExchangeContext(c, m, server)
|
|
}
|
|
return r, q, err
|
|
}
|
|
|
|
func (m *Manager) hasTXT(ctx context.Context, server, name, value string, recurse bool) (bool, string) {
|
|
r, q, err := exchange(ctx, server, name, dns.TypeTXT, recurse)
|
|
if err != nil {
|
|
return false, q + " → " + err.Error()
|
|
}
|
|
for _, rr := range r.Answer {
|
|
if t, ok := rr.(*dns.TXT); ok && strings.Join(t.Txt, "") == value {
|
|
return true, q + " → found"
|
|
}
|
|
}
|
|
return false, q + " → " + dns.RcodeToString[r.Rcode] + ", not there (yet)"
|
|
}
|
|
|
|
// cname returns the target of a CNAME at name ("" when there is none).
|
|
func (m *Manager) cname(ctx context.Context, name string) string {
|
|
for _, res := range m.cfg.ACME.Resolvers {
|
|
r, _, err := exchange(ctx, res, name, dns.TypeCNAME, true)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, rr := range r.Answer {
|
|
if c, ok := rr.(*dns.CNAME); ok {
|
|
return strings.ToLower(c.Target)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// lookupNS asks the resolvers for a zone's name servers.
|
|
func (m *Manager) lookupNS(ctx context.Context, zone string) ([]string, []string, error) {
|
|
var queries []string
|
|
var last error = errors.New("no resolvers")
|
|
for _, res := range m.cfg.ACME.Resolvers {
|
|
r, q, err := exchange(ctx, res, zone, dns.TypeNS, true)
|
|
queries = append(queries, q)
|
|
if err != nil {
|
|
last = err
|
|
continue
|
|
}
|
|
var out []string
|
|
for _, rr := range r.Answer {
|
|
if ns, ok := rr.(*dns.NS); ok {
|
|
out = append(out, strings.ToLower(ns.Ns))
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
sort.Strings(out)
|
|
return out, queries, nil
|
|
}
|
|
last = fmt.Errorf("%s answered %s", res, dns.RcodeToString[r.Rcode])
|
|
}
|
|
return nil, queries, last
|
|
}
|
|
|
|
// lookupAddrs asks the resolvers for a host's addresses.
|
|
func (m *Manager) lookupAddrs(ctx context.Context, host string) ([]string, []string) {
|
|
var queries []string
|
|
for _, res := range m.cfg.ACME.Resolvers {
|
|
var out []string
|
|
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
|
|
r, q, err := exchange(ctx, res, host, t, true)
|
|
queries = append(queries, q)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, rr := range r.Answer {
|
|
switch a := rr.(type) {
|
|
case *dns.A:
|
|
out = append(out, a.A.String())
|
|
case *dns.AAAA:
|
|
out = append(out, a.AAAA.String())
|
|
}
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out, queries
|
|
}
|
|
}
|
|
return nil, queries
|
|
}
|