diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c88ddf..64b9218 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ All notable changes to Jarl are documented here. The format follows ## [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 ### Added diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index 40f3626..2f4dc0f 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -721,7 +721,35 @@ func optionCmd() *cobra.Command { } scopeArgs(describe) 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 } diff --git a/cmd/jarl/exposure.go b/cmd/jarl/exposure.go index d0588de..78ec83c 100644 --- a/cmd/jarl/exposure.go +++ b/cmd/jarl/exposure.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "io" "strings" @@ -42,6 +43,54 @@ type exposureReport struct { Count int64 } `json:"names"` } `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 @@ -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 { fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since) for _, e := range a.Counts { @@ -186,6 +239,33 @@ fix Jarl can stage for each.`, 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 } diff --git a/cmd/jarld/main.go b/cmd/jarld/main.go index 56dd792..53b5f05 100644 --- a/cmd/jarld/main.go +++ b/cmd/jarld/main.go @@ -188,6 +188,7 @@ func renderExtras(cfg *config.Config) render.Extras { h := &render.StatsHooks{Channel: netip.MustParseAddrPort(cfg.Stats.StatisticsChannel)} if cfg.Stats.QueryLog { h.LogFile, h.LogVersions, h.LogSize = cfg.Stats.LogFile, cfg.Stats.LogVersions, cfg.Stats.LogSize + h.NoRateLimitLog = !cfg.Stats.RateLimitLog } x.Stats = h } @@ -333,7 +334,9 @@ func serveCmd() *cobra.Command { TOTPRequire: a.cfg.TOTP.Require, TOTPIssuer: a.cfg.TOTP.IssuerName(), Metrics: server.Metrics{Enabled: a.cfg.Metrics.Enabled, Listen: a.cfg.Metrics.Listen, 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{ Enabled: a.cfg.ACME.HTTPReq.Enabled, AllowPlainHTTP: !a.cfg.ACME.HTTPReq.RequireHTTPS, diff --git a/docs/api.md b/docs/api.md index 133f3a2..268948f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -470,7 +470,8 @@ listing with `?changeset=` marks them `pending`. | 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}` | | GET / PATCH / DELETE | `/options/{id}` | | | GET / PATCH | `/settings` | `{"nameservers": [...], "rname": "...", "default_ttl": 3600, "refresh": ..., "retry": ..., "expire": ..., "negative_ttl": ..., "serial_policy": "date"}` | diff --git a/docs/cli.md b/docs/cli.md index 91425fc..b081950 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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_log_only # then start enforcing 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 unack external security_open_resolver 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 describe # every option allowed at this scope 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 diff --git a/docs/concepts.md b/docs/concepts.md index 9296a06..e9e29de 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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 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 options can all carry a comment, and Jarl writes it into the generated file. Comments in imported zone files are kept. diff --git a/docs/exposure.md b/docs/exposure.md index 94e4c78..644b03c 100644 --- a/docs/exposure.md +++ b/docs/exposure.md @@ -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 `would rate limit`. When rate limiting is configured and statistics are 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`, `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 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 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 | |--------|------|------| -| 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/ack` | `{"view": "external", "code": "security_open_resolver", "reason": "..."}` | | POST | `/exposure/unack` | `{"view": "external", "code": "security_open_resolver"}` | diff --git a/docs/metrics.md b/docs/metrics.md index c344e8c..5de6af8 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -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_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_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)). | ### Queries (with `stats.enabled`) @@ -238,6 +239,10 @@ groups: for: 20m annotations: 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 expr: jarl_blocking_paused == 1 for: 24h diff --git a/docs/stats.md b/docs/stats.md index 234db3d..e933792 100644 --- a/docs/stats.md +++ b/docs/stats.md @@ -60,6 +60,7 @@ off again. The statistics database stays until you delete it stats: enabled: false 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 statistics_channel: 127.0.0.1:8058 # must be a loopback address 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 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 lists, views, zones). named's own counters can't be filtered. diff --git a/internal/config/config.go b/internal/config/config.go index 82ea732..f70afde 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -364,6 +364,10 @@ type Stats struct { // per-zone counts) from named's response log. Without it only named's // own counters are collected. 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 // configuration database. Database string `yaml:"database"` @@ -451,6 +455,7 @@ func Default() *Config { }, Stats: Stats{ QueryLog: true, + RateLimitLog: true, Database: "/var/lib/jarl/stats.db", StatisticsChannel: "127.0.0.1:8058", LogFile: "/var/log/named/jarl-responses.log", @@ -736,6 +741,10 @@ stats: # Per-query data (top names and clients, per view and zone). false # collects only named's own counters, with no logging overhead. 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 statistics_channel: 127.0.0.1:8058 log_file: /var/log/named/jarl-responses.log diff --git a/internal/engine/viewkeys.go b/internal/engine/viewkeys.go index 2d8bc52..653a670 100644 --- a/internal/engine/viewkeys.go +++ b/internal/engine/viewkeys.go @@ -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)} } + +// 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)) +} diff --git a/internal/mailcheck/mailcheck.go b/internal/mailcheck/mailcheck.go new file mode 100644 index 0000000..2727af3 --- /dev/null +++ b/internal/mailcheck/mailcheck.go @@ -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 +} diff --git a/internal/mailcheck/mailcheck_test.go b/internal/mailcheck/mailcheck_test.go new file mode 100644 index 0000000..7f83f17 --- /dev/null +++ b/internal/mailcheck/mailcheck_test.go @@ -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) + } + } +} diff --git a/internal/namedconf/printer.go b/internal/namedconf/printer.go index 8d70b17..efe28e6 100644 --- a/internal/namedconf/printer.go +++ b/internal/namedconf/printer.go @@ -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()) +} diff --git a/internal/namedconf/printer_test.go b/internal/namedconf/printer_test.go index 744e72c..b54cd43 100644 --- a/internal/namedconf/printer_test.go +++ b/internal/namedconf/printer_test.go @@ -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) + } +} diff --git a/internal/render/generated.go b/internal/render/generated.go new file mode 100644 index 0000000..2e9c8a1 --- /dev/null +++ b/internal/render/generated.go @@ -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") +} diff --git a/internal/render/render.go b/internal/render/render.go index 306e55e..95a0c65 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -274,10 +274,7 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa logRewrites := logResponses && len(pzs) > 0 // Rate limiting logs what it limits (or would, in log-only mode) as // query-errors; the statistics count it (docs/exposure.md). - logRRL := false - for _, o := range s.Options { - logRRL = logRRL || logResponses && o.Name == "rate-limit" - } + logRRL := logRateLimit(s, st) sawStats, sawLogging := false, false if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { w.write("\n") diff --git a/internal/render/stats.go b/internal/render/stats.go index 61ef9f8..517598d 100644 --- a/internal/render/stats.go +++ b/internal/render/stats.go @@ -26,6 +26,9 @@ type StatsHooks struct { LogFile string // response log; "" disables response logging LogVersions int 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. diff --git a/internal/render/stats_test.go b/internal/render/stats_test.go index 0e68d0e..ae3cd62 100644 --- a/internal/render/stats_test.go +++ b/internal/render/stats_test.go @@ -82,3 +82,45 @@ func TestStatsHooksMerged(t *testing.T) { func TestStatsHooksWithNamed(t *testing.T) { 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) + } + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 011f6e3..0026d7d 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -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 +} diff --git a/internal/server/h_exposure.go b/internal/server/h_exposure.go index f17ad33..a18895d 100644 --- a/internal/server/h_exposure.go +++ b/internal/server/h_exposure.go @@ -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 } diff --git a/internal/server/h_mail.go b/internal/server/h_mail.go new file mode 100644 index 0000000..505ab84 --- /dev/null +++ b/internal/server/h_mail.go @@ -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 + } + } +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go index dc0b59a..735cadf 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -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 { diff --git a/internal/server/present.go b/internal/server/present.go index 1c9ea9a..668abd4 100644 --- a/internal/server/present.go +++ b/internal/server/present.go @@ -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 diff --git a/internal/server/routes.go b/internal/server/routes.go index f7d56e7..7165b10 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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)) diff --git a/internal/server/server.go b/internal/server/server.go index c7ac8e1..d049cb7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/internal/web/static/assets/pages/exposure.js b/internal/web/static/assets/pages/exposure.js index 59c92a9..3e4db81 100644 --- a/internal/web/static/assets/pages/exposure.js +++ b/internal/web/static/assets/pages/exposure.js @@ -1,8 +1,8 @@ // Internet exposure (docs/exposure.md): which views are reachable from // the internet, why, and what to harden, with fixes staged like any edit // and findings that can be accepted on purpose. -import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural } from "../ui.js"; -import { get, change, describeResult, can } from "../api.js"; +import { h, mount, table, toast, badge, modal, input, field, errorBox, select, plural, relTime } from "../ui.js"; +import { api, get, change, describeResult, can } from "../api.js"; import { rerender } from "../app.js"; 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")), 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); } @@ -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: "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); +} diff --git a/internal/web/static/assets/pages/options.js b/internal/web/static/assets/pages/options.js index 8323dc0..5f29e92 100644 --- a/internal/web/static/assets/pages/options.js +++ b/internal/web/static/assets/pages/options.js @@ -159,14 +159,32 @@ export async function render(el) { const settingsEl = h("div"); const serverEl = h("div"); const topEl = h("div"); + const genEl = h("div"); mount(el, 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."))), - settingsEl, serverEl, topEl); + settingsEl, serverEl, topEl, genEl); 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(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." }); + 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) { diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 5f71784..e0b0175 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -453,6 +453,13 @@ contains "rate-limit { responses-per-second 10; log-only yes; };" run "exposure: log-only now" 0 $J exposure contains "^external *public *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 contains "responses-per-second 10; }" # A burst of identical queries in the external view (signed with its key):