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:
1 parent
67ae0091b3
commit
13ffd8cad4
31 files changed
+1153
-18
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user