package encdns import ( "bytes" "context" "crypto/tls" "crypto/x509" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "syscall" "time" "github.com/miekg/dns" ) // Probe is the outcome of checking one DoT or DoH listener the way a // client would: connect, check the certificate, ask a question. type Probe struct { Kind string `json:"kind"` // dot or doh Address string `json:"address"` Port int `json:"port"` // OK: it answered over TLS with a certificate for the host name that // is current. Problem says what is wrong when not; Warning notes // something that doesn't stop clients from working everywhere. OK bool `json:"ok"` Problem string `json:"problem,omitempty"` Warning string `json:"warning,omitempty"` // What the certificate named served says. CertNames []string `json:"cert_names,omitempty"` CertNotAfter *time.Time `json:"cert_not_after,omitempty"` CertSerial string `json:"cert_serial,omitempty"` // Trusted: it chains to a CA this server trusts (not so for a // staging or test CA). Trusted bool `json:"trusted"` RCode string `json:"rcode,omitempty"` Millis int64 `json:"ms"` } // Health is the last check of every listener. type Health struct { Checked time.Time `json:"checked"` Probes []Probe `json:"probes"` } // OK reports whether every listener works. func (h *Health) OK() bool { for _, p := range h.Probes { if !p.OK { return false } } return true } // Check probes every DoT and DoH listener of the setup. hostname is what // clients ask for, serial the certificate Jarl last wrote for named ("" if // unknown), roots the CAs to trust (nil: the system's). func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health { h := &Health{Checked: time.Now(), Probes: []Probe{}} for _, e := range s.DoT { h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots)) } for _, e := range s.DoH { h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots)) } return h } const probeTimeout = 5 * time.Second func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe { p := Probe{Kind: kind, Address: e.Address, Port: e.Port} addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port)) ctx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() start := time.Now() q := new(dns.Msg) q.SetQuestion(dns.Fqdn(hostname), dns.TypeA) q.RecursionDesired = true var state tls.ConnectionState var answer *dns.Msg var err error cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong if kind == "dot" { cfg.NextProtos = []string{"dot"} state, answer, err = askDoT(ctx, addr, cfg, q) } else { state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q) } p.Millis = time.Since(start).Milliseconds() if len(state.PeerCertificates) > 0 { leaf := state.PeerCertificates[0] p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber) na := leaf.NotAfter p.CertNotAfter = &na inter := x509.NewCertPool() for _, c := range state.PeerCertificates[1:] { inter.AddCert(c) } _, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname}) p.Trusted = verr == nil switch { case leaf.VerifyHostname(hostname) != nil: p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname) case time.Now().After(leaf.NotAfter): p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")" case serial != "" && p.CertSerial != serial: p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)" case !p.Trusted: p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA" } } switch { case err != nil: p.Problem = describe(err, e) case p.Problem != "": case answer == nil: p.Problem = "no DNS answer" default: p.RCode = dns.RcodeToString[answer.Rcode] p.OK = true if answer.Rcode == dns.RcodeRefused { p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname } } return p } func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) { d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg} conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return tls.ConnectionState{}, nil, err } defer conn.Close() tc := conn.(*tls.Conn) state := tc.ConnectionState() conn.SetDeadline(time.Now().Add(probeTimeout)) co := &dns.Conn{Conn: conn} if err := co.WriteMsg(q); err != nil { return state, nil, err } r, err := co.ReadMsg() return state, r, err } func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) { cfg = cfg.Clone() cfg.NextProtos = []string{"h2", "http/1.1"} tr := &http.Transport{ TLSClientConfig: cfg, ForceAttemptHTTP2: true, // Whatever the host name resolves to here: the listener's address. DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr) }, } defer tr.CloseIdleConnections() wire, err := q.Pack() if err != nil { return tls.ConnectionState{}, nil, err } url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query" req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire)) if err != nil { return tls.ConnectionState{}, nil, err } req.Header.Set("Content-Type", "application/dns-message") req.Header.Set("Accept", "application/dns-message") resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req) if err != nil { return tls.ConnectionState{}, nil, err } defer resp.Body.Close() var state tls.ConnectionState if resp.TLS != nil { state = *resp.TLS } body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) if err != nil { return state, nil, err } if resp.StatusCode != http.StatusOK { return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode) } r := new(dns.Msg) if err := r.Unpack(body); err != nil { return state, nil, fmt.Errorf("not a DNS answer: %w", err) } return state, r, nil } // describe says what a failed probe means. func describe(err error, e Endpoint) string { var ne net.Error switch { case errors.Is(err, syscall.ECONNREFUSED): return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port) case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded): return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout) } return err.Error() } // RequireNames marks the probes whose certificate doesn't cover every host // name clients may use (the first is what the probe asked for). func (h *Health) RequireNames(hosts []string) { for i := range h.Probes { p := &h.Probes[i] if p.Problem != "" || len(p.CertNames) == 0 { continue } var missing []string for _, host := range hosts { if !nameCovered(p.CertNames, host) { missing = append(missing, host) } } if len(missing) > 0 { p.OK = false p.Problem = fmt.Sprintf("named serves a certificate for %s, which doesn't cover %s: clients using that name reject it", strings.Join(p.CertNames, ", "), strings.Join(missing, ", ")) } } } func nameCovered(names []string, host string) bool { host = strings.ToLower(strings.TrimSuffix(host, ".")) for _, n := range names { n = strings.ToLower(strings.TrimSuffix(n, ".")) if n == host { return true } if rest, ok := strings.CutPrefix(n, "*."); ok { if _, parent, ok := strings.Cut(host, "."); ok && parent == rest { return true } } } return false }