Files
steward/internal/certs/check.go
T
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
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>
2026-10-14 15:46:36 +01:00

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
}