jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.1 KiB
Go
209 lines
7.1 KiB
Go
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()
|
|
}
|