The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
8.1 KiB
Go
246 lines
8.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()
|
|
}
|
|
|
|
// 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
|
|
}
|