Mail reverse DNS check, what Jarl adds to named.conf, lists one per line

jarld checks the reverse DNS of the public zones' MX and SPF addresses as the internet sees it, every 6 hours (Exposure page, jarl exposure mail, jarl_mail_reverse_dns_ok). The Server options page, jarl option generated and GET /options/generated list what Jarl writes into named.conf by itself; stats.rate_limit_log turns off the query-errors logging. Option lists of three or more elements are shown one per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 21:02:11 +01:00
1 parent d33f573782
commit c27a6c10bd
30 files changed
+1095 -16

No files matched your search

+19
View File
@@ -659,3 +659,22 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principa
writeJSON(w, http.StatusOK, out)
return nil
}
// generatedOptions: GET /options/generated lists what Jarl adds to
// named.conf by itself (the statistics channel, its logging, blocking's
// response-policy, view keys), with why and what controls it.
func (s *Server) generatedOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
out := s.eng.GeneratedStatements(r.Context(), st)
if out == nil {
out = []render.Generated{}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+8 -1
View File
@@ -1,6 +1,7 @@
package server
import (
"jarl/internal/mailcheck"
"net/http"
"time"
@@ -32,6 +33,11 @@ type exposureReport struct {
// RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
// RateLimitLog is false when stats.rate_limit_log is off: what rate
// limiting does isn't counted then.
RateLimitLog bool `json:"rate_limit_log"`
// Mail is the last check of the mail addresses' reverse DNS.
Mail *mailcheck.Report `json:"mail,omitempty"`
}
type rateLimitActivity struct {
@@ -52,7 +58,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
return err
}
problems := s.eng.Validate(st)
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}, RateLimitLog: s.cfg.RateLimitLog}
finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc}
@@ -96,6 +102,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a
}
out.Mail = s.mailReport()
writeJSON(w, http.StatusOK, out)
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"context"
"net/http"
"time"
"jarl/internal/auth"
"jarl/internal/mailcheck"
)
// mailCheckEvery is how often jarld checks the reverse DNS of the
// addresses that send and receive mail (docs/exposure.md#mail).
const mailCheckEvery = 6 * time.Hour
// checkMail checks the reverse DNS of the public zones' mail addresses and
// keeps the report; it logs addresses that became wrong.
func (s *Server) checkMail(ctx context.Context) *mailcheck.Report {
st, err := s.store.LoadState(ctx)
if err != nil {
s.log.Warn("mail reverse DNS check: loading state", "err", err)
return nil
}
public := map[int64]bool{}
for _, x := range s.eng.Exposures(st) {
public[x.ViewID] = x.Public
}
if len(st.Views) == 0 {
public[0] = true
}
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
rep := mailcheck.Check(ctx, st, func(id int64) bool { return public[id] }, mailcheck.Resolver{Servers: s.cfg.Resolvers})
s.mail.Lock()
prev := s.mail.r
s.mail.r = rep
s.mail.Unlock()
was := map[string]bool{}
if prev != nil {
for _, r := range prev.Results {
was[r.Zone+"|"+r.Address] = r.OK
}
}
for _, r := range rep.Results {
if ok, seen := was[r.Zone+"|"+r.Address]; !r.OK && (ok || !seen) {
s.log.Warn("mail reverse DNS", "zone", r.Zone, "address", r.Address, "problem", r.Message)
}
}
return rep
}
func (s *Server) mailReport() *mailcheck.Report {
s.mail.Lock()
defer s.mail.Unlock()
return s.mail.r
}
// mailCheckNow: POST /exposure/mail checks now and returns the report.
func (s *Server) mailCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
rep := s.checkMail(r.Context())
if rep == nil {
rep = &mailcheck.Report{Checked: time.Now(), Results: []mailcheck.Result{}}
}
writeJSON(w, http.StatusOK, rep)
return nil
}
// mailLoop checks a minute after the start, then every mailCheckEvery.
func (s *Server) mailLoop(ctx context.Context) {
wait := time.Minute
for {
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.checkMail(ctx)
wait = mailCheckEvery
}
}
}
+7
View File
@@ -296,6 +296,13 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
}
}
if m := s.mailReport(); m != nil {
for _, r := range m.Results {
pw.gauge("jarl_mail_reverse_dns_ok", "1 when an address that sends or receives a public zone's mail has a PTR record that resolves back to it, at the last check (every 6 hours).", b2f(r.OK),
"zone", r.Zone, "address", r.Address)
}
}
if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
for _, t := range tokens {
if t.ExpiresAt != nil && t.RevokedAt == nil {
+1 -1
View File
@@ -118,7 +118,7 @@ func presentOption(st *model.State, o *model.Option) api.Option {
if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
out.Description, out.Status = c.Description, c.Status
}
if f := namedconf.FormatValue(o.Name, o.Value); f != o.Value {
if f := namedconf.FormatValueList(o.Name, o.Value, 3); f != o.Value {
out.Formatted = f
}
return out
+2
View File
@@ -129,6 +129,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
// Options and settings
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions))
m.HandleFunc("GET "+p+"/options/generated", s.authed(s.generatedOptions))
m.HandleFunc("POST "+p+"/options", s.authed(s.createOption))
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption))
@@ -180,6 +181,7 @@ func (s *Server) routes() {
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/mail", s.authed(s.mailCheckNow))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
+13
View File
@@ -24,6 +24,7 @@ import (
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/mailcheck"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
"jarl/internal/stats"
@@ -63,6 +64,12 @@ type Config struct {
TOTPIssuer string
// Metrics configures /metrics (metrics.go).
Metrics Metrics
// RateLimitLog says rate limiting's log lines reach the statistics
// (stats.rate_limit_log), for the Exposure page.
RateLimitLog bool
// Resolvers are public recursive resolvers (acme.resolvers), to see
// the mail addresses' reverse DNS as receivers do.
Resolvers []string
// Certs manages TLS certificates (nil in tests that don't need it),
// including the one jarld serves (h_certs.go, sniff.go).
Certs *certs.Manager
@@ -99,6 +106,11 @@ type Server struct {
h *encdns.Health
}
encRoots *x509.CertPool
// mail is the last check of the mail addresses' reverse DNS (h_mail.go).
mail struct {
sync.Mutex
r *mailcheck.Report
}
mu sync.Mutex
srvs []*http.Server
@@ -235,6 +247,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
ready()
}
go s.housekeeping(ctx)
go s.mailLoop(ctx)
go func() {
// Once jarld and named have settled after a start.
select {