Encrypted DNS health, comparing a zone's views, --local everywhere

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 20:46:56 +01:00
1 parent 67ae0091b3
commit 13ffd8cad4
31 files changed
+1153 -18

No files matched your search

+15
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
@@ -285,6 +286,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp.TOTP = s.totpOverview(r.Context())
resp.Certificates = s.certSummary(r.Context())
}
if p.Can(auth.Action{Resource: auth.ResOptions}) {
if h := s.encryptedDNSHealth(); h != nil {
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
for _, pr := range h.Probes {
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
if pr.Problem != "" {
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
} else if pr.Warning != "" {
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
}
}
resp.EncryptedDNS = sum
}
}
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
resp.BlocklistErrors = s.blocklistErrors(r.Context())
if st, err := s.store.LoadState(r.Context()); err == nil {
+89
View File
@@ -0,0 +1,89 @@
package server
import (
"net/http"
"strings"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
)
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
// views (default: every view with a primary zone of that name), ?all=1
// includes the names and types that are the same everywhere.
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
z, err := s.zoneByPath(r, st, p)
if err != nil {
// A name in several views is what there is to compare.
ref := r.PathValue("zone")
if ref == "root" {
ref = "."
}
name, perr := engine.ParseZoneName(ref)
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
return err
} else {
z = zs[0]
}
}
var want []string
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
if v = strings.TrimSpace(v); v != "" {
want = append(want, v)
}
}
var zones []*model.Zone
for _, o := range st.ZonesNamed(z.Name) {
vn := st.ViewName(o)
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
continue
}
if len(want) > 0 && !containsFold(want, vn) {
continue
}
zones = append(zones, o)
}
st.SortByView(zones)
if len(zones) < 2 {
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
}
rows := engine.CompareZones(st, zones)
all := queryBool(r, "all")
out := struct {
Zone string `json:"zone"`
Views []string `json:"views"`
Linked bool `json:"linked"` // all of them in one link
Same int `json:"same"` // names and types the same everywhere
Rows []engine.CompareRow `json:"rows"`
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
for _, o := range zones {
out.Views = append(out.Views, st.ViewName(o))
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
}
for _, row := range rows {
if row.Same {
out.Same++
if !all {
continue
}
}
out.Rows = append(out.Rows, row)
}
writeJSON(w, http.StatusOK, out)
return nil
}
func containsFold(list []string, s string) bool {
for _, x := range list {
if strings.EqualFold(x, s) {
return true
}
}
return false
}
+84
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"net/http"
"net/netip"
"slices"
@@ -43,6 +44,8 @@ type encStatus struct {
// DoTHost and DoHURL are what to give clients.
DoTHost string `json:"dot_host,omitempty"`
DoHURL string `json:"doh_url,omitempty"`
// Health is the last check of the listeners (every 10 minutes).
Health *encdns.Health `json:"health,omitempty"`
// For the setup form.
Addresses []encAddress `json:"addresses"`
Views []encView `json:"views"`
@@ -104,6 +107,9 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
out.DoHURL += "/dns-query"
}
}
if out.Enabled {
out.Health = s.encryptedDNSHealth()
}
seen := map[netip.Addr]bool{}
for _, a := range localAddrs() {
a = a.Unmap()
@@ -234,3 +240,81 @@ func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *
}
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
}
// encTarget returns the host name clients connect to and the serial of
// the certificate Jarl last wrote for named.
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
if s.cfg.Certs == nil || st.Certificate == "" {
return "", ""
}
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
if err != nil {
return "", ""
}
for _, n := range append(c.Names, c.Domains...) {
if !strings.HasPrefix(n, "*.") {
host = n
break
}
}
return host, c.Serial
}
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
// keeps the result; a change for the worse is logged.
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
if s.cfg.Certs == nil {
return nil
}
state, err := s.store.LoadState(ctx)
if err != nil {
return nil
}
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
var h *encdns.Health
if st.Enabled {
if host, serial := s.encTarget(ctx, st); host != "" {
h = encdns.Check(ctx, st, host, serial, s.encRoots)
}
}
s.encHealth.Lock()
prev := s.encHealth.h
s.encHealth.h = h
s.encHealth.Unlock()
if h != nil {
for _, p := range h.Probes {
if p.OK {
continue
}
was := false
if prev != nil {
for _, q := range prev.Probes {
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
}
}
if !was {
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
}
}
}
return h
}
func (s *Server) encryptedDNSHealth() *encdns.Health {
s.encHealth.Lock()
defer s.encHealth.Unlock()
return s.encHealth.h
}
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
h := s.checkEncryptedDNS(r.Context())
if h == nil {
return conflict("encrypted DNS isn't set up")
}
writeJSON(w, http.StatusOK, h)
return nil
}
+7
View File
@@ -289,6 +289,13 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
}
}
if h := s.encryptedDNSHealth(); h != nil {
for _, pr := range h.Probes {
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
}
}
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
for _, t := range tokens {
if t.ExpiresAt != nil && t.RevokedAt == nil {
+7
View File
@@ -22,6 +22,13 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
}
out.Registrar = s.registrarFor(z)
if z.Type == model.ZonePrimary && z.ViewID != 0 {
for _, o := range st.ZonesNamed(z.Name) {
if o.ID != z.ID && o.Type == model.ZonePrimary {
out.OtherViews = append(out.OtherViews, st.ViewName(o))
}
}
}
for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
+2
View File
@@ -111,6 +111,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
@@ -176,6 +177,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
m.HandleFunc("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
+18
View File
@@ -4,6 +4,7 @@ package server
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/fs"
@@ -21,6 +22,7 @@ import (
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
@@ -90,6 +92,13 @@ type Server struct {
// state part of /metrics.
started time.Time
metricsCache metricsCache
// encHealth is the last check of encrypted DNS's listeners
// (h_encdns.go); encRoots the CAs it trusts (nil: the system's).
encHealth struct {
sync.Mutex
h *encdns.Health
}
encRoots *x509.CertPool
mu sync.Mutex
srvs []*http.Server
@@ -226,6 +235,14 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
ready()
}
go s.housekeeping(ctx)
go func() {
// Once jarld and named have settled after a start.
select {
case <-ctx.Done():
case <-time.After(30 * time.Second):
s.checkEncryptedDNS(ctx)
}
}()
select {
case <-ctx.Done():
case err := <-errc:
@@ -300,6 +317,7 @@ func (s *Server) housekeeping(ctx context.Context) {
}
s.limiter.cleanup()
s.warnExpiringTokens(ctx, time.Now())
s.checkEncryptedDNS(ctx)
if s.certs != nil {
s.certs.reloadIfChanged()
}