package certs import ( "context" "errors" "fmt" "net" "sort" "strings" "time" "github.com/miekg/dns" "housecarl/internal/api" "housecarl/internal/auth" "housecarl/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 := "housecarl-check-" + auth.RandomString(8) if err := m.jarl.SetTXT(ctx, name, value, zones, true, true, "Housecarl'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, "Housecarl'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, "Housecarl'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 }