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

+21
View File
@@ -6,6 +6,27 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
### Added
- Mail reverse DNS check: the addresses of the public zones' MX hosts and
SPF records are looked up as the internet sees them, every 6 hours: a
PTR record whose name resolves back to the address, which Gmail and
Outlook require. It says who runs the reverse zone (your provider) and
what to set there, and notes when Jarl has its own reverse zone for the
address, which only your clients see. The Exposure page (Check now),
`jarl exposure mail` and Prometheus `jarl_mail_reverse_dns_ok`.
- Added by Jarl: the Server options page, `jarl option generated` and
`GET /options/generated` list what Jarl writes into named.conf by
itself (keys.conf, the statistics channel and logging, blocking's
response-policy, the view keys), why, and what controls each.
- `stats.rate_limit_log` (default true): `false` stops Jarl adding named's
`query-errors` category to its log channel when rate limiting is set.
### Changed
- Option values with a list of three or more elements (allow-query,
forwarders, listen-on, ...) are shown one element per line.
## [0.5.1] - 2026-10-12 ## [0.5.1] - 2026-10-12
### Added ### Added
+29 -1
View File
@@ -721,7 +721,35 @@ func optionCmd() *cobra.Command {
} }
scopeArgs(describe) scopeArgs(describe)
describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type") describe.Flags().StringVar(&zoneType, "zone-type", "primary", "with --zone: the zone type")
cmd.AddCommand(list, set, unset, describe) generated := &cobra.Command{
Use: "generated",
Short: "What Jarl adds to named.conf by itself, why, and what controls it",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var gen []struct {
Where string `json:"where"`
Text string `json:"text"`
Why string `json:"why"`
Setting string `json:"setting"`
}
if err := c.Get(ctx(), "/options/generated", nil, &gen); err != nil {
return err
}
return output(gen, func(w io.Writer) {
for i, g := range gen {
if i > 0 {
fmt.Fprintln(w)
}
fmt.Fprintf(w, "# %s: %s\n# controlled by: %s\n%s\n", g.Where, g.Why, g.Setting, g.Text)
}
})
},
}
cmd.AddCommand(list, set, unset, describe, generated)
return cmd return cmd
} }
+81 -1
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"strings" "strings"
@@ -42,6 +43,54 @@ type exposureReport struct {
Count int64 Count int64
} `json:"names"` } `json:"names"`
} `json:"rate_limiting"` } `json:"rate_limiting"`
Mail *mailReport `json:"mail"`
}
type mailReport struct {
Checked string `json:"checked"`
Results []struct {
Zone string `json:"zone"`
Address string `json:"address"`
Roles []string `json:"roles"`
PTR []string `json:"ptr"`
OK bool `json:"ok"`
Level string `json:"level"`
Message string `json:"message"`
ManagedBy string `json:"managed_by"`
LocalZone string `json:"local_zone"`
} `json:"results"`
Errors []string `json:"errors"`
}
func printMail(w io.Writer, m *mailReport) {
if len(m.Results) == 0 && len(m.Errors) == 0 {
fmt.Fprintln(w, "No mail addresses: no public zone has an MX record or SPF addresses.")
return
}
fmt.Fprintln(w, "ZONE\tADDRESS\tFOR\tPTR\tSTATUS")
for _, r := range m.Results {
ptr := strings.Join(r.PTR, ",")
if ptr == "" {
ptr = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Zone, r.Address, strings.Join(r.Roles, ", "), ptr, r.Level)
if r.Level != "ok" {
fmt.Fprintf(w, " %s\n", r.Message)
}
if r.LocalZone != "" {
fmt.Fprintf(w, " Jarl's reverse zone %s only answers inside: receivers ask %s\n", r.LocalZone, orOwner(r.ManagedBy))
}
}
for _, e := range m.Errors {
fmt.Fprintf(w, "error: %s\n", e)
}
}
func orOwner(s string) string {
if s == "" {
return "the address's owner"
}
return s
} }
// exposureScope turns "server" (or "") into the server, anything else // exposureScope turns "server" (or "") into the server, anything else
@@ -115,6 +164,10 @@ fix Jarl can stage for each.`,
} }
} }
} }
if r.Mail != nil {
fmt.Fprintln(w, "\nMAIL REVERSE DNS")
printMail(w, r.Mail)
}
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 { if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since) fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
for _, e := range a.Counts { for _, e := range a.Counts {
@@ -186,6 +239,33 @@ fix Jarl can stage for each.`,
return report(resp) return report(resp)
}, },
} }
cmd.AddCommand(fix, ack, unack) mail := &cobra.Command{
Use: "mail",
Short: "Check the reverse DNS of the addresses that send and receive your mail, now",
Long: `Finds the addresses of the public zones' MX hosts and SPF records and checks,
through public resolvers, that each has a PTR record whose name resolves
back to it, as Gmail and Outlook do. Exits 1 when one doesn't.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var m mailReport
if err := c.Do(ctx(), "POST", "/exposure/mail", nil, map[string]any{}, &m); err != nil {
return err
}
if err := output(m, func(w io.Writer) { printMail(w, &m) }); err != nil {
return err
}
for _, r := range m.Results {
if !r.OK {
return errors.New("some mail addresses have no working reverse DNS")
}
}
return nil
},
}
cmd.AddCommand(fix, ack, unack, mail)
return cmd return cmd
} }
+4 -1
View File
@@ -188,6 +188,7 @@ func renderExtras(cfg *config.Config) render.Extras {
h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)} h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)}
if cfg.Stats.QueryLog { if cfg.Stats.QueryLog {
h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize
h.NoRateLimitLog = !cfg.Stats.RateLimitLog
} }
x.Stats = h x.Stats = h
} }
@@ -333,7 +334,9 @@ func serveCmd() *cobra.Command {
TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(), TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(),
Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen, Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen,
Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token}, Allow: a.cfg.Metrics.AllowPrefixes(), Token: a.cfg.Metrics.Token},
Certs: cm, Certs: cm,
Resolvers: a.cfg.ACME.Resolvers,
RateLimitLog: a.cfg.Stats.Enabled && a.cfg.Stats.QueryLog && a.cfg.Stats.RateLimitLog,
HTTPReq: server.HTTPReq{ HTTPReq: server.HTTPReq{
Enabled: a.cfg.ACME.HTTPReq.Enabled, Enabled: a.cfg.ACME.HTTPReq.Enabled,
AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS,
+2 -1
View File
@@ -470,7 +470,8 @@ listing with `?changeset=` marks them `pending`.
| Method | Path | Body / query | | Method | Path | Body / query |
|--------|------|--------------| |--------|------|--------------|
| GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see) | | GET | `/options` | `?scope=options\|view\|zone\|toplevel&scope_id=ID` (none: everything you may see). `formatted` lays out a block over several lines, a list of three or more one element per line |
| GET | `/options/generated` | What Jarl adds to named.conf by itself: `where`, `text`, `why`, `setting` (what turns it on or off) |
| POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` | | POST | `/options` | `{"scope": "zone", "scope_id": 11, "name": "allow-transfer", "value": "{ key xfer; }", "comment": "", "position": 0}` |
| GET / PATCH / DELETE | `/options/{id}` | | | GET / PATCH / DELETE | `/options/{id}` | |
| GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` | | GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` |
+2
View File
@@ -405,6 +405,7 @@ jarl exposure # public views, findings,
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
jarl exposure fix server exposure_version jarl exposure fix server exposure_version
jarl exposure mail # reverse DNS of the addresses that send and receive mail
jarl exposure ack external security_open_resolver --reason "public resolver, monitored" jarl exposure ack external security_open_resolver --reason "public resolver, monitored"
jarl exposure unack external security_open_resolver jarl exposure unack external security_open_resolver
jarl view update external --exposure public|private|auto jarl view update external --exposure public|private|auto
@@ -476,6 +477,7 @@ jarl option set logging '{ channel q { file "/var/log/named/query.log" versions
jarl option unset forwarders jarl option unset forwarders
jarl option describe # every option allowed at this scope jarl option describe # every option allowed at this scope
jarl option describe allow-query-cache # grammar and ARM description jarl option describe allow-query-cache # grammar and ARM description
jarl option generated # what Jarl adds to named.conf by itself, and why
``` ```
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
+8
View File
@@ -48,6 +48,14 @@ overwritten. Jarl records a checksum of every file it writes and reports
**drift** when a file changes behind its back. It then refuses to overwrite **drift** when a file changes behind its back. It then refuses to overwrite
the file until you say so (`--force`, or `jarl resync --force`). the file until you say so (`--force`, or `jarl resync --force`).
Some of named.conf comes from Jarl itself rather than from your options:
the include of keys.conf, the statistics channel and the logging
statistics need, blocking's `response-policy` and policy zones, and the
view keys at the front of each view's `match-clients`. The Server options
page lists them under **Added by Jarl**, with why each is there and what
turns it on or off (`jarl option generated`). Where you have a statement
of your own (`logging`, `statistics-channels`), Jarl merges into it.
Comments are part of the data model: zones, records, ACLs, views, keys and Comments are part of the data model: zones, records, ACLs, views, keys and
options can all carry a comment, and Jarl writes it into the generated file. options can all carry a comment, and Jarl writes it into the generated file.
Comments in imported zone files are kept. Comments in imported zone files are kept.
+36 -2
View File
@@ -107,7 +107,10 @@ is only in its log (category `query-errors`). That is why the activity
needs query statistics; without them, look at named's log for needs query statistics; without them, look at named's log for
`would rate limit`. When rate limiting is configured and statistics are `would rate limit`. When rate limiting is configured and statistics are
on, Jarl adds `query-errors` to its log channel (keeping named's syslog on, Jarl adds `query-errors` to its log channel (keeping named's syslog
destination for it). destination for it). `stats.rate_limit_log: false` in jarl.yaml leaves
`query-errors` alone; the page then can't show what rate limiting does.
The Server options page lists this, and everything else Jarl adds to
named.conf by itself, under **Added by Jarl** (`jarl option generated`).
The statistics also count it: the `rrl` breakdown (`drop`, `slip`, The statistics also count it: the `rrl` breakdown (`drop`, `slip`,
`would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top `would-drop`, `would-slip`), the `ratelimited` and `ratelimited_name` top
@@ -160,6 +163,36 @@ external`). Set in the server options, it applies to every view that has
none of its own, your internal resolver included, which RRL isn't meant none of its own, your internal resolver included, which RRL isn't meant
for. for.
## Mail
Receivers such as Gmail and Outlook check the address mail comes from: it
needs a PTR record (reverse DNS), and the name it gives must resolve back
to the address. Without that, mail is rejected or marked as spam. The PTR
records of your public addresses live in the reverse zones of whoever
owns the addresses, your hosting provider or ISP: you set them in their
panel, not in Jarl. A reverse zone you create in Jarl only answers your
own clients (unless the owner delegated it to you).
So Jarl checks them as the internet sees them, through the public
resolvers of `acme.resolvers`: the addresses of every public zone's MX
hosts (receiving) and SPF `ip4:`, `ip6:`, `a` and `a:` addresses
(sending). The Exposure page's **Mail reverse DNS** panel shows each
address, its PTR, who runs its reverse zone (the provider, from the
zone's name server) and what to do; jarld checks a minute after it starts
and every 6 hours, and logs an address that goes wrong.
```
$ jarl exposure mail
ZONE ADDRESS FOR PTR STATUS
adbraun.lol 172.235.168.106 SPF ip4, MX mail.adbraun.lol mail.adbraun.lol ok
```
It warns when an address has no PTR record, or when its name doesn't
resolve back to it; notes when the name is an alias (CNAME), or isn't the
MX name (fine if the mail server uses it in HELO). `jarl exposure mail`
exits 1 when an address isn't right; Prometheus has
`jarl_mail_reverse_dns_ok{zone,address}`.
## Confirming a change that opens a view up ## Confirming a change that opens a view up
A change that *introduces* an open resolver, open zone transfers, cache A change that *introduces* an open resolver, open zone transfers, cache
@@ -204,7 +237,8 @@ monitoring, response size limits and usually a machine of its own.
| Method | Path | Body | | Method | Path | Body |
|--------|------|------| |--------|------|------|
| GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`) | | GET | `/exposure` | `scopes` (per view, or the server: `public`, `override`, `reason`, `rate_limit` off/log-only/enforcing, `findings` with `fix` where Jarl has one), `server` (server-wide findings), `rate_limiting` (last 24h, with statistics: `counts`, `networks`, `names`), `rate_limit_log`, `mail` (the last mail check) |
| POST | `/exposure/mail` | Checks the mail addresses' reverse DNS now: `checked`, `results` (`zone`, `address`, `roles`, `ptr`, `ok`, `level` ok/info/warning, `message`, `reverse_zone`, `managed_by`, `local_zone`), `errors` |
| POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change | | POST | `/exposure/fix` | `{"view": "external", "code": "exposure_rate_limit"}` (`view_id` works too; no view: the server). Staged with `?changeset=` like any change |
| POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` | | POST | `/exposure/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` |
| POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` | | POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` |
+5
View File
@@ -132,6 +132,7 @@ same values.
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). | | `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
| `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. | | `jarl_certificate_failing{certificate,source}` | 1 when obtaining (or reading) the certificate failed the last time, or it has expired. |
| `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. | | `jarl_certificate_renewal_failures{certificate}` | Failed attempts to obtain a certificate since its last success. |
| `jarl_mail_reverse_dns_ok{zone,address}` | 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 ([exposure.md](exposure.md#mail)). |
| `jarl_encrypted_dns_up{kind,address,port}` | 1 when the DoT or DoH listener answered with the right, current certificate at the last check ([encrypted-dns.md](encrypted-dns.md#health)). | | `jarl_encrypted_dns_up{kind,address,port}` | 1 when the DoT or DoH listener answered with the right, current certificate at the last check ([encrypted-dns.md](encrypted-dns.md#health)). |
### Queries (with `stats.enabled`) ### Queries (with `stats.enabled`)
@@ -238,6 +239,10 @@ groups:
for: 20m for: 20m
annotations: annotations:
summary: "DNS over {{ $labels.kind }} on {{ $labels.address }} port {{ $labels.port }} isn't working (see the Encrypted DNS page)" summary: "DNS over {{ $labels.kind }} on {{ $labels.address }} port {{ $labels.port }} isn't working (see the Encrypted DNS page)"
- alert: MailReverseDNS
expr: jarl_mail_reverse_dns_ok == 0
annotations:
summary: "{{ $labels.address }} ({{ $labels.zone }} mail) has no reverse DNS that resolves back: receivers may reject its mail"
- alert: BlockingPausedForLong - alert: BlockingPausedForLong
expr: jarl_blocking_paused == 1 expr: jarl_blocking_paused == 1
for: 24h for: 24h
+8
View File
@@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it
stats: stats:
enabled: false enabled: false
query_log: true # per-query data; false = named's counters only query_log: true # per-query data; false = named's counters only
rate_limit_log: true # with a rate-limit: also log what it limits (query-errors)
database: /var/lib/jarl/stats.db database: /var/lib/jarl/stats.db
statistics_channel: 127.0.0.1:8058 # must be a loopback address statistics_channel: 127.0.0.1:8058 # must be a loopback address
log_file: /var/log/named/jarl-responses.log log_file: /var/log/named/jarl-responses.log
@@ -81,6 +82,13 @@ the response log. Logging every answer costs named some CPU and disk I/O,
which is negligible at homelab volumes. At tens of thousands of queries per which is negligible at homelab volumes. At tens of thousands of queries per
second, consider counters only. second, consider counters only.
`rate_limit_log` matters only with a `rate-limit` set: Jarl then sends
named's `query-errors` category (where rate limiting logs what it limits,
or would in log-only mode) to the response log too, and keeps it in
syslog. That other query errors come along is the price; `false` stops it,
and the Exposure page can't show rate limiting's activity then
([exposure.md](exposure.md#response-rate-limiting)).
`exclude_clients` drops those clients from the log-derived figures (top `exclude_clients` drops those clients from the log-derived figures (top
lists, views, zones). named's own counters can't be filtered. lists, views, zones). named's own counters can't be filtered.
+9
View File
@@ -364,6 +364,10 @@ type Stats struct {
// per-zone counts) from named's response log. Without it only named's // per-zone counts) from named's response log. Without it only named's
// own counters are collected. // own counters are collected.
QueryLog bool `yaml:"query_log"` QueryLog bool `yaml:"query_log"`
// RateLimitLog sends rate limiting's log lines (named's query-errors
// category) to the response log too, so the Exposure page and the
// statistics can show what was, or would be, limited.
RateLimitLog bool `yaml:"rate_limit_log"`
// Database is the statistics database, kept apart from Jarl's // Database is the statistics database, kept apart from Jarl's
// configuration database. // configuration database.
Database string `yaml:"database"` Database string `yaml:"database"`
@@ -451,6 +455,7 @@ func Default() *Config {
}, },
Stats: Stats{ Stats: Stats{
QueryLog: true, QueryLog: true,
RateLimitLog: true,
Database: "/var/lib/jarl/stats.db", Database: "/var/lib/jarl/stats.db",
StatisticsChannel: "127.0.0.1:8058", StatisticsChannel: "127.0.0.1:8058",
LogFile: "/var/log/named/jarl-responses.log", LogFile: "/var/log/named/jarl-responses.log",
@@ -736,6 +741,10 @@ stats:
# Per-query data (top names and clients, per view and zone). false # Per-query data (top names and clients, per view and zone). false
# collects only named's own counters, with no logging overhead. # collects only named's own counters, with no logging overhead.
query_log: true query_log: true
# With a rate-limit set, also log what rate limiting limits (or would,
# in log-only mode): named's query-errors category, which Jarl then adds
# to its log channel (and keeps in syslog). The Exposure page shows it.
rate_limit_log: true
database: /var/lib/jarl/stats.db database: /var/lib/jarl/stats.db
statistics_channel: 127.0.0.1:8058 statistics_channel: 127.0.0.1:8058
log_file: /var/log/named/jarl-responses.log log_file: /var/log/named/jarl-responses.log
+6
View File
@@ -66,3 +66,9 @@ func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *n
} }
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)} return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
} }
// GeneratedStatements lists what Jarl adds to named.conf by itself
// (render.GeneratedStatements).
func (e *Engine) GeneratedStatements(ctx context.Context, st *model.State) []render.Generated {
return render.GeneratedStatements(st, e.cfg.Paths, e.extras(ctx))
}
+339
View File
@@ -0,0 +1,339 @@
// Package mailcheck checks the reverse DNS of the addresses that send and
// receive mail for your public zones (docs/exposure.md#mail): receivers
// such as Gmail and Outlook reject or mark mail from an address without a
// PTR record, or whose PTR name doesn't resolve back to it. Those records
// belong to whoever owns the address (your hosting provider or ISP), not
// to Jarl, so the check asks the internet and says who to ask.
package mailcheck
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"time"
"github.com/miekg/dns"
"golang.org/x/net/publicsuffix"
"jarl/internal/model"
)
// Target is an address mail comes from or goes to, and why it is checked.
type Target struct {
Zone string `json:"zone"`
Address string `json:"address"`
Roles []string `json:"roles"` // e.g. "MX mail.adbraun.lol", "SPF ip4"
Host string `json:"host,omitempty"` // the MX host name, when it is one
}
// Result is the check of one address.
type Result struct {
Target
PTR []string `json:"ptr"` // the names its PTR records give
// OK: a PTR name resolves back to the address (forward-confirmed).
OK bool `json:"ok"`
// Level is ok, info or warning; Message says what to do.
Level string `json:"level"`
Message string `json:"message"`
// ReverseZone and ManagedBy say where the PTR record lives.
ReverseZone string `json:"reverse_zone,omitempty"`
ManagedBy string `json:"managed_by,omitempty"`
// LocalZone is a reverse zone for the address in Jarl ("view/zone"),
// which the internet doesn't ask unless the address's owner delegated
// it to this server.
LocalZone string `json:"local_zone,omitempty"`
}
// Report is a whole check.
type Report struct {
Checked time.Time `json:"checked"`
Results []Result `json:"results"`
// Errors are lookups that failed (no internet, say).
Errors []string `json:"errors,omitempty"`
}
// Targets finds the addresses to check in the public zones: the MX hosts
// (receiving) and the SPF record's addresses (sending). Names are looked
// up later, through the resolver.
func Targets(st *model.State, public func(viewID int64) bool) (addrs []Target, names []Target) {
for _, z := range st.SortedZones() {
if z.Type != model.ZonePrimary || z.Disabled || !public(z.ViewID) {
continue
}
zone := strings.TrimSuffix(z.Name, ".")
for _, r := range st.ZoneRecords(z.ID) {
if r.Name != z.Name || r.Disabled {
continue
}
switch r.Type {
case "MX":
f := strings.Fields(r.Data)
if len(f) == 2 && f[1] != "." {
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(strings.ToLower(f[1]), "."), Roles: []string{"MX " + strings.TrimSuffix(f[1], ".")}})
}
case "TXT":
text := strings.ReplaceAll(strings.ReplaceAll(r.Data, `" "`, ""), `"`, "")
if !strings.HasPrefix(strings.ToLower(text), "v=spf1") {
continue
}
for _, mech := range strings.Fields(text)[1:] {
m := strings.ToLower(strings.TrimLeft(mech, "+?~"))
switch {
case strings.HasPrefix(m, "ip4:"), strings.HasPrefix(m, "ip6:"):
v := m[4:]
p, err := netip.ParsePrefix(v)
if err != nil {
if a, err := netip.ParseAddr(v); err == nil {
p = netip.PrefixFrom(a, a.BitLen())
} else {
continue
}
}
if p.IsSingleIP() {
addrs = append(addrs, Target{Zone: zone, Address: p.Addr().String(), Roles: []string{"SPF " + m[:3]}})
}
case m == "a":
names = append(names, Target{Zone: zone, Host: zone, Roles: []string{"SPF a"}})
case strings.HasPrefix(m, "a:"):
names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(m[2:], "."), Roles: []string{"SPF " + m}})
}
}
}
}
}
return addrs, names
}
// Resolver asks recursive resolvers (the public ones of acme.resolvers).
type Resolver struct {
Servers []string
Timeout time.Duration
}
func (r Resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
if len(r.Servers) == 0 {
return nil, errors.New("no resolvers configured (acme.resolvers)")
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), qtype)
m.RecursionDesired = true
m.SetEdns0(1232, false)
t := r.Timeout
if t == 0 {
t = 5 * time.Second
}
var last error
for _, s := range r.Servers {
in, _, err := (&dns.Client{Timeout: t}).ExchangeContext(ctx, m, s)
if err == nil && in.Truncated {
in, _, err = (&dns.Client{Timeout: t, Net: "tcp"}).ExchangeContext(ctx, m, s)
}
if err == nil && in.Rcode != dns.RcodeServerFailure && in.Rcode != dns.RcodeRefused {
return in, nil
}
if err != nil {
last = fmt.Errorf("%s: %w", s, err)
} else {
last = fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode])
}
}
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], last)
}
// addresses returns a name's A and AAAA addresses (after CNAMEs).
func (r Resolver) addresses(ctx context.Context, name string) ([]netip.Addr, bool, error) {
var out []netip.Addr
alias := false
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
in, err := r.query(ctx, name, t)
if err != nil {
return nil, false, err
}
for _, rr := range in.Answer {
switch v := rr.(type) {
case *dns.A:
a, _ := netip.AddrFromSlice(v.A.To4())
out = append(out, a)
case *dns.AAAA:
a, _ := netip.AddrFromSlice(v.AAAA)
out = append(out, a)
case *dns.CNAME:
alias = true
}
}
}
return out, alias, nil
}
// Check looks up every target's reverse DNS.
func Check(ctx context.Context, st *model.State, public func(int64) bool, res Resolver) *Report {
rep := &Report{Checked: time.Now(), Results: []Result{}}
addrs, names := Targets(st, public)
// Names to addresses, as the internet sees them.
for _, n := range names {
as, _, err := res.addresses(ctx, n.Host)
if err != nil {
rep.Errors = append(rep.Errors, err.Error())
continue
}
if len(as) == 0 {
rep.Errors = append(rep.Errors, fmt.Sprintf("%s (%s, %s) has no address as the internet sees it: not published yet, or a typo?", n.Host, n.Zone, strings.Join(n.Roles, ", ")))
}
for _, a := range as {
t := n
t.Address = a.String()
addrs = append(addrs, t)
}
}
// One result per address, with every reason it is checked.
merged := map[string]*Target{}
var order []string
for _, t := range addrs {
k := t.Zone + "|" + t.Address
if m := merged[k]; m != nil {
for _, role := range t.Roles {
if !slices.Contains(m.Roles, role) {
m.Roles = append(m.Roles, role)
}
}
if m.Host == "" {
m.Host = t.Host
}
continue
}
t := t
merged[k] = &t
order = append(order, k)
}
for _, k := range order {
r := checkOne(ctx, *merged[k], res)
r.LocalZone = localReverse(st, r.Address, r.ReverseZone)
rep.Results = append(rep.Results, r)
}
return rep
}
// localReverse returns a reverse zone in Jarl that covers the address,
// unless it is the zone the internet asks (delegated to this server).
func localReverse(st *model.State, addr, internetZone string) string {
rev, err := dns.ReverseAddr(addr)
if err != nil {
return ""
}
rev = strings.ToLower(rev)
best := ""
var bz *model.Zone
for _, z := range st.SortedZones() {
n := strings.ToLower(dns.Fqdn(z.Name))
if z.Type == model.ZonePrimary && dns.IsSubDomain(n, rev) && len(n) > len(best) {
best, bz = n, z
}
}
if bz == nil || strings.EqualFold(strings.TrimSuffix(best, "."), internetZone) {
return ""
}
if v := st.ViewName(bz); v != "" {
return v + "/" + strings.TrimSuffix(bz.Name, ".")
}
return strings.TrimSuffix(bz.Name, ".")
}
func checkOne(ctx context.Context, t Target, res Resolver) Result {
r := Result{Target: t, PTR: []string{}}
a, err := netip.ParseAddr(t.Address)
if err != nil {
r.Level, r.Message = "warning", err.Error()
return r
}
rev, _ := dns.ReverseAddr(a.String())
in, err := res.query(ctx, rev, dns.TypePTR)
if err != nil {
r.Level, r.Message = "warning", err.Error()
return r
}
// Who manages the reverse zone: the SOA in the answer.
for _, sec := range [][]dns.RR{in.Ns, in.Answer} {
for _, rr := range sec {
if soa, ok := rr.(*dns.SOA); ok {
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
}
}
}
if r.ReverseZone == "" {
if s, err := res.query(ctx, rev, dns.TypeSOA); err == nil {
for _, sec := range [][]dns.RR{s.Answer, s.Ns} {
for _, rr := range sec {
if soa, ok := rr.(*dns.SOA); ok {
r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns)
}
}
}
}
}
for _, rr := range in.Answer {
if p, ok := rr.(*dns.PTR); ok {
r.PTR = append(r.PTR, strings.TrimSuffix(strings.ToLower(p.Ptr), "."))
}
}
where := "whoever runs the address's reverse zone (your hosting provider or ISP)"
if r.ManagedBy != "" {
where = "the reverse zone's owner (" + r.ManagedBy + ": your hosting provider's or ISP's panel)"
}
if len(r.PTR) == 0 {
r.Level = "warning"
r.Message = fmt.Sprintf("%s has no PTR record: many receivers reject or mark mail from it. Set its reverse DNS to %s at %s", t.Address, suggestName(t), where)
return r
}
var notes []string
for _, name := range r.PTR {
as, alias, err := res.addresses(ctx, name)
if err != nil {
notes = append(notes, err.Error())
continue
}
if slices.Contains(as, a) {
r.OK = true
if alias {
notes = append(notes, name+" is an alias (CNAME): receivers expect the PTR to name a host with its own A/AAAA record")
}
if t.Host != "" && name != t.Host && strings.HasPrefix(strings.Join(t.Roles, " "), "MX") {
notes = append(notes, fmt.Sprintf("the PTR name is %s, not the MX name %s: fine if the mail server says %s in HELO", name, t.Host, name))
}
}
}
switch {
case !r.OK:
r.Level = "warning"
r.Message = fmt.Sprintf("the PTR record of %s names %s, which doesn't resolve back to %s: receivers check that it does. Point the PTR at a name with an A/AAAA record for %s (set at %s), or add that record", t.Address, strings.Join(r.PTR, ", "), t.Address, t.Address, where)
case len(notes) > 0:
r.Level, r.Message = "info", strings.Join(notes, "; ")
default:
r.Level, r.Message = "ok", fmt.Sprintf("%s → %s → %s", t.Address, strings.Join(r.PTR, ", "), t.Address)
}
return r
}
func suggestName(t Target) string {
if t.Host != "" {
return t.Host
}
return "the host's name (with an A/AAAA record for it)"
}
// managedBy turns the reverse zone's primary name server into its owner's
// domain: ns1.linode.com -> linode.com, dns0.zen.co.uk -> zen.co.uk.
func managedBy(ns string) string {
ns = strings.TrimSuffix(strings.ToLower(ns), ".")
labels := strings.Split(ns, ".")
// The registered domain under the ICANN suffix: the list's private
// part (members.linode.com) names customers, not the provider.
for i := 1; i < len(labels); i++ {
if suffix, icann := publicsuffix.PublicSuffix(strings.Join(labels[i:], ".")); icann && suffix == strings.Join(labels[i:], ".") {
return strings.Join(labels[i-1:], ".")
}
}
return ns
}
+108
View File
@@ -0,0 +1,108 @@
package mailcheck
import (
"context"
"net"
"strings"
"testing"
"github.com/miekg/dns"
"jarl/internal/model"
)
// fakeResolver answers from a fixed set of records, as a recursive
// resolver would, with the reverse zone's SOA in negative answers.
func fakeResolver(t *testing.T, records ...string) string {
t.Helper()
var rrs []dns.RR
for _, s := range records {
rr, err := dns.NewRR(s)
if err != nil {
t.Fatal(err)
}
rrs = append(rrs, rr)
}
soa, _ := dns.NewRR("2.0.192.in-addr.arpa. 3600 IN SOA ns1.linode.com. hostmaster.linode.com. 1 3600 900 1209600 300")
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
m := new(dns.Msg)
m.SetReply(q)
qn := strings.ToLower(q.Question[0].Name)
for _, rr := range rrs {
if strings.ToLower(rr.Header().Name) == qn && (rr.Header().Rrtype == q.Question[0].Qtype || rr.Header().Rrtype == dns.TypeCNAME) {
m.Answer = append(m.Answer, rr)
}
}
if len(m.Answer) == 0 && dns.IsSubDomain(soa.Header().Name, qn) {
if q.Question[0].Qtype == dns.TypeSOA && qn == soa.Header().Name {
m.Answer = append(m.Answer, soa)
} else {
m.Ns = append(m.Ns, soa)
}
}
w.WriteMsg(m)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
return pc.LocalAddr().String()
}
func TestCheck(t *testing.T) {
st := model.NewState()
ext := &model.View{ID: 1, Name: "external", MatchClients: []string{"any"}}
in := &model.View{ID: 2, Name: "internal", MatchClients: []string{"lan"}}
st.Views[1], st.Views[2] = ext, in
z := &model.Zone{ID: 10, Name: "example.com.", ViewID: 1, Type: model.ZonePrimary}
zi := &model.Zone{ID: 11, Name: "example.com.", ViewID: 2, Type: model.ZonePrimary}
rev := &model.Zone{ID: 12, Name: "27.2.0.192.in-addr.arpa.", ViewID: 2, Type: model.ZonePrimary}
st.Zones[10], st.Zones[11], st.Zones[12] = z, zi, rev
rec := func(id, zone int64, name, typ, data string) {
st.Records[id] = &model.Record{ID: id, ZoneID: zone, Name: name, Type: typ, Data: data}
}
rec(1, 10, "example.com.", "MX", "10 mail.example.com.")
rec(2, 10, "example.com.", "TXT", `"v=spf1 mx ip4:192.0.2.26 ip4:192.0.2.27" " ip4:198.51.100.0/24 -all"`)
rec(3, 10, "www.example.com.", "MX", "10 ignored.example.com.") // not the apex
rec(4, 11, "example.com.", "MX", "10 internal.example.com.") // private view
rec(5, 10, "example.com.", "TXT", `"google-site-verification=x"`)
rec(6, 10, "example.com.", "MX", "20 backup.example.com.") // not on the internet
res := Resolver{Servers: []string{fakeResolver(t,
"mail.example.com. 300 IN A 192.0.2.25",
"25.2.0.192.in-addr.arpa. 300 IN PTR mail.example.com.",
"27.2.0.192.in-addr.arpa. 300 IN PTR li-27.members.linode.com.",
"li-27.members.linode.com. 300 IN A 192.0.2.99",
)}}
public := func(id int64) bool { return id == 1 }
rep := Check(context.Background(), st, public, res)
if len(rep.Errors) != 1 || !strings.Contains(rep.Errors[0], "backup.example.com (example.com, MX backup.example.com) has no address") {
t.Fatalf("errors %v", rep.Errors)
}
got := map[string]Result{}
for _, r := range rep.Results {
got[r.Address] = r
}
if len(got) != 3 {
t.Fatalf("results %+v", rep.Results)
}
if r := got["192.0.2.25"]; !r.OK || r.Level != "ok" || r.Roles[0] != "MX mail.example.com" || r.ManagedBy != "" && r.ManagedBy != "linode.com" {
t.Errorf("mail: %+v", r)
}
if r := got["192.0.2.26"]; r.OK || r.Level != "warning" || !strings.Contains(r.Message, "no PTR record") || r.ManagedBy != "linode.com" || r.ReverseZone != "2.0.192.in-addr.arpa" {
t.Errorf("no PTR: %+v", r)
}
if r := got["192.0.2.27"]; r.OK || !strings.Contains(r.Message, "doesn't resolve back") || r.LocalZone != "internal/27.2.0.192.in-addr.arpa" {
t.Errorf("not forward-confirmed: %+v", r)
}
}
func TestManagedBy(t *testing.T) {
for in, want := range map[string]string{"ns1.linode.com.": "linode.com", "dns0.zen.co.uk.": "zen.co.uk", "ns1.members.linode.com.": "linode.com", "localhost.": "localhost"} {
if got := managedBy(in); got != want {
t.Errorf("managedBy(%q) = %q, want %q", in, got, want)
}
}
}
+35
View File
@@ -182,3 +182,38 @@ func writeItemsInline(sb *strings.Builder, items []Item) {
} }
} }
} }
// FormatValueList is FormatValue, except that a value whose block is a
// plain list (no nested blocks) of at least min elements is laid out one
// element per line, for reading: allow-query, exempt-clients, forwarders.
func FormatValueList(name, value string, min int) string {
st, err := ParseOptionValue(name, value)
if err != nil {
return FormatValue(name, value)
}
items := st.Items[1:]
blocks := 0
var list *Block
for _, it := range items {
if it.Block != nil {
blocks++
list = it.Block
}
}
if blocks != 1 || len(list.Stmts) < min || blockHasBlocks(list) {
return FormatValue(name, value)
}
var sb strings.Builder
for _, it := range items {
if it.Block == nil {
sb.WriteString(Compact([]Item{it}) + " ")
continue
}
sb.WriteString("{\n")
for _, s := range list.Stmts {
sb.WriteString("\t" + Compact(s.Items) + ";\n")
}
sb.WriteString("} ")
}
return strings.TrimSpace(sb.String())
}
+20
View File
@@ -16,3 +16,23 @@ func TestFormatValue(t *testing.T) {
} }
} }
} }
func TestFormatValueList(t *testing.T) {
cases := []struct{ name, value, want string }{
{"allow-query", "{ homelab; localhost; 10.0.0.0/8; }", "{\n\thomelab;\n\tlocalhost;\n\t10.0.0.0/8;\n}"},
{"allow-query", "{ homelab; localhost; }", "{ homelab; localhost; }"}, // short: as it was
{"listen-on", "port 853 tls dot { 10.0.0.1; 10.0.0.2; 10.0.0.3; }", "port 853 tls dot {\n\t10.0.0.1;\n\t10.0.0.2;\n\t10.0.0.3;\n}"},
{"allow-transfer", "{ !10.0.0.9; key xfer; 10.0.0.0/8; }", "{\n\t!10.0.0.9;\n\tkey xfer;\n\t10.0.0.0/8;\n}"},
{"recursion", "yes", "yes"},
}
for _, c := range cases {
if got := FormatValueList(c.name, c.value, 3); got != c.want {
t.Errorf("%s %q:\n got %q\nwant %q", c.name, c.value, got, c.want)
}
}
// A nested block (rate-limit with exempt-clients) keeps FormatValue's layout.
v := "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.1; 192.0.2.2; 192.0.2.3; }; }"
if got := FormatValueList("rate-limit", v, 3); got != FormatValue("rate-limit", v) {
t.Errorf("nested: %q", got)
}
}
+142
View File
@@ -0,0 +1,142 @@
package render
import (
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// Generated is a statement Jarl adds to named's configuration by itself,
// not from an option you set: why, and what controls it.
type Generated struct {
Where string `json:"where"` // top level, options, or "view NAME"
Text string `json:"text"` // as written in named.conf
Why string `json:"why"` // what it is for
Setting string `json:"setting"` // what turns it on or off
}
// GeneratedStatements lists what Render adds to named.conf beyond the
// options in the database, so nothing there comes as a surprise.
func GeneratedStatements(s *model.State, p Paths, x Extras) []Generated {
var out []Generated
add := func(where, text, why, setting string) {
out = append(out, Generated{Where: where, Text: strings.TrimRight(text, "\n"), Why: why, Setting: setting})
}
add("top level", "include "+namedconf.QuoteString(p.KeysConfig())+";",
"TSIG keys (and the view keys) live in their own file, which only named and jarld can read", "always")
st := x.Stats
logResponses := st != nil && st.LogFile != ""
pzs := PolicyZones(s, p)
if st != nil {
_, user := findTop(s, "statistics-channels")
why := "named publishes its counters for jarld's query statistics, on a loopback address only"
if user {
why += " (added to your statistics-channels statement)"
}
add("top level", formatStmt(namedconf.NewStmt("statistics-channels", namedconf.NewBlock(st.statsInet())), 0), why, "stats.enabled in jarl.yaml")
}
if logResponses {
b := namedconf.NewBlock(st.logChannel())
cat := func(name string, extra ...string) {
ch := namedconf.NewBlock(namedconf.NewStmt(StatsChannelName))
for _, e := range extra {
ch.Stmts = append(ch.Stmts, namedconf.NewStmt(e))
}
b.Stmts = append(b.Stmts, namedconf.NewStmt("category", name, ch))
}
why := []string{"responses: every answer, for the statistics' names, clients and views"}
cat("responses")
if len(pzs) > 0 {
cat("rpz")
why = append(why, "rpz: what blocking blocked")
}
if logRateLimit(s, st) {
cat("query-errors", "default_syslog")
why = append(why, "query-errors: what rate limiting limited, or would in log-only mode, for the Exposure page (also to syslog, as before)")
}
w := "named logs to a file jarld reads (" + strings.Join(why, "; ") + ")"
if _, user := findTop(s, "logging"); user {
w += "; merged into your logging statement"
}
setting := "stats.query_log in jarl.yaml"
if logRateLimit(s, st) || hasRateLimit(s) {
setting += "; query-errors: stats.rate_limit_log"
}
add("top level", formatStmt(namedconf.NewStmt("logging", b), 0), w, setting)
if s.FindOption(model.ScopeOptions, 0, "responselog") == nil {
add("options", "responselog yes;", "named logs answers from the start, for the statistics", "stats.query_log in jarl.yaml")
}
}
views := s.OrderedViews()
scopes := []struct {
id int64
name string
}{{0, "options"}}
if len(views) > 0 {
scopes = scopes[:0]
for _, v := range views {
scopes = append(scopes, struct {
id int64
name string
}{v.ID, "view " + v.Name})
}
}
for _, sc := range scopes {
zs := scopeZones(pzs, sc.id)
if len(zs) == 0 {
continue
}
w := newConfWriter()
writeResponsePolicy(w, zs, logResponses)
writePolicyZones(w, s, zs, &Output{}, 0)
add(sc.name, dedent(w.sb.String()), "blocking: your rules and blocklists, as response policy zones named loads", "the Blocking page (jarl block, jarl blocklist)")
}
if viewKeysOn(s, Extras{ViewKeySeed: x.ViewKeySeed}) {
for _, v := range views {
mc := viewMatchClients(s, x, v)
var keys []string
for _, e := range mc {
if strings.Contains(e, "key ") {
keys = append(keys, e+";")
}
}
add("view "+v.Name, "match-clients { "+strings.Join(keys, " ")+" ... };",
"view keys: a query signed with a view's key gets that view, from any address, and never another view (jarl view key)", "always, when there are views")
}
}
return out
}
func findTop(s *model.State, name string) (*model.Option, bool) {
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name == name {
return o, true
}
}
return nil, false
}
func hasRateLimit(s *model.State) bool {
for _, o := range s.Options {
if o.Name == "rate-limit" {
return true
}
}
return false
}
// logRateLimit reports whether rate limiting's log lines go to the
// response log (docs/exposure.md).
func logRateLimit(s *model.State, st *StatsHooks) bool {
return st != nil && st.LogFile != "" && !st.NoRateLimitLog && hasRateLimit(s)
}
// dedent removes one leading tab from every line.
func dedent(s string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = strings.TrimPrefix(l, "\t")
}
return strings.Join(lines, "\n")
}
+1 -4
View File
@@ -274,10 +274,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
logRewrites := logResponses && len(pzs) > 0 logRewrites := logResponses && len(pzs) > 0
// Rate limiting logs what it limits (or would, in log-only mode) as // Rate limiting logs what it limits (or would, in log-only mode) as
// query-errors; the statistics count it (docs/exposure.md). // query-errors; the statistics count it (docs/exposure.md).
logRRL := false logRRL := logRateLimit(s, st)
for _, o := range s.Options {
logRRL = logRRL || logResponses && o.Name == "rate-limit"
}
sawStats, sawLogging := false, false sawStats, sawLogging := false, false
if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 {
w.write("\n") w.write("\n")
+3
View File
@@ -26,6 +26,9 @@ type StatsHooks struct {
LogFile string // response log; "" disables response logging LogFile string // response log; "" disables response logging
LogVersions int LogVersions int
LogSize string LogSize string
// NoRateLimitLog keeps rate limiting's log lines (category
// query-errors) out of the response log (stats.rate_limit_log: false).
NoRateLimitLog bool
} }
// StatsChannelName is the logging channel Jarl adds for responses. // StatsChannelName is the logging channel Jarl adds for responses.
+42
View File
@@ -82,3 +82,45 @@ func TestStatsHooksMerged(t *testing.T) {
func TestStatsHooksWithNamed(t *testing.T) { func TestStatsHooksWithNamed(t *testing.T) {
checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()}) checkWithNamed(t, sampleState(t), Extras{Stats: statsHooks()})
} }
// Rate limiting's log lines go to the response log unless
// stats.rate_limit_log is off; the list of what Jarl adds says so.
func TestRateLimitLogAndGenerated(t *testing.T) {
s := sampleState(t)
for id, o := range s.Options {
if o.Name == "logging" {
delete(s.Options, id)
}
}
s.Options[20000] = &model.Option{ID: 20000, Scope: model.ScopeOptions, Name: "rate-limit", Value: "{ responses-per-second 10; log-only yes; }", Position: 20000}
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
conf := string(Render(s, p, Extras{Stats: statsHooks()}).File(p.MainConfig()).Content)
if !strings.Contains(conf, "category query-errors { jarl_responses; default_syslog; };") {
t.Errorf("query-errors not logged:\n%s", conf)
}
gen := GeneratedStatements(s, p, Extras{Stats: statsHooks()})
var logging *Generated
for i := range gen {
if strings.HasPrefix(gen[i].Text, "logging") {
logging = &gen[i]
}
}
if logging == nil || !strings.Contains(logging.Text, "query-errors") || !strings.Contains(logging.Setting, "stats.rate_limit_log") {
t.Errorf("generated logging = %+v (all %+v)", logging, gen)
}
if !strings.HasPrefix(gen[0].Text, "include ") || gen[0].Where != "top level" {
t.Errorf("first generated = %+v", gen[0])
}
h := statsHooks()
h.NoRateLimitLog = true
conf = string(Render(s, p, Extras{Stats: h}).File(p.MainConfig()).Content)
if strings.Contains(conf, "query-errors") || !strings.Contains(conf, "category responses") {
t.Errorf("rate_limit_log off but query-errors logged:\n%s", conf)
}
for _, g := range GeneratedStatements(s, p, Extras{Stats: h}) {
if strings.Contains(g.Text, "query-errors") {
t.Errorf("listed while off: %+v", g)
}
}
}
+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) writeJSON(w, http.StatusOK, out)
return nil 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 package server
import ( import (
"jarl/internal/mailcheck"
"net/http" "net/http"
"time" "time"
@@ -32,6 +33,11 @@ type exposureReport struct {
// RateLimiting is what rate limiting limited, or would have, in the // RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on). // last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"` 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 { type rateLimitActivity struct {
@@ -52,7 +58,7 @@ func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Princi
return err return err
} }
problems := s.eng.Validate(st) 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 { finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code) _, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc} 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) a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a out.RateLimiting = a
} }
out.Mail = s.mailReport()
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
return nil 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 { if tokens, err := s.store.ListTokens(ctx, 0); err == nil {
for _, t := range tokens { for _, t := range tokens {
if t.ExpiresAt != nil && t.RevokedAt == nil { 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 { if c, ok := catalog.Default().Lookup(scope, o.Name); ok {
out.Description, out.Status = c.Description, c.Status 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 out.Formatted = f
} }
return out 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))) m.HandleFunc("POST "+p+"/records/{id}/enable", s.authed(s.setRecordDisabled(false)))
// Options and settings // Options and settings
m.HandleFunc("GET "+p+"/options", s.authed(s.listOptions)) 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("POST "+p+"/options", s.authed(s.createOption))
m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption)) m.HandleFunc("GET "+p+"/options/{id}", s.authed(s.getOption))
m.HandleFunc("PATCH "+p+"/options/{id}", s.authed(s.updateOption)) 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("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure)) m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix)) 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/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false))) m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus)) m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
+13
View File
@@ -24,6 +24,7 @@ import (
"jarl/internal/certs" "jarl/internal/certs"
"jarl/internal/encdns" "jarl/internal/encdns"
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/mailcheck"
"jarl/internal/nscheck" "jarl/internal/nscheck"
"jarl/internal/porkbun" "jarl/internal/porkbun"
"jarl/internal/stats" "jarl/internal/stats"
@@ -63,6 +64,12 @@ type Config struct {
TOTPIssuer string TOTPIssuer string
// Metrics configures /metrics (metrics.go). // Metrics configures /metrics (metrics.go).
Metrics Metrics 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), // Certs manages TLS certificates (nil in tests that don't need it),
// including the one jarld serves (h_certs.go, sniff.go). // including the one jarld serves (h_certs.go, sniff.go).
Certs *certs.Manager Certs *certs.Manager
@@ -99,6 +106,11 @@ type Server struct {
h *encdns.Health h *encdns.Health
} }
encRoots *x509.CertPool 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 mu sync.Mutex
srvs []*http.Server srvs []*http.Server
@@ -235,6 +247,7 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
ready() ready()
} }
go s.housekeeping(ctx) go s.housekeeping(ctx)
go s.mailLoop(ctx)
go func() { go func() {
// Once jarld and named have settled after a start. // Once jarld and named have settled after a start.
select { select {
+35 -3
View File
@@ -1,8 +1,8 @@
// Internet exposure (docs/exposure.md): which views are reachable from // Internet exposure (docs/exposure.md): which views are reachable from
// the internet, why, and what to harden, with fixes staged like any edit // the internet, why, and what to harden, with fixes staged like any edit
// and findings that can be accepted on purpose. // and findings that can be accepted on purpose.
import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js"; import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural, relTime } from "../ui.js";
import { get, change, describeResult, can } from "../api.js"; import { api, get, change, describeResult, can } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
const sevKind = { warning: "warn", error: "err", info: "" }; const sevKind = { warning: "warn", error: "err", info: "" };
@@ -28,7 +28,11 @@ export async function render(el) {
parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")), parts.push(h("div", { class: "panel" }, h("h2", null, "Findings ", badge(plural(all.filter((f) => f.severity === "warning").length, "warning"), all.some((f) => f.severity === "warning") ? "warn" : "ok")),
all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check."))); all.length ? h("div", { class: "findings" }, all.map((f) => findingRow(f, writable))) : h("div", { class: "empty" }, r.scopes.some((s) => s.public) ? "Nothing to harden." : "No view is reachable from the internet, so there is nothing to check.")));
if (r.rate_limiting) parts.push(activity(r.rate_limiting, r.scopes)); if (r.rate_limiting && r.rate_limit_log) parts.push(activity(r.rate_limiting, r.scopes));
else if (r.rate_limiting && r.scopes.some((s) => s.public && s.rate_limit !== "off")) {
parts.push(h("div", { class: "msg info" }, "What rate limiting limits isn't counted: stats.rate_limit_log is off in /etc/jarl/jarl.yaml, so its log lines stay in named's syslog only."));
}
if (r.scopes.some((s) => s.public)) parts.push(mailPanel(r.mail));
mount(el, parts); mount(el, parts);
} }
@@ -118,3 +122,31 @@ function activity(a, scopes) {
table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []), table([{ label: "Client network (limited or would be)", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.networks || []),
table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]); table([{ label: "Name and type", render: (e) => h("span", { class: "mono" }, e.key) }, { label: "Responses", render: (e) => String(e.count) }], a.names || []))]);
} }
// mailPanel: the reverse DNS of the addresses that send and receive the
// public zones' mail, as receivers check it (docs/exposure.md#mail).
function mailPanel(m) {
const out = h("div");
const lv = { ok: "ok", info: "", warning: "warn" };
const draw = (x) => {
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 6 hours."));
const rows = x.results.length ? table([
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.address), h("div", { class: "muted small" }, r.zone + ": " + r.roles.join(", "))] },
{ label: "PTR", render: (r) => r.ptr.length ? h("span", { class: "mono" }, r.ptr.join(", ")) : h("span", { class: "muted" }, "none") },
{ label: "Reverse zone run by", render: (r) => h("span", { class: "small" }, r.managed_by || "–") },
{ label: "", render: (r) => [badge(r.ok ? (r.level === "info" ? "works, note" : "works") : "problem", lv[r.level]),
r.level !== "ok" ? h("div", { class: "small" }, r.message) : null,
r.local_zone ? h("div", { class: "muted small" }, "Jarl's reverse zone " + r.local_zone + " only answers your own clients: receivers ask " + (r.managed_by || "the address's owner") + ".") : null] },
], x.results) : h("div", { class: "empty" }, "No public zone has an MX record or SPF addresses.");
mount(out, rows, (x.errors || []).map((e) => h("div", { class: "msg err small" }, e)),
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
};
draw(m);
const btn = h("button", { class: "small", onclick: async () => {
btn.disabled = true;
try { draw(await api("POST", "/exposure/mail", { body: {} })); } catch (e) { toast(e.message, "err", 8000); } finally { btn.disabled = false; }
} }, "Check now");
return h("div", { class: "panel" }, h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Mail reverse DNS"), h("div", { class: "actions" }, btn)),
h("p", { class: "muted small" }, "Receivers such as Gmail and Outlook reject or mark mail from an address without a PTR record whose name resolves back to it. These records belong to whoever owns the address (your hosting provider or ISP), so Jarl looks them up as the internet sees them: the addresses of your public zones' MX hosts and SPF records."),
out);
}
+19 -1
View File
@@ -159,14 +159,32 @@ export async function render(el) {
const settingsEl = h("div"); const settingsEl = h("div");
const serverEl = h("div"); const serverEl = h("div");
const topEl = h("div"); const topEl = h("div");
const genEl = h("div");
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"), h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Server options"),
h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))), h("div", { class: "muted" }, "Global named options, top-level statements such as logging and controls, and Jarl's defaults for new zones."))),
settingsEl, serverEl, topEl); settingsEl, serverEl, topEl, genEl);
settingsForm(settingsEl, settings); settingsForm(settingsEl, settings);
await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." }); await optionsEditor(serverEl, { scope: "options", catalogScope: "options", title: "options { }", intro: "These apply to the whole server; views and zones can override many of them." });
await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements", await optionsEditor(topEl, { scope: "toplevel", catalogScope: "toplevel", title: "Top-level statements",
intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." }); intro: "Statements outside options { }, such as logging, controls, statistics-channels, server, remote-servers, tls or include. Values are named.conf syntax, e.g. { channel ... ; category ... ; } for logging." });
generatedPanel(genEl, await get("/options/generated").catch(() => []));
}
// generatedPanel lists what Jarl adds to named.conf by itself: read-only,
// with why and what turns it on or off.
function generatedPanel(el, list) {
if (!list.length) return;
mount(el, h("div", { class: "panel" },
h("h2", null, "Added by Jarl"),
h("p", { class: "muted" }, "Jarl writes these into named.conf itself, for its own features. They aren't options you can edit here: each is controlled by the setting shown."),
h("div", { class: "table-wrap" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
h("tbody", null, list.map((g) => h("tr", null,
h("td", { class: "small" }, g.where),
h("td", null, h("pre", { class: "code optval", style: "white-space: pre-wrap; word-break: break-all" }, g.text)),
h("td", { class: "small" }, g.why),
h("td", { class: "small mono" }, g.setting))))))));
} }
function settingsForm(el, s) { function settingsForm(el, s) {
+7
View File
@@ -453,6 +453,13 @@ contains "rate-limit { responses-per-second 10; log-only yes; };"
run "exposure: log-only now" 0 $J exposure run "exposure: log-only now" 0 $J exposure
contains "^external *public *log-only" contains "^external *public *log-only"
contains "exposure_rate_limit_log_only" contains "exposure_rate_limit_log_only"
# Statistics are off here; render/stats_test.go covers their statements.
run "what Jarl adds by itself" 0 $J option generated
contains "^include .*keys.conf"
contains 'match-clients { !key "jarl-view-external"; key "jarl-view-internal"; ... };'
not_contains "statistics-channels"
run "generated, as JSON" 0 $J option generated -o json
contains '"setting": "always, when there are views"'
run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only run "fix: start enforcing" 0 $J exposure fix external exposure_rate_limit_log_only
contains "responses-per-second 10; }" contains "responses-per-second 10; }"
# A burst of identical queries in the external view (signed with its key): # A burst of identical queries in the external view (signed with its key):