Compare commits
5
Commits
v0.5.0-rc.1
...
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b054cc86 | ||
|
|
c27a6c10bd | ||
|
|
d33f573782 | ||
|
|
13ffd8cad4 | ||
|
|
67ae0091b3 |
No files matched your search
@@ -3,3 +3,5 @@
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/jarl
|
||||
/jarld
|
||||
@@ -6,6 +6,61 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.2] - 2026-10-12
|
||||
|
||||
### 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
|
||||
|
||||
- Encrypted DNS health: jarld checks the DoT and DoH listeners every 10
|
||||
minutes as a client would (the certificate is for the host name,
|
||||
current and the one Jarl last wrote; a question gets an answer) and
|
||||
says what is wrong: nothing listening, a firewall, the wrong or an
|
||||
older certificate. On the Encrypted DNS page (Check now), the
|
||||
dashboard, `jarl encrypted-dns health` and Prometheus
|
||||
`jarl_encrypted_dns_up`.
|
||||
- Comparing a zone's views: the Compare views tab and `jarl zone diff`
|
||||
show the names and types that differ between the views that have a
|
||||
zone, linked or not, with "Use this version" to share one view's
|
||||
version. `GET /zones/{zone}/compare`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `--local` (and `"local": true`) works on zones that aren't linked too:
|
||||
their records are local anyway. `--shared` there is still refused.
|
||||
- When `certificates.dir` changes, jarld points the `tls` statements
|
||||
encrypted DNS wrote at the new place when it starts; named kept the old
|
||||
copies before, which renewals no longer updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sharing a view's version of a name that was shared already (Use this
|
||||
version on the shared side) didn't replace another view's override; it
|
||||
said there was nothing to do.
|
||||
|
||||
## [0.5.0] - 2026-10-12
|
||||
|
||||
### Upgrading from 0.4
|
||||
|
||||
- The database gets three new versions (internet exposure, certificates,
|
||||
|
||||
@@ -200,12 +200,11 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.4.0 is the current release, and 0.5.0 is in testing
|
||||
(0.5.0-rc.1): it adds Prometheus metrics, address ranges in ACLs and
|
||||
other address match lists, safer changeset handling, internet exposure
|
||||
checks with response rate limiting, TLS certificates Jarl obtains and
|
||||
renews itself, linked zones for split DNS, DNS over TLS and HTTPS, and statistics
|
||||
per view. 0.4.0 added blocking (blocklists and
|
||||
Version 0.5.2 is the current release. It adds Prometheus metrics,
|
||||
address ranges in ACLs and other address match lists, safer changeset
|
||||
handling, internet exposure checks with response rate limiting, TLS
|
||||
certificates Jarl obtains and renews itself, linked zones for split DNS,
|
||||
DNS over TLS and HTTPS, and statistics per view. 0.4.0 added blocking (blocklists and
|
||||
your own rules, like Pi-hole, done by named, with different blocking for
|
||||
different devices), disabling zones, server statistics and view keys, on
|
||||
top of 0.3.0's disabled records, script-friendly CLI output and client
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
|
||||
+71
-1
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ type encStatus struct {
|
||||
DoT []encEndpoint `json:"dot"`
|
||||
DoH []encEndpoint `json:"doh"`
|
||||
Other bool `json:"other"`
|
||||
Health *encHealth `json:"health"`
|
||||
Addresses []struct {
|
||||
Address string `json:"address"`
|
||||
Private bool `json:"private"`
|
||||
@@ -99,6 +101,9 @@ views.`,
|
||||
fmt.Fprintf(w, "For DoH clients:\t%s (Firefox, Chrome, Windows 11)\n", s.DoHURL)
|
||||
}
|
||||
}
|
||||
if s.Enabled {
|
||||
printHealth(w, s.Health)
|
||||
}
|
||||
if s.Other {
|
||||
fmt.Fprintln(w, "Note:\tnamed has DoT or DoH listeners written by hand too; setup leaves them alone")
|
||||
}
|
||||
@@ -308,7 +313,38 @@ replaces the earlier setup.`,
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(setup, check, disable)
|
||||
health := &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check the DoT and DoH listeners now, as a client would",
|
||||
Long: `Connects to each listener, checks the certificate it serves (for the
|
||||
host name, current, the one Jarl last wrote) and asks a question. jarld
|
||||
also does this every 10 minutes; the dashboard and Prometheus
|
||||
(jarl_encrypted_dns_up) show the result. Exits 1 when a listener doesn't
|
||||
work.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h encHealth
|
||||
if err := c.Do(ctx(), "POST", "/encrypted-dns/health", nil, map[string]any{}, &h); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := output(h, func(w io.Writer) { printHealth(w, &h) }); err != nil {
|
||||
return err
|
||||
}
|
||||
ok := true
|
||||
for _, p := range h.Probes {
|
||||
ok = ok && p.OK
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("encrypted DNS isn't working everywhere")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(setup, check, disable, health)
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -337,3 +373,37 @@ func contains(list []string, s string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type encHealth struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Probes []struct {
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
OK bool `json:"ok"`
|
||||
Problem string `json:"problem"`
|
||||
Warning string `json:"warning"`
|
||||
RCode string `json:"rcode"`
|
||||
Ms int64 `json:"ms"`
|
||||
} `json:"probes"`
|
||||
}
|
||||
|
||||
func printHealth(w io.Writer, h *encHealth) bool {
|
||||
if h == nil {
|
||||
fmt.Fprintln(w, "Health:\tnot checked yet (jarl encrypted-dns health)")
|
||||
return true
|
||||
}
|
||||
ok := true
|
||||
for _, p := range h.Probes {
|
||||
kind := map[string]string{"dot": "DoT", "doh": "DoH"}[p.Kind]
|
||||
state := fmt.Sprintf("working (%s in %d ms)", p.RCode, p.Ms)
|
||||
if !p.OK {
|
||||
state, ok = "NOT WORKING: "+p.Problem, false
|
||||
} else if p.Warning != "" {
|
||||
state += "; " + p.Warning
|
||||
}
|
||||
fmt.Fprintf(w, "%s %s port %d:\t%s\n", kind, p.Address, p.Port, state)
|
||||
}
|
||||
fmt.Fprintf(w, "Checked:\t%s\n", h.Checked.Local().Format("2006-01-02 15:04:05"))
|
||||
return ok
|
||||
}
|
||||
+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
|
||||
}
|
||||
+88
-1
@@ -424,7 +424,7 @@ equal to the file: records not in the file are deleted.`,
|
||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||
|
||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
|
||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false), zoneDiffCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -937,3 +937,90 @@ zone is dissolved.`
|
||||
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||
return cmd
|
||||
}
|
||||
|
||||
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||
// that have it.
|
||||
func zoneDiffCmd() *cobra.Command {
|
||||
var views []string
|
||||
var all bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff ZONE [--view V --view W] [--all]",
|
||||
Short: "Show what differs between the views that have a zone",
|
||||
Long: `Lines up a zone's records in the views that have it (or the views
|
||||
given), one row per name and type, and shows the ones that differ: a
|
||||
local override in a linked zone, a record one view lacks. With --all, the
|
||||
ones that are the same too. In a linked zone, 'jarl record update ID
|
||||
--shared' makes a view's version every view's.`,
|
||||
Example: ` jarl zone diff adbraun.lol
|
||||
jarl zone diff adbraun.lol --view internal --view external --all`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], "")
|
||||
if len(views) > 0 {
|
||||
qv.Set("views", strings.Join(views, ","))
|
||||
}
|
||||
if all {
|
||||
qv.Set("all", "1")
|
||||
}
|
||||
var r struct {
|
||||
Zone string `json:"zone"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"`
|
||||
Same int `json:"same"`
|
||||
Rows []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []struct {
|
||||
Records []string `json:"records"`
|
||||
IDs []int64 `json:"ids"`
|
||||
Linked string `json:"linked"`
|
||||
} `json:"views"`
|
||||
} `json:"rows"`
|
||||
}
|
||||
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
head := []string{"NAME", "TYPE"}
|
||||
for _, v := range r.Views {
|
||||
head = append(head, strings.ToUpper(v))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(head, "\t"))
|
||||
for _, row := range r.Rows {
|
||||
cols := []string{row.Name, row.Type}
|
||||
for _, vs := range row.Views {
|
||||
cell := "-"
|
||||
if len(vs.Records) > 0 {
|
||||
cell = strings.Join(vs.Records, ", ")
|
||||
if vs.Linked == "local" {
|
||||
cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||
}
|
||||
}
|
||||
cols = append(cols, cell)
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||
}
|
||||
link := "not linked (jarl zone link " + r.Zone + ")"
|
||||
if r.Linked {
|
||||
link = "linked"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link)
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func boolCount(b bool, n int) int {
|
||||
if b {
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+3
-1
@@ -353,6 +353,7 @@ and `linked_with` (the other views).
|
||||
|--------|------|------|
|
||||
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
|
||||
|
||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||
that view's zone of the same name.
|
||||
@@ -469,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"}` |
|
||||
|
||||
@@ -193,7 +193,10 @@ jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
|
||||
```
|
||||
|
||||
A certificate named uses can't be deleted or lose its `dns` use until
|
||||
named no longer names its files.
|
||||
named no longer names its files. If you change `certificates.dir`, jarld
|
||||
writes the certificates to the new place when it starts and points the
|
||||
`tls` statements encrypted DNS wrote there (a hand-written `tls`
|
||||
statement you change yourself).
|
||||
|
||||
## A certificate you renew yourself
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ jarl zone unlink adbraun.lol --view vps
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||
jarl record update 1234 --local | --shared
|
||||
jarl zone diff adbraun.lol [--view V --view W] [--all] # what differs between the views
|
||||
```
|
||||
|
||||
When a zone exists in several views, add `--view NAME` to commands that
|
||||
@@ -404,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
|
||||
@@ -417,6 +419,7 @@ See [exposure.md](exposure.md).
|
||||
jarl encrypted-dns # what is set up, what to give clients
|
||||
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 [--address IPv6] [--doh-port 8443] [--no-doh]
|
||||
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
|
||||
jarl encrypted-dns health # check the listeners now, as a client would
|
||||
jarl encrypted-dns disable
|
||||
```
|
||||
|
||||
@@ -474,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.
|
||||
|
||||
@@ -113,6 +113,31 @@ Clients check the certificate against the host name, so the name must
|
||||
resolve to this server for them: the setup adds it to your private views.
|
||||
Open the ports in the server's firewall for your own networks only.
|
||||
|
||||
## Health
|
||||
|
||||
jarld checks the listeners itself, a minute after it starts and then
|
||||
every 10 minutes, the way a client would: it connects to each, checks the
|
||||
certificate named serves (for the host name, current, and the one Jarl
|
||||
last wrote, so a renewal named hasn't reread shows up) and asks for the
|
||||
host name. The Encrypted DNS page shows the result per listener (**Check
|
||||
now** checks again), the dashboard a card and the problems, Prometheus
|
||||
`jarl_encrypted_dns_up{kind,address,port}`:
|
||||
|
||||
```sh
|
||||
jarl encrypted-dns health # check now; exit 1 when a listener doesn't work
|
||||
```
|
||||
|
||||
What it can say:
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| nothing listens on ADDR port P | named couldn't bind the port: look in named's log (ports below 1024 need named to start as root, as the Debian service does) |
|
||||
| no answer within 5s | a firewall on the server |
|
||||
| a certificate for X, not HOST | named serves another certificate |
|
||||
| an older certificate | named hasn't reread the renewed files (`rndc reconfig`) |
|
||||
| not from a CA this server trusts (warning) | a staging or test CA: clients reject it unless they trust it |
|
||||
| REFUSED (warning) | it works, but this server's own address gets a view that doesn't answer for the host name |
|
||||
|
||||
## What Jarl checks
|
||||
|
||||
* A `tls` statement whose files don't exist is an error
|
||||
@@ -129,3 +154,4 @@ Open the ports in the server's firewall for your own networks only.
|
||||
| POST | `/encrypted-dns/check` | `{"addresses", "dot", "doh", "dot_port", "doh_port"}`: `conflicts` (address, port, what) and `free_doh_port` |
|
||||
| POST | `/encrypted-dns` | `{"hostname", "certificate", "addresses", "dot", "doh", "dot_port", "doh_port", "views"}`: staged or applied like any change; 409 while the certificate hasn't been obtained |
|
||||
| DELETE | `/encrypted-dns` | Turns it off |
|
||||
| POST | `/encrypted-dns/health` | Checks the listeners now: `checked`, `probes` (kind, address, port, `ok`, `problem`, `warning`, certificate names, expiry and serial, `trusted`, `rcode`, `ms`). `GET /encrypted-dns` has the last check as `health` |
|
||||
+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,8 @@ 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`)
|
||||
|
||||
@@ -232,6 +234,15 @@ groups:
|
||||
expr: jarl_certificate_expiry_timestamp_seconds - time() < 10 * 86400
|
||||
annotations:
|
||||
summary: "Certificate {{ $labels.certificate }} expires within 10 days"
|
||||
- alert: EncryptedDNSDown
|
||||
expr: jarl_encrypted_dns_up == 0
|
||||
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.
|
||||
|
||||
|
||||
@@ -209,6 +209,23 @@ each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||
keeps every record, all local; a link left with one zone is dissolved, as
|
||||
when the other zone is deleted.
|
||||
|
||||
**Comparing the views.** The zone's **Compare views** tab, or `jarl zone
|
||||
diff adbraun.lol`, lines the views up and shows each name and type that
|
||||
differs: a view's own records, or a name one view lacks. **Use this
|
||||
version** (`jarl record update ID --shared`) makes one view's version
|
||||
every view's. It works for zones that aren't linked too, to see what
|
||||
linking them would keep local.
|
||||
|
||||
```
|
||||
$ jarl zone diff adbraun.lol
|
||||
NAME TYPE INTERNAL EXTERNAL
|
||||
nas A 10.0.10.20 [local, id 812] 203.0.113.20 [local, id 790]
|
||||
5 names and types differ, 21 are the same; linked
|
||||
```
|
||||
|
||||
`--local` also works on a zone that isn't linked (its records are local
|
||||
anyway), so scripts don't need to know; `--shared` there is refused.
|
||||
|
||||
**Permissions** are checked in every view a change reaches. A token with
|
||||
a grant for one view only (`views=internal`) can change that view's local
|
||||
records and make overrides, but not a shared record, since that would
|
||||
|
||||
@@ -34,6 +34,9 @@ type Zone struct {
|
||||
LinkedWith []string `json:"linked_with,omitempty"`
|
||||
// Links are the zones this one is linked with, with their views.
|
||||
Links []ZoneLink `json:"links,omitempty"`
|
||||
// OtherViews are the other views with a primary zone of this name
|
||||
// (linked or not): what GET /zones/{zone}/compare compares.
|
||||
OtherViews []string `json:"other_views,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -266,6 +269,9 @@ type StatusResponse struct {
|
||||
// Certificates sums up the TLS certificates Jarl manages
|
||||
// (administrators only, when there are any).
|
||||
Certificates *CertSummary `json:"certificates,omitempty"`
|
||||
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
||||
// is set up (for those who may see options).
|
||||
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
||||
}
|
||||
|
||||
// CertSummary is what the dashboard reports about certificates.
|
||||
@@ -575,3 +581,11 @@ type ZoneLink struct {
|
||||
ID int64 `json:"id"`
|
||||
View string `json:"view"`
|
||||
}
|
||||
|
||||
// EncDNSSummary is what the dashboard says about encrypted DNS.
|
||||
type EncDNSSummary struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
OK bool `json:"ok"`
|
||||
Problems []string `json:"problems"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -488,3 +489,34 @@ func TestCleanLegoError(t *testing.T) {
|
||||
t.Errorf("rate limit: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When certificates.dir changes, Start writes the certificates to the new
|
||||
// place and points encrypted DNS's tls statement there.
|
||||
func TestDirChangeFollowed(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "c.pem"), filepath.Join(e.dir, "k.pem")
|
||||
cert, key := makeCert(t, []string{"dns.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("dns"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"dns"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.submit(engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(encdns.TLSValue("dns", e.m.cfg.Dir))}))
|
||||
|
||||
moved := filepath.Join(e.dir, "tls-moved")
|
||||
cfg := e.m.cfg
|
||||
cfg.Dir = moved
|
||||
m2 := New(cfg, e.st, e.eng, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
m2.Start(ctx)
|
||||
if _, err := os.Stat(filepath.Join(moved, "dns", ChainFile)); err != nil {
|
||||
t.Fatalf("not written to the new place: %v", err)
|
||||
}
|
||||
for _, o := range e.state().ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if o.Name == "tls" && !strings.Contains(o.Value, moved+"/dns/fullchain.pem") {
|
||||
t.Errorf("tls statement still points elsewhere: %s", o.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
@@ -766,6 +767,40 @@ func (m *Manager) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
m.followDir(ctx, all)
|
||||
}
|
||||
|
||||
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
|
||||
// certificates.dir when it has changed: Start has just written the
|
||||
// certificates there, and named would otherwise keep serving the old
|
||||
// copies, which renewals no longer update.
|
||||
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var reqs []engine.Request
|
||||
for _, b := range encdns.TLSBlocks(st) {
|
||||
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
|
||||
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
|
||||
continue
|
||||
}
|
||||
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
|
||||
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
|
||||
continue // not written there (yet)
|
||||
}
|
||||
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
|
||||
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
|
||||
}
|
||||
if len(reqs) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
|
||||
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run renews certificates when they are due and rereads file-source ones
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -272,9 +272,7 @@ func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string,
|
||||
return nil, nil, fmt.Errorf("choose at least one address to listen on")
|
||||
}
|
||||
tlsName := Prefix + s.Certificate
|
||||
dir := filepath.Join(certDir, s.Certificate)
|
||||
tlsValue := fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||
tlsName, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||
tlsValue := TLSValue(s.Certificate, certDir)
|
||||
|
||||
var reqs []engine.Request
|
||||
// Take out the earlier setup's listeners and tls statements.
|
||||
@@ -393,3 +391,11 @@ func Private(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || netip.MustParsePrefix("100.64.0.0/10").Contains(a)
|
||||
}
|
||||
|
||||
// TLSValue is the tls statement the setup writes for a certificate whose
|
||||
// files are in certDir/NAME.
|
||||
func TLSValue(cert, certDir string) string {
|
||||
dir := filepath.Join(certDir, cert)
|
||||
return fmt.Sprintf(`%s { cert-file "%s"; key-file "%s"; protocols { TLSv1.2; TLSv1.3; }; }`,
|
||||
Prefix+cert, filepath.Join(dir, "fullchain.pem"), filepath.Join(dir, "privkey.pem"))
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Probe is the outcome of checking one DoT or DoH listener the way a
|
||||
// client would: connect, check the certificate, ask a question.
|
||||
type Probe struct {
|
||||
Kind string `json:"kind"` // dot or doh
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
// OK: it answered over TLS with a certificate for the host name that
|
||||
// is current. Problem says what is wrong when not; Warning notes
|
||||
// something that doesn't stop clients from working everywhere.
|
||||
OK bool `json:"ok"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
// What the certificate named served says.
|
||||
CertNames []string `json:"cert_names,omitempty"`
|
||||
CertNotAfter *time.Time `json:"cert_not_after,omitempty"`
|
||||
CertSerial string `json:"cert_serial,omitempty"`
|
||||
// Trusted: it chains to a CA this server trusts (not so for a
|
||||
// staging or test CA).
|
||||
Trusted bool `json:"trusted"`
|
||||
RCode string `json:"rcode,omitempty"`
|
||||
Millis int64 `json:"ms"`
|
||||
}
|
||||
|
||||
// Health is the last check of every listener.
|
||||
type Health struct {
|
||||
Checked time.Time `json:"checked"`
|
||||
Probes []Probe `json:"probes"`
|
||||
}
|
||||
|
||||
// OK reports whether every listener works.
|
||||
func (h *Health) OK() bool {
|
||||
for _, p := range h.Probes {
|
||||
if !p.OK {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Check probes every DoT and DoH listener of the setup. hostname is what
|
||||
// clients ask for, serial the certificate Jarl last wrote for named ("" if
|
||||
// unknown), roots the CAs to trust (nil: the system's).
|
||||
func Check(ctx context.Context, s Status, hostname, serial string, roots *x509.CertPool) *Health {
|
||||
h := &Health{Checked: time.Now(), Probes: []Probe{}}
|
||||
for _, e := range s.DoT {
|
||||
h.Probes = append(h.Probes, probe(ctx, "dot", e, hostname, serial, roots))
|
||||
}
|
||||
for _, e := range s.DoH {
|
||||
h.Probes = append(h.Probes, probe(ctx, "doh", e, hostname, serial, roots))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
const probeTimeout = 5 * time.Second
|
||||
|
||||
func probe(ctx context.Context, kind string, e Endpoint, hostname, serial string, roots *x509.CertPool) Probe {
|
||||
p := Probe{Kind: kind, Address: e.Address, Port: e.Port}
|
||||
addr := net.JoinHostPort(e.Address, strconv.Itoa(e.Port))
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
q := new(dns.Msg)
|
||||
q.SetQuestion(dns.Fqdn(hostname), dns.TypeA)
|
||||
q.RecursionDesired = true
|
||||
var state tls.ConnectionState
|
||||
var answer *dns.Msg
|
||||
var err error
|
||||
cfg := &tls.Config{ServerName: hostname, InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} // checked below, to say what is wrong
|
||||
if kind == "dot" {
|
||||
cfg.NextProtos = []string{"dot"}
|
||||
state, answer, err = askDoT(ctx, addr, cfg, q)
|
||||
} else {
|
||||
state, answer, err = askDoH(ctx, addr, hostname, e.Port, cfg, q)
|
||||
}
|
||||
p.Millis = time.Since(start).Milliseconds()
|
||||
if len(state.PeerCertificates) > 0 {
|
||||
leaf := state.PeerCertificates[0]
|
||||
p.CertNames, p.CertSerial = leaf.DNSNames, fmt.Sprintf("%x", leaf.SerialNumber)
|
||||
na := leaf.NotAfter
|
||||
p.CertNotAfter = &na
|
||||
inter := x509.NewCertPool()
|
||||
for _, c := range state.PeerCertificates[1:] {
|
||||
inter.AddCert(c)
|
||||
}
|
||||
_, verr := leaf.Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: hostname})
|
||||
p.Trusted = verr == nil
|
||||
switch {
|
||||
case leaf.VerifyHostname(hostname) != nil:
|
||||
p.Problem = fmt.Sprintf("named serves a certificate for %s, not %s: clients reject it", strings.Join(leaf.DNSNames, ", "), hostname)
|
||||
case time.Now().After(leaf.NotAfter):
|
||||
p.Problem = "named serves an expired certificate (until " + leaf.NotAfter.Format(time.DateOnly) + ")"
|
||||
case serial != "" && p.CertSerial != serial:
|
||||
p.Problem = "named still serves an older certificate (serial " + p.CertSerial + ", Jarl has " + serial + "): it hasn't reread the files (rndc reconfig)"
|
||||
case !p.Trusted:
|
||||
p.Warning = "the certificate isn't from a CA this server trusts (a staging or test CA?): clients reject it unless they trust that CA"
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
p.Problem = describe(err, e)
|
||||
case p.Problem != "":
|
||||
case answer == nil:
|
||||
p.Problem = "no DNS answer"
|
||||
default:
|
||||
p.RCode = dns.RcodeToString[answer.Rcode]
|
||||
p.OK = true
|
||||
if answer.Rcode == dns.RcodeRefused {
|
||||
p.Warning = strings.TrimPrefix(p.Warning+"; ", "; ") + "it answers, but REFUSED: this server's own address gets a view that doesn't answer for " + hostname
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func askDoT(ctx context.Context, addr string, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
d := &tls.Dialer{NetDialer: &net.Dialer{Timeout: probeTimeout}, Config: cfg}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
tc := conn.(*tls.Conn)
|
||||
state := tc.ConnectionState()
|
||||
conn.SetDeadline(time.Now().Add(probeTimeout))
|
||||
co := &dns.Conn{Conn: conn}
|
||||
if err := co.WriteMsg(q); err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
r, err := co.ReadMsg()
|
||||
return state, r, err
|
||||
}
|
||||
|
||||
func askDoH(ctx context.Context, addr, hostname string, port int, cfg *tls.Config, q *dns.Msg) (tls.ConnectionState, *dns.Msg, error) {
|
||||
cfg = cfg.Clone()
|
||||
cfg.NextProtos = []string{"h2", "http/1.1"}
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: cfg,
|
||||
ForceAttemptHTTP2: true,
|
||||
// Whatever the host name resolves to here: the listener's address.
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: probeTimeout}).DialContext(ctx, network, addr)
|
||||
},
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
wire, err := q.Pack()
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
url := "https://" + net.JoinHostPort(hostname, strconv.Itoa(port)) + "/dns-query"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
resp, err := (&http.Client{Transport: tr, Timeout: probeTimeout}).Do(req)
|
||||
if err != nil {
|
||||
return tls.ConnectionState{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var state tls.ConnectionState
|
||||
if resp.TLS != nil {
|
||||
state = *resp.TLS
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
if err != nil {
|
||||
return state, nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return state, nil, fmt.Errorf("HTTP %d at /dns-query", resp.StatusCode)
|
||||
}
|
||||
r := new(dns.Msg)
|
||||
if err := r.Unpack(body); err != nil {
|
||||
return state, nil, fmt.Errorf("not a DNS answer: %w", err)
|
||||
}
|
||||
return state, r, nil
|
||||
}
|
||||
|
||||
// describe says what a failed probe means.
|
||||
func describe(err error, e Endpoint) string {
|
||||
var ne net.Error
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED):
|
||||
return fmt.Sprintf("nothing listens on %s port %d: named may not have been able to bind it (look in named's log; ports below 1024 need named to start as root)", e.Address, e.Port)
|
||||
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Sprintf("no answer from %s port %d within %s (a firewall on the server?)", e.Address, e.Port, probeTimeout)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package encdns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func testCert(t *testing.T, names ...string) (tls.Certificate, *x509.Certificate) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{SerialNumber: big.NewInt(0xabc123), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf, _ := x509.ParseCertificate(der)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
|
||||
}
|
||||
|
||||
// answer answers every question with 192.0.2.53.
|
||||
var answer = dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
m.Answer = append(m.Answer, &dns.A{Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.53")})
|
||||
w.WriteMsg(m)
|
||||
})
|
||||
|
||||
func listeners(t *testing.T, cert tls.Certificate) (dot, doh Endpoint) {
|
||||
cfg := &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{Listener: ln, Net: "tcp-tls", Handler: answer}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
hl, err := tls.Listen("tcp", "127.0.0.1:0", cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hs := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/dns-query" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
q := new(dns.Msg)
|
||||
if q.Unpack(b) != nil {
|
||||
http.Error(w, "bad", 400)
|
||||
return
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(q)
|
||||
m.Rcode = dns.RcodeSuccess
|
||||
out, _ := m.Pack()
|
||||
w.Header().Set("Content-Type", "application/dns-message")
|
||||
w.Write(out)
|
||||
})}
|
||||
go hs.Serve(hl)
|
||||
t.Cleanup(func() { hs.Close() })
|
||||
port := func(l net.Listener) int { return l.Addr().(*net.TCPAddr).Port }
|
||||
return Endpoint{"127.0.0.1", port(ln)}, Endpoint{"127.0.0.1", port(hl)}
|
||||
}
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cert, leaf := testCert(t, "dns.example.com")
|
||||
dot, doh := listeners(t, cert)
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(leaf)
|
||||
st := Status{Enabled: true, DoT: []Endpoint{dot}, DoH: []Endpoint{doh}}
|
||||
|
||||
h := Check(ctx, st, "dns.example.com", "abc123", roots)
|
||||
if !h.OK() || len(h.Probes) != 2 {
|
||||
t.Fatalf("working setup: %+v", h.Probes)
|
||||
}
|
||||
for _, p := range h.Probes {
|
||||
if !p.Trusted || p.RCode != "NOERROR" || p.Warning != "" || p.CertSerial != "abc123" {
|
||||
t.Errorf("%s: %+v", p.Kind, p)
|
||||
}
|
||||
}
|
||||
// named hasn't reread a renewed certificate.
|
||||
if h := Check(ctx, st, "dns.example.com", "def456", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "older certificate") {
|
||||
t.Errorf("stale: %+v", h.Probes[0])
|
||||
}
|
||||
// The wrong name.
|
||||
if h := Check(ctx, st, "other.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "not other.example.com") {
|
||||
t.Errorf("wrong name: %+v", h.Probes[0])
|
||||
}
|
||||
// A CA this server doesn't trust: works, with a warning.
|
||||
if h := Check(ctx, st, "dns.example.com", "abc123", x509.NewCertPool()); !h.OK() || !strings.Contains(h.Probes[1].Warning, "staging") {
|
||||
t.Errorf("untrusted: %+v", h.Probes[1])
|
||||
}
|
||||
// Nothing listening.
|
||||
l, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
gone := l.Addr().(*net.TCPAddr).Port
|
||||
l.Close()
|
||||
if h := Check(ctx, Status{Enabled: true, DoT: []Endpoint{{"127.0.0.1", gone}}}, "dns.example.com", "", roots); h.OK() || !strings.Contains(h.Probes[0].Problem, "nothing listens") {
|
||||
t.Errorf("nothing listening: %+v", h.Probes[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// ViewSet is what one view has for a name and type.
|
||||
type ViewSet struct {
|
||||
View string `json:"view"`
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
|
||||
IDs []int64 `json:"ids"` // the records, in the same order
|
||||
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
|
||||
}
|
||||
|
||||
// CompareRow is one name and type across views.
|
||||
type CompareRow struct {
|
||||
Name string `json:"name"` // relative to the zone
|
||||
FQDN string `json:"fqdn"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []ViewSet `json:"views"` // in the order of the zones given
|
||||
}
|
||||
|
||||
// CompareZones lines up the records of zones of the same name in
|
||||
// different views (docs/views.md#linked-zones), one row per name and type,
|
||||
// sorted like a zone file.
|
||||
func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
|
||||
type key struct{ name, typ string }
|
||||
sets := make([]map[key][]*model.Record, len(zones))
|
||||
var keys []key
|
||||
seen := map[key]bool{}
|
||||
var all []*model.Record
|
||||
for i, z := range zones {
|
||||
sets[i] = map[key][]*model.Record{}
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
sets[i][k] = append(sets[i][k], r)
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
all = append(all, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(zones) > 0 {
|
||||
// Zone file order: the apex first, then by name and type.
|
||||
model.SortRecords(all, zones[0])
|
||||
keys = keys[:0]
|
||||
for _, r := range all {
|
||||
keys = append(keys, key{r.Name, r.Type})
|
||||
}
|
||||
}
|
||||
var out []CompareRow
|
||||
for _, k := range keys {
|
||||
row := CompareRow{FQDN: k.name, Type: k.typ, Same: true}
|
||||
if len(zones) > 0 {
|
||||
row.Name = relName(k.name, zones[0].Name)
|
||||
}
|
||||
var first string
|
||||
for i, z := range zones {
|
||||
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
|
||||
rs := slices.Clone(sets[i][k])
|
||||
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
for _, r := range rs {
|
||||
d := r.Data
|
||||
if r.TTL != nil {
|
||||
d = fmt.Sprintf("%s (TTL %d)", d, *r.TTL)
|
||||
}
|
||||
if r.Disabled {
|
||||
d += " (disabled)"
|
||||
}
|
||||
vs.Records = append(vs.Records, d)
|
||||
vs.IDs = append(vs.IDs, r.ID)
|
||||
}
|
||||
if z.LinkGroup != 0 && len(rs) > 0 {
|
||||
vs.Linked = "shared"
|
||||
if rs[0].SharedID == 0 {
|
||||
vs.Linked = "local"
|
||||
}
|
||||
}
|
||||
sig := strings.Join(vs.Records, "\n")
|
||||
if i == 0 {
|
||||
first = sig
|
||||
} else if sig != first {
|
||||
row.Same = false
|
||||
}
|
||||
row.Views = append(row.Views, vs)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func relName(name, zone string) string {
|
||||
if name == zone {
|
||||
return "@"
|
||||
}
|
||||
return strings.TrimSuffix(name, "."+zone)
|
||||
}
|
||||
+18
-11
@@ -801,9 +801,10 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
in.SharedID = &ids[0]
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
// local on a zone that isn't linked: its records are local anyway.
|
||||
in.Local = nil
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||
if ptr {
|
||||
@@ -834,10 +835,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
var pre []model.Op
|
||||
if in.Local != nil {
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
if z.LinkGroup == 0 && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
if want := *in.Local; want != (r.SharedID == 0) {
|
||||
if want := *in.Local; z.LinkGroup != 0 && want != (r.SharedID == 0) {
|
||||
// The whole set of this name and type changes sides.
|
||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||
id := int64(0)
|
||||
@@ -848,10 +849,11 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
}
|
||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||
}
|
||||
if !want {
|
||||
// Sharing a version makes it every view's.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
}
|
||||
if z.LinkGroup != 0 && !*in.Local {
|
||||
// Sharing a version makes it every view's, also when it is
|
||||
// shared already and another view overrides it.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
in.Local = nil
|
||||
if string(mustJSON(in)) == "{}" {
|
||||
@@ -1153,8 +1155,8 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
// Explicitly shared: this version becomes every view's.
|
||||
pre = x.dropOverrides(z, owner, typ)
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
} else if in.Local != nil && !*in.Local {
|
||||
return nil, notLinked(x.st, z)
|
||||
}
|
||||
in.Local = nil
|
||||
if len(canon) > 0 {
|
||||
@@ -1356,3 +1358,8 @@ func firstNonEmptyStr(a, b string) string {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// notLinked refuses sharing a record of a zone that isn't linked.
|
||||
func notLinked(st *model.State, z *model.Zone) error {
|
||||
return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z))
|
||||
}
|
||||
@@ -285,3 +285,59 @@ func TestLinkExisting(t *testing.T) {
|
||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||
}
|
||||
}
|
||||
|
||||
// --local on a zone that isn't linked is fine (its records are local
|
||||
// anyway); --shared there is refused.
|
||||
func TestLocalOnUnlinkedZone(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
local, shared := true, false
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("a"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.1"), Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "b", Type: "A", Data: []string{"10.0.0.2"}, Local: &local}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "a", "A"), RecordInput{Data: model.Ptr("10.0.0.3"), Local: &local}))
|
||||
if v := env.view("internal"); !slices.Contains(v, "a A 10.0.0.3 L") || !slices.Contains(v, "b A 10.0.0.2 L") {
|
||||
t.Errorf("records: %q", v)
|
||||
}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("c"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.4"), Local: &shared})); !strings.Contains(e.Message, "nothing to share") {
|
||||
t.Errorf("--shared on an unlinked zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := st.ZonesNamed("example.com.")
|
||||
st.SortByView(zones)
|
||||
diff := map[string][]string{}
|
||||
same := 0
|
||||
for _, row := range CompareZones(st, zones) {
|
||||
if row.Same {
|
||||
same++
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for _, vs := range row.Views {
|
||||
cells = append(cells, vs.View+"="+strings.Join(vs.Records, ",")+"/"+vs.Linked)
|
||||
}
|
||||
diff[row.Name+" "+row.Type] = cells
|
||||
}
|
||||
want := map[string][]string{
|
||||
"www A": {"internal=192.0.2.10/shared", "external=203.0.113.10/local"},
|
||||
"nas A": {"internal=10.0.0.20/local", "external=/"},
|
||||
}
|
||||
for k, v := range want {
|
||||
if !slices.Equal(diff[k], v) {
|
||||
t.Errorf("%s: %q, want %q", k, diff[k], v)
|
||||
}
|
||||
}
|
||||
if len(diff) != 2 || same < 3 {
|
||||
t.Errorf("differ %v, same %d", diff, same)
|
||||
}
|
||||
// "Use this version" on the shared one replaces external's override.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if v := env.view("external"); !slices.Contains(v, "www A 192.0.2.10 S") || slices.Contains(v, "www A 203.0.113.10 L") {
|
||||
t.Errorf("sharing the shared version: external %q", v)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -285,6 +286,20 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
||||
resp.TOTP = s.totpOverview(r.Context())
|
||||
resp.Certificates = s.certSummary(r.Context())
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||
if h := s.encryptedDNSHealth(); h != nil {
|
||||
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
|
||||
for _, pr := range h.Probes {
|
||||
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
|
||||
if pr.Problem != "" {
|
||||
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
|
||||
} else if pr.Warning != "" {
|
||||
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
|
||||
}
|
||||
}
|
||||
resp.EncryptedDNS = sum
|
||||
}
|
||||
}
|
||||
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
|
||||
resp.BlocklistErrors = s.blocklistErrors(r.Context())
|
||||
if st, err := s.store.LoadState(r.Context()); err == nil {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
|
||||
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
|
||||
// views (default: every view with a primary zone of that name), ?all=1
|
||||
// includes the names and types that are the same everywhere.
|
||||
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
// A name in several views is what there is to compare.
|
||||
ref := r.PathValue("zone")
|
||||
if ref == "root" {
|
||||
ref = "."
|
||||
}
|
||||
name, perr := engine.ParseZoneName(ref)
|
||||
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
|
||||
return err
|
||||
} else {
|
||||
z = zs[0]
|
||||
}
|
||||
}
|
||||
var want []string
|
||||
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
want = append(want, v)
|
||||
}
|
||||
}
|
||||
var zones []*model.Zone
|
||||
for _, o := range st.ZonesNamed(z.Name) {
|
||||
vn := st.ViewName(o)
|
||||
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 && !containsFold(want, vn) {
|
||||
continue
|
||||
}
|
||||
zones = append(zones, o)
|
||||
}
|
||||
st.SortByView(zones)
|
||||
if len(zones) < 2 {
|
||||
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||
}
|
||||
rows := engine.CompareZones(st, zones)
|
||||
all := queryBool(r, "all")
|
||||
out := struct {
|
||||
Zone string `json:"zone"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"` // all of them in one link
|
||||
Same int `json:"same"` // names and types the same everywhere
|
||||
Rows []engine.CompareRow `json:"rows"`
|
||||
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
|
||||
for _, o := range zones {
|
||||
out.Views = append(out.Views, st.ViewName(o))
|
||||
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Same {
|
||||
out.Same++
|
||||
if !all {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsFold(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if strings.EqualFold(x, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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 (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
@@ -43,6 +44,8 @@ type encStatus struct {
|
||||
// DoTHost and DoHURL are what to give clients.
|
||||
DoTHost string `json:"dot_host,omitempty"`
|
||||
DoHURL string `json:"doh_url,omitempty"`
|
||||
// Health is the last check of the listeners (every 10 minutes).
|
||||
Health *encdns.Health `json:"health,omitempty"`
|
||||
// For the setup form.
|
||||
Addresses []encAddress `json:"addresses"`
|
||||
Views []encView `json:"views"`
|
||||
@@ -104,6 +107,9 @@ func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
out.DoHURL += "/dns-query"
|
||||
}
|
||||
}
|
||||
if out.Enabled {
|
||||
out.Health = s.encryptedDNSHealth()
|
||||
}
|
||||
seen := map[netip.Addr]bool{}
|
||||
for _, a := range localAddrs() {
|
||||
a = a.Unmap()
|
||||
@@ -234,3 +240,81 @@ func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *
|
||||
}
|
||||
return s.mutate(w, r, p, "turn DNS over TLS and HTTPS off", reqs, nil)
|
||||
}
|
||||
|
||||
// encTarget returns the host name clients connect to and the serial of
|
||||
// the certificate Jarl last wrote for named.
|
||||
func (s *Server) encTarget(ctx context.Context, st encdns.Status) (host, serial string) {
|
||||
if s.cfg.Certs == nil || st.Certificate == "" {
|
||||
return "", ""
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(ctx, st.Certificate)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
for _, n := range append(c.Names, c.Domains...) {
|
||||
if !strings.HasPrefix(n, "*.") {
|
||||
host = n
|
||||
break
|
||||
}
|
||||
}
|
||||
return host, c.Serial
|
||||
}
|
||||
|
||||
// checkEncryptedDNS checks the DoT and DoH listeners as a client would and
|
||||
// keeps the result; a change for the worse is logged.
|
||||
func (s *Server) checkEncryptedDNS(ctx context.Context) *encdns.Health {
|
||||
if s.cfg.Certs == nil {
|
||||
return nil
|
||||
}
|
||||
state, err := s.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
st := encdns.GetStatus(state, s.cfg.Certs.FilesDir(""))
|
||||
var h *encdns.Health
|
||||
if st.Enabled {
|
||||
if host, serial := s.encTarget(ctx, st); host != "" {
|
||||
h = encdns.Check(ctx, st, host, serial, s.encRoots)
|
||||
}
|
||||
}
|
||||
s.encHealth.Lock()
|
||||
prev := s.encHealth.h
|
||||
s.encHealth.h = h
|
||||
s.encHealth.Unlock()
|
||||
if h != nil {
|
||||
for _, p := range h.Probes {
|
||||
if p.OK {
|
||||
continue
|
||||
}
|
||||
was := false
|
||||
if prev != nil {
|
||||
for _, q := range prev.Probes {
|
||||
was = was || q.Kind == p.Kind && q.Address == p.Address && q.Port == p.Port && !q.OK
|
||||
}
|
||||
}
|
||||
if !was {
|
||||
s.log.Warn("encrypted DNS isn't working", "kind", p.Kind, "address", p.Address, "port", p.Port, "problem", p.Problem)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *Server) encryptedDNSHealth() *encdns.Health {
|
||||
s.encHealth.Lock()
|
||||
defer s.encHealth.Unlock()
|
||||
return s.encHealth.h
|
||||
}
|
||||
|
||||
// encryptedDNSCheckNow: POST /encrypted-dns/health checks the listeners now.
|
||||
func (s *Server) encryptedDNSCheckNow(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.encReady(p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
h := s.checkEncryptedDNS(r.Context())
|
||||
if h == nil {
|
||||
return conflict("encrypted DNS isn't set up")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,20 @@ func (s *Server) collectState(ctx context.Context, pw *promWriter, now time.Time
|
||||
}
|
||||
}
|
||||
|
||||
if h := s.encryptedDNSHealth(); h != nil {
|
||||
for _, pr := range h.Probes {
|
||||
pw.gauge("jarl_encrypted_dns_up", "1 when the DNS over TLS or HTTPS listener answered with the right, current certificate at the last check (every 10 minutes).", b2f(pr.OK),
|
||||
"kind", pr.Kind, "address", pr.Address, "port", strconv.Itoa(pr.Port))
|
||||
}
|
||||
}
|
||||
|
||||
if 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 {
|
||||
|
||||
@@ -22,6 +22,13 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
if z.Type == model.ZonePrimary && z.ViewID != 0 {
|
||||
for _, o := range st.ZonesNamed(z.Name) {
|
||||
if o.ID != z.ID && o.Type == model.ZonePrimary {
|
||||
out.OtherViews = append(out.OtherViews, st.ViewName(o))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range st.LinkedWith(z) {
|
||||
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
|
||||
@@ -111,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
|
||||
|
||||
@@ -111,6 +111,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
@@ -128,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))
|
||||
@@ -176,8 +178,10 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns", s.authed(s.encryptedDNSSetup))
|
||||
m.HandleFunc("DELETE "+p+"/encrypted-dns", s.authed(s.encryptedDNSDisable))
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns/check", s.authed(s.encryptedDNSCheck))
|
||||
m.HandleFunc("POST "+p+"/encrypted-dns/health", s.authed(s.encryptedDNSCheckNow))
|
||||
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
|
||||
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
|
||||
m.HandleFunc("POST "+p+"/exposure/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))
|
||||
|
||||
@@ -4,6 +4,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -21,7 +22,9 @@ import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/encdns"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/mailcheck"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/stats"
|
||||
@@ -61,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
|
||||
@@ -90,6 +99,18 @@ type Server struct {
|
||||
// state part of /metrics.
|
||||
started time.Time
|
||||
metricsCache metricsCache
|
||||
// encHealth is the last check of encrypted DNS's listeners
|
||||
// (h_encdns.go); encRoots the CAs it trusts (nil: the system's).
|
||||
encHealth struct {
|
||||
sync.Mutex
|
||||
h *encdns.Health
|
||||
}
|
||||
encRoots *x509.CertPool
|
||||
// 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
|
||||
@@ -226,6 +247,15 @@ 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 {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(30 * time.Second):
|
||||
s.checkEncryptedDNS(ctx)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errc:
|
||||
@@ -300,6 +330,7 @@ func (s *Server) housekeeping(ctx context.Context) {
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
s.warnExpiringTokens(ctx, time.Now())
|
||||
s.checkEncryptedDNS(ctx)
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function render(el) {
|
||||
st.backup ? backupCard(st.backup) : null,
|
||||
exposure ? exposureCard(exposure) : null,
|
||||
st.certificates ? certCard(st.certificates) : null,
|
||||
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||||
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||||
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||||
);
|
||||
const last = s.last_applied;
|
||||
const parts = [h("div", { class: "page-head" },
|
||||
@@ -43,6 +46,9 @@ export async function render(el) {
|
||||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||||
}
|
||||
for (const p of (st.encrypted_dns || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg err" }, h("strong", null, "Encrypted DNS: "), p, " ", h("a", { href: "#/encrypted-dns" }, "Encrypted DNS")));
|
||||
}
|
||||
for (const c of (st.certificates || {}).problems || []) {
|
||||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// set up and what to give clients, and a setup that obtains the
|
||||
// certificate if needed, checks the ports and stages named's
|
||||
// configuration like any change.
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox } from "../ui.js";
|
||||
import { h, mount, field, input, badge, toast, errorBox, confirmDialog, copyBox, checkbox, fmtTime, relTime } from "../ui.js";
|
||||
import { api, get, change, describeResult, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
@@ -50,6 +50,7 @@ function statusPanel(s) {
|
||||
}
|
||||
return [
|
||||
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
|
||||
healthPanel(s.health),
|
||||
h("div", { class: "panel" }, h("h2", null, "For your clients"),
|
||||
h("p", { class: "muted small" }, "Clients check the certificate against the host name, so it must resolve to this server for them (Jarl added it to the private views you chose). Open the ports in the server's firewall for your networks only."),
|
||||
clients),
|
||||
@@ -223,3 +224,28 @@ async function disable() {
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
// healthPanel shows the last check of the listeners: Jarl connects to each
|
||||
// as a client would, checks the certificate and asks a question.
|
||||
function healthPanel(hl) {
|
||||
const kind = { dot: "DNS over TLS", doh: "DNS over HTTPS" };
|
||||
const out = h("div");
|
||||
const draw = (x) => {
|
||||
if (!x) return mount(out, h("div", { class: "muted" }, "Not checked yet: Jarl checks a minute after it starts, then every 10 minutes."));
|
||||
mount(out, h("ul", { class: "probes" }, x.probes.map((p) => h("li", null,
|
||||
badge(p.ok ? "working" : "not working", p.ok ? "ok" : "err"), " ",
|
||||
h("strong", null, kind[p.kind]), " ", h("span", { class: "mono" }, p.address + " port " + p.port),
|
||||
p.ok ? h("span", { class: "muted small" }, " answered " + p.rcode + " in " + p.ms + " ms" + (p.cert_not_after ? " · certificate until " + fmtTime(p.cert_not_after) : "")) : null,
|
||||
p.problem ? h("div", { class: "msg err small" }, p.problem) : null,
|
||||
p.warning ? h("div", { class: "msg warn small" }, p.warning) : null))),
|
||||
h("div", { class: "muted small" }, "Checked ", relTime(x.checked), "."));
|
||||
};
|
||||
draw(hl);
|
||||
const btn = h("button", { class: "small", onclick: async () => {
|
||||
btn.disabled = true;
|
||||
try { draw(await api("POST", "/encrypted-dns/health", { 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" }, "Health"), h("div", { class: "actions" }, btn)),
|
||||
h("p", { class: "muted small" }, "Jarl connects to each listener as a client would: the certificate must be for the host name, current, and the one Jarl last wrote; then it asks for the host name."),
|
||||
out);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function render(el, params, query) {
|
||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
||||
zone.other_views ? ["compare", "Compare views"] : null,
|
||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
@@ -64,6 +65,7 @@ export async function render(el, params, query) {
|
||||
else if (tab === "servers") await serversTab(body, zone);
|
||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||
else if (tab === "registrar") await registrarTab(body, zone);
|
||||
else if (tab === "compare") await compareTab(body, zone);
|
||||
// A later click (another tab) has taken over the page.
|
||||
if (seq !== renderSeq || !holder.isConnected) return;
|
||||
holder.replaceWith(body);
|
||||
@@ -71,6 +73,40 @@ export async function render(el, params, query) {
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
// ---- comparing a zone across views
|
||||
|
||||
// compareTab shows the names and types that differ between the views that
|
||||
// have the zone, with a button to share one view's version (linked zones).
|
||||
async function compareTab(el, zone) {
|
||||
let all = false;
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
||||
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
||||
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
||||
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
||||
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
||||
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
||||
onclick: () => share(vs, row) }, "Use this version") : null))));
|
||||
mount(out,
|
||||
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
||||
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
||||
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
||||
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
||||
};
|
||||
const share = async (vs, row) => {
|
||||
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
||||
try {
|
||||
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
||||
await draw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
await draw();
|
||||
mount(el, out);
|
||||
}
|
||||
|
||||
// ---- linked zones (docs/views.md#linked-zones)
|
||||
|
||||
// linkButton links the zone with the same zone in other views, or takes
|
||||
|
||||
@@ -557,3 +557,13 @@ header.top .header-right { display: flex; align-items: center; gap: 12px; margin
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
|
||||
/* Encrypted DNS: the health of each listener. */
|
||||
ul.probes { list-style: none; padding: 0; margin: 0 0 8px; }
|
||||
ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
ul.probes > li .msg { margin: 6px 0 0; }
|
||||
|
||||
/* Comparing a zone across views. */
|
||||
table.compare td.compare-cell { vertical-align: top; }
|
||||
table.compare td.compare-cell button { margin-top: 4px; }
|
||||
table.compare tr.muted td { color: var(--muted); }
|
||||
@@ -418,11 +418,20 @@ expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test
|
||||
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
||||
contains "linked with internal"
|
||||
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||
run "zone diff: what differs between the views" 0 $J zone diff adbraun.test
|
||||
contains "ns1 *A *127.0.0.1 \\[local, id [0-9]*\\] *203.0.113.1 \\[local, id [0-9]*\\]"
|
||||
contains "names and types differ, [0-9]* are the same; linked"
|
||||
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&all=1"
|
||||
contains '"linked":true'
|
||||
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
||||
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||
run "unlinked: no link column" 0 $J record list adbraun.test --view external
|
||||
not_contains "shared"
|
||||
run "--local on a zone that isn't linked is fine" 0 $J record add adbraun.test solo A 10.9.9.9 --view external --local
|
||||
run "--shared there is refused (exit 6)" 6 $J record add adbraun.test solo2 A 10.9.9.8 --view external --shared
|
||||
contains "nothing to share"
|
||||
run "remove it" 0 $J record delete adbraun.test solo A --view external -y
|
||||
run "zone create --link-with: a linked copy" 0 $J zone create linkcopy.test --view internal --ns ns1.adbraun.test.
|
||||
run "a record in it" 0 $J record add linkcopy.test www A 192.0.2.80 --view internal
|
||||
run "copy it into external, linked" 0 $J zone create linkcopy.test --view external --link-with internal
|
||||
@@ -444,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):
|
||||
@@ -792,6 +808,13 @@ if [ -n "$PEBBLE" ]; then
|
||||
contains "^192.168.1.20$"
|
||||
run "plain DNS still answers on port 53's listener" 0 $DIG +short -p $DNS @127.0.0.1 nas.adbraun.test A
|
||||
contains "^192.168.1.20$"
|
||||
run "encrypted-dns health: both listeners work" 0 $J encrypted-dns health
|
||||
contains "DoT 127.0.0.1 port $EDOT: *working (NOERROR"
|
||||
contains "DoH 127.0.0.1 port $EDOH: *working"
|
||||
contains "isn't from a CA this server trusts"
|
||||
sleep 16 # /metrics caches Jarl's state for 15 seconds
|
||||
run "health metric" 0 curl -sf http://127.0.0.1:$HTTP/metrics
|
||||
contains "^jarl_encrypted_dns_up{kind=\"dot\",address=\"127.0.0.1\",port=\"$EDOT\"} 1$"
|
||||
run "a certificate named uses can't be deleted (exit 6)" 6 $J cert delete ns1 --yes
|
||||
contains "take it out first"
|
||||
run "encrypted-dns disable" 0 $J encrypted-dns disable
|
||||
|
||||
Reference in new issue
Block a user