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:
1 parent
d33f573782
commit
c27a6c10bd
30 files changed
+1094
-15
No files matched your search
@@ -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
|
||||
|
||||
+29
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+81
-1
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -334,6 +335,8 @@ func serveCmd() *cobra.Command {
|
||||
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,
|
||||
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,
|
||||
|
||||
+2
-1
@@ -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"}` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+36
-2
@@ -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"}` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user