Compare commits

...
11 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 d33f573782 Release 0.5.1
release / release (push) Successful in 2m6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:46:57 +01:00
adamandClaude Opus 5.5 13ffd8cad4 Encrypted DNS health, comparing a zone's views, --local everywhere
jarld checks the DoT and DoH listeners every 10 minutes as a client would (page, dashboard, jarl encrypted-dns health, jarl_encrypted_dns_up). The Compare views tab and jarl zone diff show what differs between a zone's views, with Use this version. --local works on unlinked zones. A changed certificates.dir is followed by encrypted DNS's tls statements. Sharing an already shared version replaces other views' overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:46:56 +01:00
adamandClaude Opus 5.5 67ae0091b3 Release 0.5.0
release / release (push) Successful in 2m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:27:25 +01:00
adamandClaude Opus 5.5 c7e5bbb628 Release 0.5.0-rc.1
release / release (push) Successful in 2m4s
0.4.1's release candidates grew into 0.5.0: exposure checks, certificates, linked zones, encrypted DNS and per-view statistics are new features. The upgrade from 0.4.0 packages was tested in a Debian 13 container. The CLI's filtered stats summary says answers for the busiest minute too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:19:38 +01:00
adamandClaude Opus 5.5 740f3cc328 Release 0.4.1-rc.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 5fcf4082c9 Web UI: Server and account menus keep the top bar on one line
Server options, ACLs, TSIG keys, Exposure, Certificates, Encrypted DNS and the audit log are in a Server menu, Account, Users and Log out under the user's name; the right-hand group stays together when it wraps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 1cec6cc393 Fix "update PTR": add a missing PTR, also in linked views
Ticking update PTR alone did nothing: the web UI didn't send it, and the engine only acted when the name or address changed. Alone it now adds a missing PTR record (or enables it); for linked zones every linked view's reverse zone counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:06:18 +01:00
adamandClaude Opus 5.5 921bdd6c95 Release 0.4.1-rc.5
release / release (push) Successful in 2m3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00
adamandClaude Opus 5.5 12820b17bb Statistics per view: toggle views on the Statistics page
Answers, result codes, record types, zones, blocking, rate limiting and the top lists are also counted per view, from named's response log. The Statistics page has a toggle per view with All and None, remembered per browser; jarl stats --view and ?views= on the API. named's own counters stay for the whole server, and the page says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:29:33 +01:00
adamandClaude Opus 5.5 ead749dd3d Release 0.4.1-rc.4
release / release (push) Successful in 2m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00
adamandClaude Opus 5.5 66dcf53684 DNS over TLS and HTTPS set up in one step
jarl encrypted-dns setup (and the Encrypted DNS page) finds or obtains a
certificate for the host name, checks that nothing else listens on the
DoT and DoH ports at the chosen addresses (offering a free DoH port), and
stages named's tls statement and listeners as one change, keeping port 53
where it was when there was no listen-on, plus the host name's records in
the private views (local to the view in a linked zone). Status shows what
to give Android, Linux, Firefox, Chrome and Windows clients. A tls
statement with missing files is refused (tls_file_missing), and a
certificate named uses can't be deleted. docs/encrypted-dns.md, e2e with
real DoT and DoH queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:09:14 +01:00
51 changed files with 3720 additions and 119 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@
*.db
*.db-wal
*.db-shm
/jarl
/jarld
+77
View File
@@ -6,8 +6,50 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased]
## [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,
linked zones), applied when jarld starts; a 0.4 jarld can't open it
afterwards, so take a backup first (`sudo jarld backup`). Backups made
by 0.4 restore into this version. Tested: 0.4.0 packages with views,
zones, records, blocking and statistics, upgraded in place and
restored from a 0.4.0 backup.
- Statistics per view start with this version: earlier figures are for
all views together.
- New exposure warnings (docs/exposure.md) appear for views reachable from
the internet, e.g. "no rate-limit" or "version visible" on an external
view: fix them from the Exposure page, or acknowledge them. A change
@@ -18,8 +60,25 @@ All notable changes to Jarl are documented here. The format follows
parser doesn't know them. Remove the ranges first if you need to
downgrade.
### Changed
- The web UI's top bar had grown too long, and the user name, Users and
Log out ended up on a second line on the left. The server's
configuration and security pages (Server options, ACLs, TSIG keys,
Exposure, Certificates, Encrypted DNS, Audit log) are now in a
**Server** menu, and Account, Users and Log out in a menu under your
name. On narrow screens the search box and your name stay together on
the right.
### Fixed
- "update PTR" when editing a record (`jarl record update ID --ptr`) did
nothing unless the name or address changed too, and the web UI didn't
even send it alone: a reverse zone created after the record never got
its PTR. Alone, it now adds the PTR record if it is missing (or enables
it). For records of linked zones, the reverse zones of every linked view
count, not only the record's own view.
- Option values in the web UI looked different from the editor: a long
line wrapped back to the left edge, losing its indentation, and the
column changed width when Edit opened. Values now keep their layout
@@ -40,6 +99,24 @@ All notable changes to Jarl are documented here. The format follows
### Added
- Statistics per view ([docs/stats.md](docs/stats.md#views)): the
Statistics page has a toggle per view, with All and None, remembered in
the browser, to see some views only or several together (linked views,
say). Answers, result codes, record types, zones, blocking, rate
limiting and the top lists follow it; named's own counters stay for the
whole server, and the page says so. `jarl stats --view`, API
`?views=`. Per-view figures start with this version.
- DNS over TLS and HTTPS ([docs/encrypted-dns.md](docs/encrypted-dns.md)):
`jarl encrypted-dns setup --hostname NAME --address ADDR` (and the
Encrypted DNS page) obtains a certificate for the host name if needed,
checks that nothing else listens on port 853 and the DoH port (443 by
default) at those addresses, offering a free port, and stages named's
`tls` statement and listeners as one change, keeping port 53 where it
was, plus the host name's records in your private views (local to the
view in a linked zone). The page and `jarl encrypted-dns` say what to
give Android, Linux, Firefox, Chrome and Windows. A `tls` statement whose
files are missing is refused (`tls_file_missing`), and a certificate
named uses can't be deleted.
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
same name in different views can be linked, and Jarl keeps their shared
records in step: a change in one view reaches the others in the same
+9 -5
View File
@@ -97,6 +97,9 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
serves the domain, decided at every renewal. Use them for the web UI
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
rereads them on renewal), or bring your own.
* **DNS over TLS and HTTPS** for your own devices in one step: the
certificate, named's listeners on the addresses you choose (after
checking the ports are free) and the host name in your private views.
* **Linked zones for split DNS**: the same zone in several views, with the
shared records kept in step by Jarl and a few records of each view's own
(an internal address for a public name, say).
@@ -141,6 +144,7 @@ in `/etc/jarl/jarl.yaml` to reach it from your management network.
| [docs/views.md](docs/views.md) | Split DNS: adding views to a server that has none |
| [docs/dnssec.md](docs/dnssec.md) | DNSSEC signing, going public with your own name servers, Porkbun |
| [docs/acme.md](docs/acme.md) | Let's Encrypt DNS-01 with certbot, acme.sh, lego and Traefik |
| [docs/encrypted-dns.md](docs/encrypted-dns.md) | DNS over TLS and HTTPS for your clients: setup, ports, client settings |
| [docs/certificates.md](docs/certificates.md) | TLS certificates Jarl obtains and renews for its web UI and named's DNS over TLS/HTTPS |
| [docs/stats.md](docs/stats.md) | Query statistics: enabling, what is collected, retention |
| [docs/exposure.md](docs/exposure.md) | Internet exposure: which views are public, hardening checks, response rate limiting |
@@ -196,11 +200,11 @@ review step.
## Status
Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.1-rc.3): 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, and linked zones for split DNS. 0.4.0 added blocking (blocklists and
Version 0.5.1 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
+1 -1
View File
@@ -1 +1 @@
0.4.1-rc.3
0.5.1
+409
View File
@@ -0,0 +1,409 @@
package main
import (
"errors"
"fmt"
"io"
"net/url"
"strings"
"time"
"github.com/spf13/cobra"
)
type encEndpoint struct {
Address string `json:"address"`
Port int `json:"port"`
}
type encStatus struct {
Enabled bool `json:"enabled"`
Certificate string `json:"certificate"`
Hostname string `json:"hostname"`
DoTHost string `json:"dot_host"`
DoHURL string `json:"doh_url"`
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"`
} `json:"addresses"`
Views []struct {
Name string `json:"name"`
Public bool `json:"public"`
HasZone bool `json:"has_zone"`
} `json:"views"`
Certificates []struct {
Name string `json:"name"`
Names []string `json:"names"`
Domains []string `json:"domains"`
DNS bool `json:"dns"`
Obtained bool `json:"obtained"`
} `json:"certificates"`
}
type encConflict struct {
Address string `json:"address"`
Port int `json:"port"`
What string `json:"what"`
}
func endpoints(es []encEndpoint) string {
var out []string
for _, e := range es {
out = append(out, fmt.Sprintf("%s port %d", e.Address, e.Port))
}
return strings.Join(out, ", ")
}
func encryptedDNSCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "encrypted-dns",
Aliases: []string{"encdns"},
Short: "DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md)",
Long: `Shows whether named serves DNS over TLS (DoT) and DNS over HTTPS (DoH),
where, and what to give clients. 'setup' sets it up: a certificate for the
host name (obtained if needed), named's tls statement and listeners on the
addresses you choose, and the host name's address records in your private
views.`,
Example: ` jarl encrypted-dns
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --no-doh
jarl encrypted-dns check --address 10.0.10.5 --doh-port 443
jarl encrypted-dns disable`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", nil, &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
if !s.Enabled {
fmt.Fprintln(w, "Encrypted DNS:\toff (jarl encrypted-dns setup --hostname NAME --address ADDR)")
} else {
fmt.Fprintf(w, "Certificate:\t%s\n", s.Certificate)
if len(s.DoT) > 0 {
fmt.Fprintf(w, "DNS over TLS:\t%s\n", endpoints(s.DoT))
}
if len(s.DoH) > 0 {
fmt.Fprintf(w, "DNS over HTTPS:\t%s\n", endpoints(s.DoH))
}
if s.DoTHost != "" {
fmt.Fprintf(w, "For DoT clients:\t%s (Android: Private DNS hostname; Linux: DNS=ADDRESS#%s, DNSOverTLS=yes)\n", s.DoTHost, s.Hostname)
}
if s.DoHURL != "" {
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")
}
var addrs []string
for _, a := range s.Addresses {
t := a.Address
if !a.Private {
t += " (public)"
}
addrs = append(addrs, t)
}
fmt.Fprintf(w, "This server's addresses:\t%s\n", strings.Join(addrs, ", "))
})
},
}
var host, certName string
var addrs, views []string
var noDoT, noDoH bool
var dohPort, dotPort int
setup := &cobra.Command{
Use: "setup --hostname NAME --address ADDR...",
Short: "Set up DNS over TLS and HTTPS (obtaining a certificate if needed)",
Long: `Sets up DoT (port 853) and DoH (port 443, or --doh-port) on the addresses
given, which should be this server's addresses on your own networks:
- the certificate: --certificate, or one with the dns use whose names
cover the host name, or a new one Jarl obtains for it (and waits for);
- a check that nothing else listens on those ports there;
- named's tls statement and listen-on statements (and an explicit
listen-on { any; } when there was none, as the first listen-on would
otherwise stop named listening on port 53 everywhere);
- the host name's A and AAAA records, in the private views that have its
zone (or --view), local to the view when the zone is linked.
It is one change, staged or applied like any other. Running it again
replaces the earlier setup.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
host = strings.ToLower(strings.TrimSuffix(host, "."))
if host == "" {
return errors.New("give --hostname, the name clients connect to (e.g. dns.adbraun.lol)")
}
var s encStatus
if err := c.Get(ctx(), "/encrypted-dns", q("hostname", host), &s); err != nil {
return err
}
if len(addrs) == 0 {
var list []string
for _, a := range s.Addresses {
list = append(list, a.Address)
}
return fmt.Errorf("give --address (this server's addresses on your own networks); it has %s", strings.Join(list, ", "))
}
// The certificate.
if certName == "" {
for _, ci := range s.Certificates {
if coversName(append(ci.Names, ci.Domains...), host) {
certName = ci.Name
break
}
}
}
if certName == "" {
certName = strings.SplitN(host, ".", 2)[0]
fmt.Printf("Creating certificate %s for %s\n", certName, host)
var r struct {
Certificate certInfo `json:"certificate"`
}
if err := c.Do(ctx(), "POST", "/certificates", nil, map[string]any{"name": certName, "domains": []string{host}, "uses": []string{"dns"}}, &r); err != nil {
return err
}
if err := waitIssue(c, certName); err != nil {
return err
}
} else {
var ci certInfo
if err := c.Get(ctx(), certPath(certName), nil, &ci); err != nil {
return err
}
if !contains(ci.Uses, "dns") {
fmt.Printf("Adding the dns use to certificate %s\n", certName)
if err := c.Do(ctx(), "PATCH", certPath(certName), nil, map[string]any{"uses": append(ci.Uses, "dns")}, nil); err != nil {
return err
}
}
if ci.NotAfter == nil {
if ci.IssuingSince == nil {
if err := c.Do(ctx(), "POST", certPath(certName)+"/issue", nil, map[string]any{}, nil); err != nil {
return err
}
}
if err := waitIssue(c, certName); err != nil {
return err
}
}
}
body := map[string]any{"hostname": host, "certificate": certName, "addresses": addrs, "dot": !noDoT, "doh": !noDoH}
if cmd.Flags().Changed("doh-port") {
body["doh_port"] = dohPort
}
if cmd.Flags().Changed("dot-port") {
body["dot_port"] = dotPort
}
// Nothing else may listen there.
var chk struct {
Conflicts []encConflict `json:"conflicts"`
FreeDoHPort int `json:"free_doh_port"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, body, &chk); err != nil {
return err
}
if len(chk.Conflicts) > 0 {
var msgs []string
for _, cf := range chk.Conflicts {
msgs = append(msgs, fmt.Sprintf("%s listens on %s port %d", cf.What, cf.Address, cf.Port))
}
hint := ""
if chk.FreeDoHPort != 0 && !noDoH {
hint = fmt.Sprintf("; for DoH, port %d is free there (--doh-port %d)", chk.FreeDoHPort, chk.FreeDoHPort)
}
return fmt.Errorf("%s%s", strings.Join(msgs, "; "), hint)
}
if chk.Error != "" && !flagQuiet {
fmt.Println("Note:", chk.Error)
}
if len(views) == 0 {
for _, v := range s.Views {
if !v.Public && v.HasZone {
views = append(views, v.Name)
}
}
}
body["views"] = views
resp, err := c.Mutate(ctx(), "POST", "/encrypted-dns", nil, body)
if err != nil {
return err
}
return report(resp)
},
}
f := setup.Flags()
f.StringVar(&host, "hostname", "", "the name clients connect to (the certificate's)")
f.StringVar(&certName, "certificate", "", "the Jarl certificate to serve (default: one for the host name, obtained if there is none)")
f.StringArrayVar(&addrs, "address", nil, "this server's address to listen on (repeat for several)")
f.BoolVar(&noDoT, "no-dot", false, "no DNS over TLS")
f.BoolVar(&noDoH, "no-doh", false, "no DNS over HTTPS")
f.IntVar(&dohPort, "doh-port", 443, "port for DNS over HTTPS")
f.IntVar(&dotPort, "dot-port", 853, "port for DNS over TLS (clients expect 853)")
f.StringArrayVar(&views, "view", nil, "views to add the host name's records to (default: the private views that have its zone)")
var cAddrs []string
var cDoH int
check := &cobra.Command{
Use: "check --address ADDR...",
Short: "See whether something already listens on the DoT and DoH ports",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var r struct {
Conflicts []encConflict `json:"conflicts"`
FreeDoHPort int `json:"free_doh_port"`
Error string `json:"error"`
}
if err := c.Do(ctx(), "POST", "/encrypted-dns/check", nil, map[string]any{"addresses": cAddrs, "dot": true, "doh": true, "doh_port": cDoH}, &r); err != nil {
return err
}
return output(r, func(w io.Writer) {
if r.Error != "" {
fmt.Fprintln(w, r.Error)
}
if len(r.Conflicts) == 0 {
fmt.Fprintln(w, "The ports are free.")
}
for _, cf := range r.Conflicts {
fmt.Fprintf(w, "%s port %d:\t%s listens there\n", cf.Address, cf.Port, cf.What)
}
if r.FreeDoHPort != 0 {
fmt.Fprintf(w, "Free for DoH:\tport %d\n", r.FreeDoHPort)
}
})
},
}
check.Flags().StringArrayVar(&cAddrs, "address", nil, "address to check (repeat for several)")
check.Flags().IntVar(&cDoH, "doh-port", 443, "port for DNS over HTTPS")
disable := &cobra.Command{
Use: "disable",
Short: "Turn DNS over TLS and HTTPS off (the certificate and records stay)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "DELETE", "/encrypted-dns", url.Values{}, nil)
if err != nil {
return err
}
return report(resp)
},
}
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
}
// coversName reports whether certificate names cover host (a wildcard
// covers one label).
func coversName(names []string, host string) bool {
for _, n := range names {
n = strings.ToLower(strings.TrimSuffix(n, "."))
if n == host {
return true
}
if base, ok := strings.CutPrefix(n, "*."); ok {
if _, rest, ok := strings.Cut(host, "."); ok && rest == base {
return true
}
}
}
return false
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
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
}
+1 -1
View File
@@ -259,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
}
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
add("changes", applyCmd(), changesetCmd(), historyCmd())
add("admin", statusCmd(), exposureCmd(), certCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
add("admin", statusCmd(), exposureCmd(), certCmd(), encryptedDNSCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
setupCompletion(root)
return root
}
+37 -9
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"math"
"net/url"
"slices"
"strconv"
"strings"
@@ -21,8 +22,22 @@ func statsCmd() *cobra.Command {
Long: `Query statistics collected from named (see docs/stats.md). They must be
enabled in /etc/jarl/jarl.yaml (stats.enabled: true).
Ranges are given with --since: 90m, 24h, 7d, 2w. Use -o json or -o yaml to
export the data.`,
Ranges are given with --since: 90m, 24h, 7d, 2w. --view limits the figures
to some views (repeat it, or separate with commas): answers from named's
response log, per view; named's own counters stay for the whole server.
Use -o json or -o yaml to export the data.`,
}
var statsViews []string
cmd.PersistentFlags().StringSliceVar(&statsViews, "view", nil, "only these views (repeat, or comma separated)")
// withViews adds the --view filter to a query.
withViews := func(v url.Values) url.Values {
if len(statsViews) > 0 {
if v == nil {
v = url.Values{}
}
v.Set("views", strings.Join(statsViews, ","))
}
return v
}
// Each command has its own --since variable: their defaults differ, and
// a shared variable would take the last default for all of them.
@@ -77,14 +92,27 @@ export the data.`,
return err
}
var s stats.Summary
if err := c.Get(ctx(), "/stats/summary", q("since", summarySince, "top", strconv.Itoa(topN)), &s); err != nil {
if err := c.Get(ctx(), "/stats/summary", withViews(q("since", summarySince, "top", strconv.Itoa(topN))), &s); err != nil {
return err
}
return output(s, func(w io.Writer) {
fmt.Fprintf(w, "Range:\t%s to %s\n", s.From.Local().Format("2006-01-02 15:04"), s.To.Local().Format("2006-01-02 15:04"))
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
if len(s.FilterViews) > 0 {
since := ""
if s.ViewsSince != nil {
since = ", per view since " + s.ViewsSince.Local().Format("2006-01-02 15:04")
}
fmt.Fprintf(w, "Views:\t%s (from the response log%s; named's own counters are left out)\n", strings.Join(s.FilterViews, ", "), since)
fmt.Fprintf(w, "Answers:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
} else {
fmt.Fprintf(w, "Queries:\t%d (%.2f per second on average)\n", s.Queries, s.AvgQPS)
}
if s.Peak != nil {
fmt.Fprintf(w, "Busiest %s:\t%s with %d queries\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries)
what := "queries"
if len(s.FilterViews) > 0 {
what = "answers"
}
fmt.Fprintf(w, "Busiest %s:\t%s with %d %s\n", s.Peak.Per, s.Peak.Time.Local().Format("2006-01-02 15:04"), s.Peak.Queries, what)
}
if s.CacheHitRatio != nil {
fmt.Fprintf(w, "Cache hit ratio:\t%.1f%%\n", *s.CacheHitRatio*100)
@@ -157,7 +185,7 @@ export the data.`,
return err
}
var es []stats.Entry
if err := c.Get(ctx(), "/stats/top", q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit)), &es); err != nil {
if err := c.Get(ctx(), "/stats/top", withViews(q("kind", kind, "since", topSince, "limit", strconv.Itoa(limit))), &es); err != nil {
return err
}
return output(es, func(w io.Writer) {
@@ -207,7 +235,7 @@ export the data.`,
qv.Add("key", k)
}
var tl stats.Timeline
if err := c.Get(ctx(), "/stats/timeline", qv, &tl); err != nil {
if err := c.Get(ctx(), "/stats/timeline", withViews(qv), &tl); err != nil {
return err
}
return output(tl, func(w io.Writer) {
@@ -256,7 +284,7 @@ export the data.`,
return err
}
var hm stats.Heatmap
if err := c.Get(ctx(), "/stats/heatmap", q("since", heatmapSince, "key", hmKey), &hm); err != nil {
if err := c.Get(ctx(), "/stats/heatmap", withViews(q("since", heatmapSince, "key", hmKey)), &hm); err != nil {
return err
}
return output(hm, func(w io.Writer) {
@@ -308,7 +336,7 @@ export the data.`,
return err
}
var es []stats.Entry
if err := c.Get(ctx(), "/stats/breakdown", q("group", args[0], "since", breakdownSince), &es); err != nil {
if err := c.Get(ctx(), "/stats/breakdown", withViews(q("group", args[0], "since", breakdownSince)), &es); err != nil {
return err
}
return output(es, func(w io.Writer) {
+88 -1
View File
@@ -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
}
+10
View File
@@ -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.
@@ -539,6 +540,10 @@ in it and its records.
### Query statistics
Every statistics endpoint below takes `views=internal,vps` to limit the
figures to those views ([stats.md](stats.md#views)); a breakdown that only
exists for the whole server then answers `400 not_per_view`.
Needs `stats:read`. When statistics are disabled, every endpoint but
`status` answers 404 `stats_disabled`. See [stats.md](stats.md).
@@ -556,6 +561,11 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
curl -sf -H "$AUTH" "$API/stats/top?kind=nxdomain&since=7d&limit=10" | jq -r '.[] | "\(.count)\t\(.key)"'
```
### Encrypted DNS
See [encrypted-dns.md](encrypted-dns.md#api): `GET`, `POST` and `DELETE
/encrypted-dns`, `POST /encrypted-dns/check`.
### Certificates
Administrators only. See [certificates.md](certificates.md#api).
+9 -5
View File
@@ -183,16 +183,20 @@ configuration names them, Jarl runs `rndc reconfig`, and named serves the
new certificate at once (checked with BIND 9.20: DoT keeps working, with
the new certificate).
Until Jarl sets up DNS over TLS and HTTPS for you, add it by hand:
`jarl encrypted-dns setup` sets up DNS over TLS and HTTPS with it
([encrypted-dns.md](encrypted-dns.md)). By hand, a `tls` statement names
the files:
```sh
jarl option set tls 'dot { cert-file "/etc/bind/jarl/tls/ns1/fullchain.pem"; key-file "/etc/bind/jarl/tls/ns1/privkey.pem"; }' --toplevel
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add # DoT
jarl option set listen-on 'port 443 tls dot http default { 10.0.10.5; }' --add # DoH at /dns-query
jarl option set listen-on 'port 853 tls dot { 10.0.10.5; }' --add
```
Listen on internal addresses only, unless you mean to run a public
resolver ([exposure.md](exposure.md)).
A certificate named uses can't be deleted or lose its `dns` use until
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
+19 -1
View File
@@ -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
@@ -307,7 +308,9 @@ adds the quotes and splits long values into 255-byte strings. In reverse zones t
host name (a bare `nas` is refused, since it would mean
`nas.0.10.10.in-addr.arpa.`). `--ptr`
keeps the PTR record in the reverse zone in step (when that zone is managed
by Jarl). Every record type BIND supports can be used. Jarl knows the
by Jarl); `jarl record update ID --ptr` with nothing else adds the PTR record
if it is missing, e.g. after creating the reverse zone. In linked zones, the
reverse zones of every linked view count. Every record type BIND supports can be used. Jarl knows the
syntax of the common ones and checks them strictly, and the rest are
validated by parsing.
@@ -409,6 +412,18 @@ jarl view update external --exposure public|private|auto
See [exposure.md](exposure.md).
## Encrypted DNS
```sh
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
```
See [encrypted-dns.md](encrypted-dns.md).
## Certificates
```sh
@@ -517,6 +532,9 @@ working.
## Query statistics
`--view internal,vps` (or repeated) limits any `jarl stats` command to those
views ([stats.md](stats.md#views)).
```sh
jarl stats status
jarl stats summary --since 7d
+157
View File
@@ -0,0 +1,157 @@
# DNS over TLS and HTTPS
named (BIND 9.20) can answer over encrypted connections itself: DNS over
TLS (DoT, port 853) and DNS over HTTPS (DoH, at `/dns-query`). Your
phones, laptops and browsers then ask this server without anyone on the
network seeing or changing their queries, and they get exactly what plain
DNS gives them: the same views, the same records and the same blocking.
Jarl sets it up in one step: a certificate for the host name clients
connect to (obtained and renewed by Jarl, [certificates.md](certificates.md)),
named's `tls` statement and listeners on the addresses you choose, and the
host name's address records in your private views.
In the web UI: **Encrypted DNS** (administrators). In the CLI: `jarl
encrypted-dns`. In the API: `/api/v1/encrypted-dns`.
## Setting it up
```sh
jarl encrypted-dns setup --hostname dns.adbraun.lol --address 10.0.10.5 --address 2a02:8013:7f3d:10::5
```
This:
1. **finds or obtains the certificate**: one Jarl manages whose names cover
the host name (`--certificate NAME` to pick one), or a new one named
after the host name's first label, which Jarl obtains from the CA (the
command waits). The certificate gets the `dns` use, so it is written
where named can read it, and named rereads it on every renewal.
2. **checks the ports**: nothing else may listen on the chosen addresses
on port 853 or the DoH port. Jarl looks at the host's listening sockets
(it binds nothing) and says what is in the way, e.g. `jarld (Jarl's web
UI and API) listens on 10.0.10.5 port 443`, and which port is free
instead (`--doh-port 8443`).
3. **stages named's configuration**, as one change you can review:
```
tls jarl-dns { cert-file ".../tls/dns/fullchain.pem"; key-file ".../tls/dns/privkey.pem"; protocols { TLSv1.2; TLSv1.3; }; };
options {
listen-on port 853 tls jarl-dns { 10.0.10.5; };
listen-on port 443 tls jarl-dns http default { 10.0.10.5; };
listen-on-v6 port 853 tls jarl-dns { 2a02:8013:7f3d:10::5; };
...
```
named listens on port 53 on every address only while there is no
`listen-on` statement at all. The first one, for DoT, would end that, so
when you have none the setup adds `listen-on { any; };` (and the same
for IPv6) to keep plain DNS where it was.
4. **adds the host name's A and AAAA records** with the chosen addresses,
in the private views that have a zone for it (`--view` to choose). In a
linked zone they are local to the view, so the name doesn't reach your
external view.
Running it again replaces the earlier setup (other addresses, ports, DoT
or DoH only with `--no-doh` / `--no-dot`). `jarl encrypted-dns disable`
takes the listeners and the `tls` statement out; the certificate and the
records stay.
| Option | Default | |
|--|--|--|
| `--hostname` | | The name clients connect to; the certificate is for it |
| `--address` | | This server's addresses to listen on (repeat). Choose your own networks' addresses: a listener on a public address answers anyone who reaches it, like port 53 there |
| `--certificate` | one for the host name, obtained if needed | A certificate Jarl manages |
| `--no-dot`, `--no-doh` | both on | |
| `--dot-port` | 853 | Clients expect 853 |
| `--doh-port` | 443 | Any free port; clients take it in the URL |
| `--view` | private views with the zone | Where the host name's records go |
In the web UI the same form checks the ports as you choose, offers the
free port, and obtains the certificate before setting up.
## Which view clients get
A DoT or DoH client gets the view its address matches, as over plain DNS:
your phone at home gets the internal view, with its records and blocking.
Choosing a view by key (`match-clients { key ...; }`) doesn't work over
DoT and DoH, as clients don't sign their queries.
## Clients
`jarl encrypted-dns` (and the page) shows what to give them:
```
For DoT clients: dns.adbraun.lol
For DoH clients: https://dns.adbraun.lol/dns-query
```
| Client | Where |
|--|--|
| Android 9+ | Settings → Network → Private DNS → Private DNS provider hostname: `dns.adbraun.lol` (DoT) |
| Linux (systemd-resolved) | `/etc/systemd/resolved.conf`: `DNS=10.0.10.5#dns.adbraun.lol` and `DNSOverTLS=yes` (or `opportunistic`, which falls back to plain DNS) |
| Firefox | Settings → Privacy & Security → DNS over HTTPS → Increased protection → Custom: the DoH URL |
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
Test from a machine on your network:
```sh
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com
dig +https @10.0.10.5 example.com
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
```
**Android away from home.** Private DNS in hostname mode applies on every
network. Away from home, your server can't be reached, and Android then
resolves nothing at all ("Private DNS server cannot be accessed"). Use a
VPN home (WireGuard) when you're out, or switch Private DNS to
*Automatic* when you leave. Firefox's *Increased protection* and
systemd-resolved's `opportunistic` fall back to normal DNS by themselves.
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
(`tls_file_missing`): named won't load its configuration without them.
* A certificate named uses can't be deleted, nor lose its `dns` use: turn
encrypted DNS off first.
* Listeners you wrote yourself are left alone; the page notes them.
## API
| Method | Path | Body |
|--------|------|------|
| GET | `/encrypted-dns` | `enabled`, `certificate`, `hostname`, `dot` and `doh` (address and port), `dot_host`, `doh_url`, `listeners`; for the form: `addresses` (this server's, with `private`), `views` (with `public`, and `has_zone` for `?hostname=`), `certificates` |
| 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` |
+6
View File
@@ -132,6 +132,7 @@ same values.
| `jarl_certificate_expiry_timestamp_seconds{certificate,source}` | When each certificate Jarl manages expires ([certificates.md](certificates.md)). |
| `jarl_certificate_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_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 +233,11 @@ 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: BlockingPausedForLong
expr: jarl_blocking_paused == 1
for: 24h
+20
View File
@@ -184,6 +184,26 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
`operator` roles include it, `dns-editor` doesn't. Statistics show client
addresses and what they look up, so grant it with care.
## Views
With views, the Statistics page has a toggle per view (and **All** and
**None**): switch some off to see only the others, for example your
internal and VPS views without the internet's noise on the external one,
or several linked views together. The choice is remembered in your
browser. The CLI takes `--view` on every `jarl stats` command (repeat it,
or separate views with commas), and the API `?views=internal,vps`.
Only what named's response log says can be split by view: answers (shown
as *answers* instead of queries), result codes, record types, zones,
blocking, rate limiting and the top lists. named's own counters (answer
sources, transport, upstream response times, DNSSEC, the cache and the
server) are for the whole server, and the page says so; asking for them
per view (`jarl stats breakdown answer --view internal`) is refused.
Per-view figures are kept from the version that added them (Jarl 0.4.1);
the page says since when. They take some more room in the statistics
database, roughly in proportion to the number of views.
## How data is kept
- Minute counters are kept for `retention.minute`, hourly counters and top
+6
View File
@@ -104,6 +104,12 @@ applied.
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
## TLS
| Code | Severity | Check |
|------|----------|-------|
| `tls_file_missing` | error | The files a top-level `tls` statement names (certificate, key, CA) exist: named doesn't load its configuration without them |
## Linked zones
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
+17
View File
@@ -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
+14
View File
@@ -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"`
}
+32
View File
@@ -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)
}
}
}
+41
View File
@@ -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"
@@ -336,6 +337,9 @@ func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Pri
if err := m.checkUses(ctx, c); err != nil {
return nil, err
}
if before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
}
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
if _, err := m.readFiles(c); err != nil {
return nil, inputErr("%v", err)
@@ -373,6 +377,9 @@ func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) er
if err != nil {
return err
}
if c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
}
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
return err
}
@@ -760,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
+401
View File
@@ -0,0 +1,401 @@
// Package encdns sets up DNS over TLS (DoT) and DNS over HTTPS (DoH) in
// named (docs/encrypted-dns.md): a tls statement naming a certificate Jarl
// manages, and listen-on statements for port 853 (DoT) and the DoH port.
// Its state is read from named's configuration, so what the setup stages
// stays ordinary, visible configuration.
package encdns
import (
"fmt"
"net/netip"
"path/filepath"
"strconv"
"strings"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// Prefix starts the names of the tls statements the setup writes.
const Prefix = "jarl-"
// DoTPort is the port for DNS over TLS (RFC 7858).
const DoTPort = 853
// Listener is a listen-on or listen-on-v6 statement.
type Listener struct {
OptionID int64 `json:"option_id"`
V6 bool `json:"v6"`
Port int `json:"port"` // as written; 0 when it isn't
TLS string `json:"tls,omitempty"` // tls statement ("" for plain DNS)
HTTP string `json:"http,omitempty"` // http endpoints: DNS over HTTPS
Addrs []string `json:"addresses"` // the address match list
}
// EffectivePort is the port named listens on: as written, or its
// default for the kind of listener.
func (l Listener) EffectivePort() int {
switch {
case l.Port != 0:
return l.Port
case l.HTTP != "" && l.TLS != "" && l.TLS != "none":
return 443
case l.HTTP != "":
return 80
case l.TLS != "":
return DoTPort
}
return 53
}
// Kind is plain, dot or doh.
func (l Listener) Kind() string {
switch {
case l.HTTP != "":
return "doh"
case l.TLS != "" && l.TLS != "none":
return "dot"
}
return "plain"
}
// Managed reports whether the setup wrote the listener.
func (l Listener) Managed() bool { return strings.HasPrefix(l.TLS, Prefix) }
// Listeners returns the server's listen-on and listen-on-v6 statements.
func Listeners(st *model.State) []Listener {
var out []Listener
for _, o := range st.ScopedOptions(model.ScopeOptions, 0) {
if o.Name != "listen-on" && o.Name != "listen-on-v6" {
continue
}
l, err := parseListener(o.Name, o.Value)
if err != nil {
continue
}
l.OptionID, l.V6 = o.ID, o.Name == "listen-on-v6"
out = append(out, l)
}
return out
}
func parseListener(name, value string) (Listener, error) {
var l Listener
stmt, err := namedconf.ParseOptionValue(name, value)
if err != nil {
return l, err
}
items := stmt.Items[1:]
for i := 0; i < len(items); i++ {
it := items[i]
if it.Block != nil {
for _, s := range it.Block.Stmts {
l.Addrs = append(l.Addrs, namedconf.Compact(s.Items))
}
continue
}
if it.Value == nil || i+1 >= len(items) || items[i+1].Value == nil {
continue
}
arg := items[i+1].Value.Text
switch strings.ToLower(it.Value.Text) {
case "port":
l.Port, _ = strconv.Atoi(arg)
case "tls":
l.TLS = arg
case "http":
l.HTTP = arg
}
i++
}
return l, nil
}
// TLSBlock is a top-level tls statement.
type TLSBlock struct {
OptionID int64 `json:"option_id"`
Name string `json:"name"`
CertFile string `json:"cert_file"`
KeyFile string `json:"key_file"`
}
// TLSBlocks returns the top-level tls statements.
func TLSBlocks(st *model.State) []TLSBlock {
var out []TLSBlock
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name != "tls" {
continue
}
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
if err != nil {
continue
}
b := TLSBlock{OptionID: o.ID, Name: stmt.Arg(0)}
if blk := stmt.FirstBlock(); blk != nil {
for _, s := range blk.Stmts {
switch s.Keyword() {
case "cert-file":
b.CertFile = s.Arg(0)
case "key-file":
b.KeyFile = s.Arg(0)
}
}
}
out = append(out, b)
}
return out
}
// Endpoint is an address and port named serves DoT or DoH on.
type Endpoint struct {
Address string `json:"address"`
Port int `json:"port"`
}
// Status is what is set up.
type Status struct {
Enabled bool `json:"enabled"`
// TLS is the tls statement the setup wrote, and Certificate the
// certificate it names (a Jarl certificate's name, when it is one).
TLS *TLSBlock `json:"tls,omitempty"`
Certificate string `json:"certificate,omitempty"`
DoT []Endpoint `json:"dot"`
DoH []Endpoint `json:"doh"`
// Listeners are all listen-on statements, the setup's and others.
Listeners []Listener `json:"listeners"`
// Other are DoT or DoH listeners written by hand.
Other bool `json:"other,omitempty"`
}
// GetStatus reads the setup from the configuration. certDir is where Jarl
// writes certificates for named (certificates.dir).
func GetStatus(st *model.State, certDir string) Status {
s := Status{Listeners: Listeners(st), DoT: []Endpoint{}, DoH: []Endpoint{}}
if s.Listeners == nil {
s.Listeners = []Listener{}
}
for _, b := range TLSBlocks(st) {
if strings.HasPrefix(b.Name, Prefix) {
b := b
s.TLS = &b
if dir := filepath.Dir(b.CertFile); filepath.Dir(dir) == filepath.Clean(certDir) {
s.Certificate = filepath.Base(dir)
}
break
}
}
for _, l := range s.Listeners {
if l.Kind() == "plain" {
continue
}
if !l.Managed() {
s.Other = true
continue
}
for _, a := range l.Addrs {
e := Endpoint{Address: a, Port: l.EffectivePort()}
if l.Kind() == "doh" {
s.DoH = append(s.DoH, e)
} else {
s.DoT = append(s.DoT, e)
}
}
}
s.Enabled = len(s.DoT)+len(s.DoH) > 0
return s
}
// Setup is what to set up.
type Setup struct {
// Hostname is the name clients connect to (the certificate's).
Hostname string `json:"hostname"`
// Certificate is the Jarl certificate (with the dns use) to serve.
Certificate string `json:"certificate"`
// Addresses are the addresses of this server to listen on.
Addresses []string `json:"addresses"`
DoT bool `json:"dot"`
DoH bool `json:"doh"`
DoHPort int `json:"doh_port"` // default 443
DoTPort int `json:"dot_port"` // default 853 (clients expect it)
// Views get the hostname's A/AAAA records (default: none). In a
// linked zone they are local to the view.
Views []string `json:"views"`
}
// Plan returns the requests that set up encrypted DNS as s says,
// replacing an earlier setup, and notes for the user. certDir is where
// the certificate's files are.
func Plan(st *model.State, s Setup, certDir string) ([]engine.Request, []string, error) {
var notes []string
if !s.DoT && !s.DoH {
return nil, nil, fmt.Errorf("choose DNS over TLS, DNS over HTTPS or both")
}
if s.DoHPort == 0 {
s.DoHPort = 443
}
if s.DoTPort == 0 {
s.DoTPort = DoTPort
}
for _, p := range []int{s.DoHPort, s.DoTPort} {
if p < 1 || p > 65535 || p == 53 {
return nil, nil, fmt.Errorf("port %d: use a free port other than 53", p)
}
}
if s.DoT && s.DoH && s.DoHPort == s.DoTPort {
return nil, nil, fmt.Errorf("DoT and DoH need different ports (both are %d)", s.DoHPort)
}
if s.Certificate == "" {
return nil, nil, fmt.Errorf("choose the certificate to serve")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s.Hostname), "."))
if host == "" || !strings.Contains(host, ".") {
return nil, nil, fmt.Errorf("give the host name clients connect to, e.g. dns.example.com")
}
var v4, v6 []string
for _, a := range s.Addresses {
ip, err := netip.ParseAddr(strings.TrimSpace(a))
if err != nil {
return nil, nil, fmt.Errorf("%q isn't an address of this server", a)
}
ip = ip.Unmap()
if ip.IsUnspecified() {
return nil, nil, fmt.Errorf("listen on this server's addresses, not on %s (that would include public ones)", ip)
}
if ip.Is4() {
v4 = append(v4, ip.String())
} else {
v6 = append(v6, ip.String())
}
}
if len(v4)+len(v6) == 0 {
return nil, nil, fmt.Errorf("choose at least one address to listen on")
}
tlsName := Prefix + s.Certificate
tlsValue := TLSValue(s.Certificate, certDir)
var reqs []engine.Request
// Take out the earlier setup's listeners and tls statements.
current := Listeners(st)
for _, l := range current {
if l.Managed() {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
}
}
haveTLS := false
for _, b := range TLSBlocks(st) {
switch {
case b.Name == tlsName:
haveTLS = true
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(tlsValue)}))
case strings.HasPrefix(b.Name, Prefix):
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
}
}
if !haveTLS {
reqs = append(reqs, option(model.ScopeTopLevel, "tls", tlsValue))
}
// named listens on port 53 everywhere only while there is no
// listen-on statement at all: adding the first one for DoT would end
// that, so keep it explicitly.
plain := func(v6 bool) bool {
for _, l := range current {
if l.V6 == v6 && !l.Managed() && l.Kind() == "plain" {
return true
}
}
return false
}
add := func(name string, addrs []string) {
if len(addrs) == 0 {
return
}
list := "{ " + strings.Join(addrs, "; ") + "; }"
if !plain(name == "listen-on-v6") {
reqs = append(reqs, option(model.ScopeOptions, name, "{ any; }"))
notes = append(notes, fmt.Sprintf("named listened on port 53 on every address by default; %s { any; } keeps that, as the first %s statement would otherwise end it", name, name))
}
if s.DoT {
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s %s", s.DoTPort, tlsName, list)))
}
if s.DoH {
reqs = append(reqs, option(model.ScopeOptions, name, fmt.Sprintf("port %d tls %s http default %s", s.DoHPort, tlsName, list)))
}
}
add("listen-on", v4)
add("listen-on-v6", v6)
// The host name's addresses, in the views asked for.
fqdn := host + "."
for _, vn := range s.Views {
v := st.ViewByName(vn)
if v == nil {
return nil, nil, fmt.Errorf("view %q does not exist", vn)
}
z := zoneFor(st, fqdn, v.ID)
if z == nil {
notes = append(notes, fmt.Sprintf("view %s has no zone for %s, so no address records were added there; clients must find it some other way", v.Name, host))
continue
}
var local *bool
if z.LinkGroup != 0 {
local = model.Ptr(true)
}
for _, set := range []struct {
typ string
addrs []string
}{{"A", v4}, {"AAAA", v6}} {
if len(set.addrs) == 0 {
continue
}
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: fqdn, Type: set.typ, Data: set.addrs, Local: local}))
}
}
return reqs, notes, nil
}
func option(scope, name, value string) engine.Request {
return engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{Scope: model.Ptr(scope), Name: model.Ptr(name), Value: model.Ptr(value)})
}
// zoneFor returns the primary zone in a view that holds name.
func zoneFor(st *model.State, name string, viewID int64) *model.Zone {
var best *model.Zone
for _, z := range st.ZonesInView(viewID) {
if z.Type == model.ZonePrimary && (name == z.Name || strings.HasSuffix(name, "."+z.Name)) && (best == nil || len(z.Name) > len(best.Name)) {
best = z
}
}
return best
}
// Disable returns the requests that take the setup out: its listeners and
// tls statement. Plain listeners and the host name's records stay.
func Disable(st *model.State) []engine.Request {
var reqs []engine.Request
for _, l := range Listeners(st) {
if l.Managed() {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, l.OptionID, nil))
}
}
for _, b := range TLSBlocks(st) {
if strings.HasPrefix(b.Name, Prefix) {
reqs = append(reqs, engine.NewRequest(engine.OpOptionDelete, b.OptionID, nil))
}
}
return reqs
}
// Private reports whether an address is private (RFC 1918, ULA, ...).
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"))
}
+188
View File
@@ -0,0 +1,188 @@
package encdns
import (
"context"
"io"
"log/slog"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/store"
)
type env struct {
t *testing.T
eng *engine.Engine
st *store.Store
certDir string
}
func newEnv(t *testing.T) *env {
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "jarl.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
eng := engine.New(st, nil, engine.Config{
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := &env{t: t, eng: eng, st: st, certDir: filepath.Join(dir, "tls")}
os.MkdirAll(filepath.Join(e.certDir, "dns"), 0o755)
os.WriteFile(filepath.Join(e.certDir, "dns", "fullchain.pem"), []byte("x"), 0o644)
os.WriteFile(filepath.Join(e.certDir, "dns", "privkey.pem"), []byte("x"), 0o600)
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
engine.NewRequest(engine.OpViewCreate, 0, engine.ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
return e
}
func (e *env) submit(reqs ...engine.Request) *engine.Result {
e.t.Helper()
res, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{})
if err != nil {
e.t.Fatalf("%v (%+v)", err, res)
}
return res
}
func (e *env) state() *model.State {
st, err := e.st.LoadState(context.Background())
if err != nil {
e.t.Fatal(err)
}
return st
}
func values(st *model.State, scope, name string) []string {
var out []string
for _, o := range st.ScopedOptions(scope, 0) {
if o.Name == name {
out = append(out, o.Value)
}
}
return out
}
func TestSetup(t *testing.T) {
e := newEnv(t)
setup := Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5", "2001:db8::5"}, DoT: true, DoH: true, Views: []string{"internal", "external"}}
reqs, notes, err := Plan(e.state(), setup, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st := e.state()
tls := values(st, model.ScopeTopLevel, "tls")
if len(tls) != 1 || !strings.HasPrefix(tls[0], "jarl-dns {") || !strings.Contains(tls[0], filepath.Join(e.certDir, "dns", "fullchain.pem")) {
t.Errorf("tls = %q", tls)
}
// The plain listeners named had by default are kept explicitly.
want4 := []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }", "port 443 tls jarl-dns http default { 10.0.10.5; }"}
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, want4) {
t.Errorf("listen-on = %q, want %q", got, want4)
}
if got := values(st, model.ScopeOptions, "listen-on-v6"); len(got) != 3 || got[0] != "{ any; }" {
t.Errorf("listen-on-v6 = %q", got)
}
if len(notes) != 3 || !strings.Contains(strings.Join(notes, " "), "external has no zone") {
t.Errorf("notes = %q", notes)
}
// The host name's addresses in the internal view.
z := st.FindZone("example.com.", st.ViewByName("internal").ID)
var addrs []string
for _, r := range st.ZoneRecords(z.ID) {
if r.Name == "dns.example.com." {
addrs = append(addrs, r.Type+" "+r.Data)
}
}
if !slices.Equal(addrs, []string{"A 10.0.10.5", "AAAA 2001:db8::5"}) {
t.Errorf("records = %q", addrs)
}
s := GetStatus(st, e.certDir)
if !s.Enabled || s.Certificate != "dns" || len(s.DoT) != 2 || len(s.DoH) != 2 || s.DoH[0].Port != 443 || s.Other {
t.Errorf("status = %+v", s)
}
// Setting up again replaces the setup: DoT only, one address, and the
// plain listeners aren't doubled.
reqs, _, err = Plan(st, Setup{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, e.certDir)
if err != nil {
t.Fatal(err)
}
e.submit(reqs...)
st = e.state()
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }", "port 853 tls jarl-dns { 10.0.10.5; }"}) {
t.Errorf("after the second setup: %q", got)
}
if got := values(st, model.ScopeOptions, "listen-on-v6"); !slices.Equal(got, []string{"{ any; }"}) {
t.Errorf("listen-on-v6 after the second setup: %q", got)
}
if got := values(st, model.ScopeTopLevel, "tls"); len(got) != 1 {
t.Errorf("tls statements: %q", got)
}
// Off: the setup's listeners and tls statement go, the rest stays.
e.submit(Disable(st)...)
st = e.state()
if got := values(st, model.ScopeOptions, "listen-on"); !slices.Equal(got, []string{"{ any; }"}) || len(values(st, model.ScopeTopLevel, "tls")) != 0 || GetStatus(st, e.certDir).Enabled {
t.Errorf("after disabling: listen-on %q tls %q", got, values(st, model.ScopeTopLevel, "tls"))
}
}
func TestSetupRefuses(t *testing.T) {
e := newEnv(t)
st := e.state()
for _, s := range []Setup{
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}}, // neither DoT nor DoH
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"0.0.0.0"}, DoT: true}, // every address
{Hostname: "dns.example.com", Certificate: "dns", DoT: true}, // no address
{Hostname: "dns.example.com", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoH: true, DoHPort: 53}, // port 53
{Hostname: "dns", Certificate: "dns", Addresses: []string{"10.0.10.5"}, DoT: true}, // not a host name
} {
if _, _, err := Plan(st, s, e.certDir); err == nil {
t.Errorf("accepted %+v", s)
}
}
// A tls statement whose files are missing is refused by validation.
_, err := e.eng.Submit(context.Background(), []engine.Request{engine.NewRequest(engine.OpOptionCreate, 0, engine.OptionInput{
Scope: model.Ptr(model.ScopeTopLevel), Name: model.Ptr("tls"), Value: model.Ptr(`x { cert-file "/nonexistent/c.pem"; key-file "/nonexistent/k.pem"; }`)})},
auth.System, "test", "", engine.ApplyOptions{})
if err == nil || !strings.Contains(err.Error(), "doesn't exist") {
t.Errorf("missing files: %v", err)
}
}
func TestPorts(t *testing.T) {
s, err := parseProcAddr("0100007F:0035")
if err != nil || s.Addr != netip.MustParseAddr("127.0.0.1") || s.Port != 53 {
t.Errorf("v4: %+v %v", s, err)
}
s, err = parseProcAddr("00000000000000000000000001000000:01BB")
if err != nil || s.Addr != netip.MustParseAddr("::1") || s.Port != 443 {
t.Errorf("v6: %+v %v", s, err)
}
e := newEnv(t)
old := ListeningSockets
t.Cleanup(func() { ListeningSockets = old })
ListeningSockets = func() ([]Socket, error) {
return []Socket{{netip.MustParseAddr("0.0.0.0"), 443}, {netip.MustParseAddr("10.0.10.5"), 8443}, {netip.MustParseAddr("::"), 853}}, nil
}
c, err := PortCheck(e.state(), []string{"10.0.10.5"}, []int{853, 443, 8443}, []string{"0.0.0.0:443"})
if err != nil || len(c) != 3 || c[1].What != "jarld (Jarl's web UI and API)" || c[2].What != "another program" {
t.Errorf("conflicts = %+v %v", c, err)
}
if p := FreeDoHPort(e.state(), []string{"10.0.10.5"}, nil); p != 4443 {
t.Errorf("free DoH port = %d, want 4443", p)
}
}
+208
View File
@@ -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()
}
+115
View File
@@ -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])
}
}
+149
View File
@@ -0,0 +1,149 @@
package encdns
import (
"bufio"
"encoding/hex"
"fmt"
"net/netip"
"os"
"strconv"
"strings"
"jarl/internal/model"
)
// Socket is a listening TCP socket on this host.
type Socket struct {
Addr netip.Addr
Port int
}
// ListeningSockets reads the host's listening TCP sockets from
// /proc/net/tcp and tcp6 (Linux; nil elsewhere). It needs no privileges
// and binds nothing. Tests replace it.
var ListeningSockets = func() ([]Socket, error) {
var out []Socket
for _, f := range []string{"/proc/net/tcp", "/proc/net/tcp6"} {
socks, err := parseProcNet(f)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
out = append(out, socks...)
}
return out, nil
}
func parseProcNet(path string) ([]Socket, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []Socket
sc := bufio.NewScanner(f)
sc.Scan() // header
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 4 || fields[3] != "0A" { // 0A: LISTEN
continue
}
s, err := parseProcAddr(fields[1])
if err == nil {
out = append(out, s)
}
}
return out, sc.Err()
}
// parseProcAddr parses "0100007F:0035": the address in 32-bit words of
// host byte order (little-endian here), then the port.
func parseProcAddr(s string) (Socket, error) {
ha, hp, ok := strings.Cut(s, ":")
if !ok {
return Socket{}, fmt.Errorf("bad address %q", s)
}
b, err := hex.DecodeString(ha)
if err != nil || (len(b) != 4 && len(b) != 16) {
return Socket{}, fmt.Errorf("bad address %q", s)
}
for i := 0; i+4 <= len(b); i += 4 {
b[i], b[i+1], b[i+2], b[i+3] = b[i+3], b[i+2], b[i+1], b[i]
}
addr, _ := netip.AddrFromSlice(b)
port, err := strconv.ParseUint(hp, 16, 16)
if err != nil {
return Socket{}, err
}
return Socket{Addr: addr.Unmap(), Port: int(port)}, nil
}
// Conflict is something else listening where a listener would go.
type Conflict struct {
Address string `json:"address"`
Port int `json:"port"`
What string `json:"what"` // who it is, when Jarl knows
}
// PortCheck says which of addresses × ports something else listens on
// already. named's own listeners (the setup's, now) and jarld's (known
// listen addresses) are told apart.
func PortCheck(st *model.State, addresses []string, ports []int, jarld []string) ([]Conflict, error) {
socks, err := ListeningSockets()
if err != nil {
return nil, err
}
// What named listens on now with DoT or DoH (it will again).
ours := map[string]bool{}
for _, l := range Listeners(st) {
if l.Kind() != "plain" {
for _, a := range l.Addrs {
ours[a+"#"+strconv.Itoa(l.EffectivePort())] = true
}
}
}
jarldOn := map[string]bool{}
for _, l := range jarld {
jarldOn[l] = true
}
var out []Conflict
for _, as := range addresses {
a, err := netip.ParseAddr(as)
if err != nil {
continue
}
a = a.Unmap()
for _, p := range ports {
if ours[a.String()+"#"+strconv.Itoa(p)] {
continue
}
for _, s := range socks {
if s.Port != p || !(s.Addr == a || s.Addr.IsUnspecified() && s.Addr.Is4() == a.Is4() || s.Addr.IsUnspecified() && s.Addr.Is6()) {
continue
}
what := "another program"
for l := range jarldOn {
if strings.HasSuffix(l, ":"+strconv.Itoa(p)) {
what = "jarld (Jarl's web UI and API)"
}
}
out = append(out, Conflict{Address: a.String(), Port: p, What: what})
break
}
}
}
return out, nil
}
// FreeDoHPort returns the first of the usual DoH ports nothing listens on
// at the addresses (0 when none is free).
func FreeDoHPort(st *model.State, addresses []string, jarld []string) int {
for _, p := range []int{443, 8443, 4443, 9443} {
if c, err := PortCheck(st, addresses, []int{p}, jarld); err == nil && len(c) == 0 {
return p
}
}
return 0
}
+104
View File
@@ -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)
}
+87 -24
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/netip"
"slices"
"strings"
"time"
@@ -642,11 +643,42 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
return nil, nil
var ops []model.Op
found := false
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
found = true
more, err := x.ptrCreateIn(rz, ptrName, owner)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if !found {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
}
return ops, nil
}
// ptrViews are the views whose reverse zones get the PTR records of zone
// z's address records: z's own, and, for a linked zone, the other views
// of the link (the record is in all of them).
func ptrViews(st *model.State, z *model.Zone) []int64 {
out := []int64{z.ViewID}
for _, o := range st.LinkedWith(z) {
if !slices.Contains(out, o.ViewID) {
out = append(out, o.ViewID)
}
}
return out
}
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
@@ -674,10 +706,18 @@ func (x *expander) ptrSetDisabled(z *model.Zone, owner string, p *rr.Parsed, dis
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
ops = append(ops, x.ptrSetDisabledIn(rz, ptrName, owner, disabled)...)
}
return ops
}
func (x *expander) ptrSetDisabledIn(rz *model.Zone, ptrName, owner string, disabled bool) []model.Op {
var ops []model.Op
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" && strings.EqualFold(r.Data, owner) && r.Disabled != disabled {
@@ -700,12 +740,16 @@ func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.
if !ok {
return nil
}
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
if rz == nil {
return nil
var ops []model.Op
for _, vid := range ptrViews(x.st, z) {
rz, ptrName := reverseZoneFor(x.st, vid, addr)
if rz == nil {
continue
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
ops = append(ops, newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner)))
}
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
return ops
}
// parseWarnings keeps what parsing noticed (e.g. a name read as absolute).
@@ -757,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 {
@@ -790,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)
@@ -804,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)) == "{}" {
@@ -885,7 +931,10 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if len(pre) > 0 {
base = 0 // the record was just updated by pre
}
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
ops := pre
if string(mustJSON(in)) != "{}" || !ptr {
ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary))
}
disabled := r.Disabled
if in.Disabled != nil {
disabled = *in.Disabled
@@ -909,6 +958,15 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
}
ops = append(ops, x.ptrSetDisabled(z, owner, p, disabled)...)
}
if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
@@ -1097,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 {
@@ -1300,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))
}
+3 -1
View File
@@ -174,7 +174,9 @@ type RecordInput struct {
// Disabled keeps the record in Jarl but out of the zone.
Disabled *bool `json:"disabled,omitempty"`
// PTR also creates (or deletes, disables or enables) the matching PTR
// record in the reverse zone, for A and AAAA records.
// record in the reverse zone, for A and AAAA records. On an update that
// changes nothing else it adds the PTR record if it is missing. In a
// linked zone, every linked view's reverse zone counts.
PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"`
+56
View File
@@ -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)
}
}
+53
View File
@@ -0,0 +1,53 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// Ticking "update PTR" on an unchanged address record adds its PTR when
// the reverse zone is there (it was created after the record).
func TestUpdatePTRAddsMissing(t *testing.T) {
env := newEnv(t)
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}))
env.submit(env.admin, rec("example.com", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32")}))
var id int64
for _, r := range env.state().Records {
if r.Name == "ldn.example.com." {
id = r.ID
}
}
env.submit(env.admin, NewRequest(OpRecordUpdate, id, RecordInput{PTR: true}))
got := env.records("154.144.144.139.in-addr.arpa.")
if !slices.ContainsFunc(got, func(s string) bool { return strings.Contains(s, "PTR ldn.example.com.") }) {
t.Errorf("reverse zone = %q", got)
}
}
// In linked zones the PTR goes to the reverse zone of whichever linked
// view has one.
func TestUpdatePTRLinked(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, recIn("internal", "ldn", "A", "139.144.144.154"))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("139.144.144.154/32"), View: model.Ptr("external")}))
res := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true}))
st := env.state()
z := st.FindZone("154.144.144.139.in-addr.arpa.", st.ViewByName("external").ID)
ok := false
for _, r := range st.ZoneRecords(z.ID) {
ok = ok || r.Type == "PTR" && r.Data == "ldn.example.com."
}
if !ok {
t.Errorf("no PTR in external's reverse zone (warnings %q)", res.Warnings)
}
// Again: already there, nothing to do.
if e := env.submitErr(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "ldn", "A"), RecordInput{PTR: true})); !strings.Contains(e.Message, "nothing to do") {
t.Errorf("second time: %v", e)
}
}
+15
View File
@@ -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 {
+89
View File
@@ -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
}
+320
View File
@@ -0,0 +1,320 @@
package server
import (
"context"
"net/http"
"net/netip"
"slices"
"strconv"
"strings"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/model"
"jarl/internal/nscheck"
)
// DNS over TLS and HTTPS (docs/encrypted-dns.md).
type encAddress struct {
Address string `json:"address"`
Private bool `json:"private"`
}
type encView struct {
Name string `json:"name"`
Public bool `json:"public"`
HasZone bool `json:"has_zone,omitempty"` // has a zone for the host name
}
type encCert struct {
Name string `json:"name"`
Names []string `json:"names"`
Domains []string `json:"domains"`
DNS bool `json:"dns"` // has the dns use
Status certs.Status `json:"status"`
Obtained bool `json:"obtained"`
}
type encStatus struct {
encdns.Status
// Hostname is the name clients connect to (the certificate's first).
Hostname string `json:"hostname,omitempty"`
// 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"`
Certificates []encCert `json:"certificates"`
}
func (s *Server) encReady(p *auth.Principal, write bool) error {
if err := require(p, auth.Action{Resource: auth.ResOptions, Write: write}); err != nil {
return err
}
if s.cfg.Certs == nil {
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
}
return nil
}
// localAddrs returns this host's addresses (a test may replace it).
var localAddrs = nscheck.LocalAddrs
// encryptedDNS: GET /encrypted-dns.
func (s *Server) encryptedDNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
m := s.cfg.Certs
out := encStatus{Status: encdns.GetStatus(st, m.FilesDir("")), Addresses: []encAddress{}, Views: []encView{}, Certificates: []encCert{}}
all, err := m.List(r.Context())
if err != nil {
return err
}
for _, c := range all {
out.Certificates = append(out.Certificates, encCert{Name: c.Name, Names: c.Names, Domains: c.Domains, DNS: c.HasUse(model.CertUseDNS),
Status: m.StatusOf(c), Obtained: c.NotAfter != nil})
if c.Name == out.Certificate {
for _, n := range append(c.Names, c.Domains...) {
if !strings.HasPrefix(n, "*.") {
out.Hostname = n
break
}
}
}
}
if out.Hostname != "" {
if len(out.DoT) > 0 {
out.DoTHost = out.Hostname
if out.DoT[0].Port != encdns.DoTPort {
out.DoTHost += ":" + strconv.Itoa(out.DoT[0].Port)
}
}
if len(out.DoH) > 0 {
out.DoHURL = "https://" + out.Hostname
if out.DoH[0].Port != 443 {
out.DoHURL += ":" + strconv.Itoa(out.DoH[0].Port)
}
out.DoHURL += "/dns-query"
}
}
if out.Enabled {
out.Health = s.encryptedDNSHealth()
}
seen := map[netip.Addr]bool{}
for _, a := range localAddrs() {
a = a.Unmap()
if seen[a] || a.IsLinkLocalUnicast() {
continue
}
seen[a] = true
out.Addresses = append(out.Addresses, encAddress{Address: a.String(), Private: encdns.Private(a)})
}
slices.SortFunc(out.Addresses, func(a, b encAddress) int { return strings.Compare(a.Address, b.Address) })
host := strings.TrimSuffix(r.URL.Query().Get("hostname"), ".")
if host == "" {
host = out.Hostname
}
for _, x := range s.eng.Exposures(st) {
if x.View == "" {
continue
}
v := encView{Name: x.View, Public: x.Public}
if host != "" {
if vv := st.ViewByName(x.View); vv != nil {
for _, z := range st.ZonesInView(vv.ID) {
if z.Type == model.ZonePrimary && (host+"." == z.Name || strings.HasSuffix(host+".", "."+z.Name)) {
v.HasZone = true
}
}
}
}
out.Views = append(out.Views, v)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// encryptedDNSCheck: POST /encrypted-dns/check {"addresses", "dot", "doh",
// "doh_port"} says what already listens where the listeners would go.
func (s *Server) encryptedDNSCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, false); err != nil {
return err
}
var in encdns.Setup
if err := decodeJSON(r, &in); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
if in.DoHPort == 0 {
in.DoHPort = 443
}
if in.DoTPort == 0 {
in.DoTPort = encdns.DoTPort
}
var ports []int
if in.DoT {
ports = append(ports, in.DoTPort)
}
if in.DoH {
ports = append(ports, in.DoHPort)
}
conflicts, err := encdns.PortCheck(st, in.Addresses, ports, s.cfg.Listen)
out := map[string]any{"conflicts": conflicts}
if err != nil {
out["error"] = "can't see which ports are in use: " + err.Error()
}
if conflicts == nil {
out["conflicts"] = []encdns.Conflict{}
}
if in.DoH {
out["free_doh_port"] = encdns.FreeDoHPort(st, in.Addresses, s.cfg.Listen)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// encryptedDNSSetup: POST /encrypted-dns stages (or applies) the setup.
func (s *Server) encryptedDNSSetup(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, true); err != nil {
return err
}
var in encdns.Setup
if err := decodeJSON(r, &in); err != nil {
return err
}
c, err := s.cfg.Certs.Get(r.Context(), in.Certificate)
if err != nil {
return certErr(err)
}
switch {
case !c.HasUse(model.CertUseDNS):
return badRequest("certificate %s doesn't have the dns use, so named can't read it: jarl cert update %s --use dns (and keep its other uses)", c.Name, c.Name)
case c.NotAfter == nil:
return conflict("certificate %s hasn't been obtained yet (%s); set up encrypted DNS once it has", c.Name, s.cfg.Certs.StatusOf(c).Message)
case in.Hostname != "" && !certs.Covers(c.Names, []string{in.Hostname}):
return badRequest("certificate %s is for %s, not %s: clients would reject it", c.Name, strings.Join(c.Names, ", "), in.Hostname)
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
reqs, notes, err := encdns.Plan(st, in, s.cfg.Certs.FilesDir(""))
if err != nil {
return badRequest("%v", err)
}
what := []string{}
if in.DoT {
what = append(what, "DNS over TLS")
}
if in.DoH {
what = append(what, "DNS over HTTPS")
}
return s.mutateNotes(w, r, p, "set up "+strings.Join(what, " and ")+" for "+in.Hostname, reqs, nil, notes)
}
// encryptedDNSDisable: DELETE /encrypted-dns.
func (s *Server) encryptedDNSDisable(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := s.encReady(p, true); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
reqs := encdns.Disable(st)
if len(reqs) == 0 {
return conflict("encrypted DNS isn't set up")
}
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
}
+22 -8
View File
@@ -1,6 +1,7 @@
package server
import (
"errors"
"net/http"
"net/netip"
"path/filepath"
@@ -14,14 +15,24 @@ import (
"jarl/internal/stats"
)
// statsQuerier checks permission and that statistics are enabled.
func (s *Server) statsQuerier(p *auth.Principal) (*stats.Querier, error) {
// statsQuerier checks permission and that statistics are enabled. With
// ?views=internal,vps the figures are for those views only.
func (s *Server) statsQuerier(r *http.Request, p *auth.Principal) (*stats.Querier, error) {
if err := require(p, auth.Action{Resource: auth.ResStats}); err != nil {
return nil, err
}
if s.statsQ == nil {
return nil, &httpError{http.StatusNotFound, "stats_disabled", "query statistics are disabled; set stats.enabled: true in /etc/jarl/jarl.yaml and restart jarl (see docs/stats.md)"}
}
var views []string
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
if v = strings.TrimSpace(v); v != "" {
views = append(views, v)
}
}
if len(views) > 0 {
return s.statsQ.ForViews(views), nil
}
return s.statsQ, nil
}
@@ -46,7 +57,7 @@ func (s *Server) statsStatus(w http.ResponseWriter, r *http.Request, p *auth.Pri
}
func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -66,7 +77,7 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -93,7 +104,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -124,7 +135,7 @@ func (s *Server) statsTimeline(w http.ResponseWriter, r *http.Request, p *auth.P
}
func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -143,7 +154,7 @@ func (s *Server) statsHeatmap(w http.ResponseWriter, r *http.Request, p *auth.Pr
}
func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
q, err := s.statsQuerier(p)
q, err := s.statsQuerier(r, p)
if err != nil {
return err
}
@@ -152,6 +163,9 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
return err
}
es, err := q.Breakdown(r.Context(), r.URL.Query().Get("group"), since)
if errors.Is(err, stats.ErrNotPerView) {
return &httpError{http.StatusBadRequest, "not_per_view", r.URL.Query().Get("group") + ": " + err.Error()}
}
if err != nil {
return badRequest("%v", err)
}
@@ -247,7 +261,7 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
// limits (rndc status), and the space left on the file systems Jarl and
// named write to.
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if _, err := s.statsQuerier(p); err != nil {
if _, err := s.statsQuerier(r, p); err != nil {
return err
}
out := map[string]any{"server": s.cfg.Stats.Server()}
+7
View File
@@ -289,6 +289,13 @@ 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 tokens, err := s.store.ListTokens(ctx, 0); err == nil {
for _, t := range tokens {
if t.ExpiresAt != nil && t.RevokedAt == nil {
+7
View File
@@ -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)})
+13 -2
View File
@@ -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))
@@ -172,6 +173,11 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))
m.HandleFunc("POST "+p+"/certificates/{name}/check", s.authed(s.certCheck))
m.HandleFunc("GET "+p+"/encrypted-dns", s.authed(s.encryptedDNS))
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/ack", s.authed(s.exposureAck(true)))
@@ -253,6 +259,11 @@ func changesetTarget(r *http.Request) (int64, error) {
// mutate applies (or stages, or previews) requests and writes the
// response. object, if set, returns the resulting object to include.
func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any) error {
return s.mutateNotes(w, r, p, description, reqs, object, nil)
}
// mutateNotes is mutate with notes for the user, shown as warnings.
func (s *Server) mutateNotes(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, reqs []engine.Request, object func(st *model.State, created int64) any, notes []string) error {
csID, err := changesetTarget(r)
if err != nil {
return err
@@ -263,7 +274,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
return err
}
res.Changeset.Ops = engine.RedactOps(res.Changeset.Ops)
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: append(notes, res.Warnings...), Problems: raw(res.Problems)})
return nil
}
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
@@ -272,7 +283,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
s.writeErr(w, r, err, res)
return nil
}
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings}
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: append(notes, res.Warnings...)}
if object != nil && res.Applied {
st, err := s.store.LoadState(r.Context())
if err == nil {
+18
View File
@@ -4,6 +4,7 @@ package server
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io/fs"
@@ -21,6 +22,7 @@ import (
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/certs"
"jarl/internal/encdns"
"jarl/internal/engine"
"jarl/internal/nscheck"
"jarl/internal/porkbun"
@@ -90,6 +92,13 @@ 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
mu sync.Mutex
srvs []*http.Server
@@ -226,6 +235,14 @@ func (s *Server) Serve(ctx context.Context, ready func()) error {
ready()
}
go s.housekeeping(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 +317,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()
}
+14 -1
View File
@@ -35,7 +35,10 @@ func TestStatsAPI(t *testing.T) {
minute := now.Unix() - now.Unix()%60 - 120
hour := minute - minute%3600
b := &stats.Batch{}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10}
vals := map[string]int64{"queries": 50, "rcode:NOERROR": 45, "rcode:NXDOMAIN": 5, "qtype:A": 30, "qtype:AAAA": 20, "zone:example.com": 40, "zone:-": 10,
"view:internal": 30, "view:external": 20, stats.ViewKey("internal", "responses"): 30, stats.ViewKey("external", "responses"): 20,
stats.ViewKey("internal", "rcode:NOERROR"): 30, stats.ViewKey("external", "rcode:NOERROR"): 15, stats.ViewKey("external", "rcode:NXDOMAIN"): 5,
"answer:authoritative": 40}
b.AddCounters(stats.ResMinute, minute, vals)
b.AddCounters(stats.ResHour, hour, vals)
b.SetTops(stats.ResHour, hour, stats.KindName, []stats.Entry{{Key: "www.example.com", Count: 30}, {Key: "mail.example.com", Count: 10}})
@@ -47,6 +50,16 @@ func TestStatsAPI(t *testing.T) {
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
e := newAPIEnv(t, c)
// Some views only.
if sum := e.do("GET", "/stats/summary?since=1h&views=external", e.token, nil, 200); sum["queries"].(float64) != 20 || len(sum["rcodes"].([]any)) != 2 || sum["filter_views"] == nil {
t.Errorf("external summary = %v", sum)
}
if b := e.list("/stats/breakdown?group=rcode&since=1h&views=internal,external", e.token); len(b) != 2 || b[0]["count"].(float64) != 45 {
t.Errorf("rcodes of both views = %v", b)
}
if out, status := e.req("GET", "/stats/breakdown?group=answer&since=1h&views=internal", e.token, nil); status != 400 || !strings.Contains(string(out), "not_per_view") {
t.Errorf("answers per view = %d %s", status, out)
}
if st := e.do("GET", "/stats/status", e.token, nil, 200); st["enabled"] != true {
t.Errorf("status = %v", st)
}
+76 -19
View File
@@ -33,6 +33,13 @@ const (
KindRateLimitedName = "ratelimited_name"
)
// ViewKey is the key of a counter for one view: per-view copies of the
// counters that come from named's response log (docs/stats.md).
func ViewKey(view, key string) string { return "@" + view + "/" + key }
// ViewKind is the kind of a top list for one view.
func ViewKind(kind, view string) string { return kind + "@" + view }
// Kinds lists the top-list kinds.
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient, KindRateLimited, KindRateLimitedName}
@@ -115,6 +122,9 @@ type Collector struct {
// rewritten holds queries named blocked (client#port|name) until their
// response line arrives.
rewritten map[string]bool
// viewsSince is set once per-view counters exist (meta views_since).
viewsSince bool
viewsSinceAt int64
// proc is the last sample of named's process, server the latest server
// statistics (server.go).
proc *namedProc
@@ -212,6 +222,9 @@ func (c *Collector) start(ctx context.Context) {
}
}
}
if v, _ := c.store.Meta(ctx, "views_since"); v != "" {
c.viewsSince = true
}
if v, _ := c.store.Meta(ctx, "first_data"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
t := time.Unix(n, 0)
@@ -227,7 +240,8 @@ func (c *Collector) start(ctx context.Context) {
if last, err := strconv.ParseInt(merged, 10, 64); err == nil {
from := max(last+3600, cur-48*3600)
for h := from; h < cur; h += 3600 {
for _, k := range Kinds {
kinds, _ := c.store.KindsAt(ctx, ResHour, h)
for _, k := range kinds {
if es, err := c.store.TopsAt(ctx, ResHour, h, k); err == nil && len(es) > 0 {
b.AddTops(ResDay, c.dayOf(time.Unix(h, 0)), k, es, 3*c.opts.TopN)
}
@@ -235,7 +249,8 @@ func (c *Collector) start(ctx context.Context) {
}
}
b.SetMeta("merged_through", strconv.FormatInt(cur-3600, 10))
for _, k := range Kinds {
curKinds, _ := c.store.KindsAt(ctx, ResHour, cur)
for _, k := range curKinds {
es, err := c.store.TopsAt(ctx, ResHour, cur, k)
if err != nil || len(es) == 0 {
continue
@@ -294,7 +309,7 @@ func (c *Collector) count(minute int64, key string, n int64) {
c.minutes[minute] = m
}
m[key] += n
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") {
if !strings.HasPrefix(key, "g:") && !strings.HasPrefix(key, "gn:") && !strings.HasPrefix(key, "@") {
c.totals[key] += n
}
}
@@ -365,33 +380,55 @@ func (c *Collector) handleLine(line string) {
if view == "" {
view = "_default"
}
zone := c.zoneOf(ev.Name, ev.View)
c.count(minute, "responses", 1)
c.count(minute, "view:"+view, 1)
c.count(minute, "zone:"+c.zoneOf(ev.Name, ev.View), 1)
c.count(minute, "zone:"+zone, 1)
// The same, per view, for the statistics of chosen views.
c.countView(minute, view, "responses", 1)
c.countView(minute, view, "zone:"+zone, 1)
c.countView(minute, view, "rcode:"+ev.RCode, 1)
c.countView(minute, view, "qtype:"+ev.Type, 1)
client := clientKey(ev.Client, c.opts.Anonymize)
hours := c.hours
if hour < c.liveHour {
hours = c.late
}
hours[hour] = c.hourMaps(hours, hour)
tops := hours[hour]
tops[KindName][ev.Name]++
tops[KindClient][client]++
switch {
case blocked:
// Counted by handleRewrite; not a name that doesn't exist.
case ev.RCode == "NXDOMAIN":
tops[KindNXDomain][ev.Name]++
case ev.RCode == "SERVFAIL":
tops[KindServFail][ev.Name]++
case ev.RCode == "REFUSED":
tops[KindRefused][client]++
}
for _, m := range tops {
add := func(kind, key string) {
hours[hour][kind][key]++
m := c.topsMap(hours, hour, ViewKind(kind, view))
m[key]++
if len(m) > maxKeys {
shrink(m)
}
}
add(KindName, ev.Name)
add(KindClient, client)
switch {
case blocked:
// Counted by handleRewrite; not a name that doesn't exist.
case ev.RCode == "NXDOMAIN":
add(KindNXDomain, ev.Name)
case ev.RCode == "SERVFAIL":
add(KindServFail, ev.Name)
case ev.RCode == "REFUSED":
add(KindRefused, client)
}
for _, k := range Kinds {
if m := hours[hour][k]; len(m) > maxKeys {
shrink(m)
}
}
}
// countView counts a per-view copy of a counter.
func (c *Collector) countView(minute int64, view, key string, n int64) {
if !c.viewsSince {
c.viewsSince = true
c.viewsSinceAt = minute
}
c.count(minute, ViewKey(view, key), n)
}
// handleRewrite counts a query that blocking answered: "blocked", and
@@ -412,15 +449,24 @@ func (c *Collector) handleRewrite(rw Rewrite) {
}
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
view := rw.View
if view == "" {
view = "_default"
}
c.count(minute, "blocked", 1)
c.count(minute, "blocklist:"+policySource(rw.Via), 1)
c.countView(minute, view, "blocked", 1)
c.countView(minute, view, "blocklist:"+policySource(rw.Via), 1)
hours := c.hours
if hour < c.liveHour {
hours = c.late
}
hours[hour] = c.hourMaps(hours, hour)
client := clientKey(rw.Client, c.opts.Anonymize)
hours[hour][KindBlocked][rw.Name]++
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
hours[hour][KindBlockedClient][client]++
c.topsMap(hours, hour, ViewKind(KindBlocked, view))[rw.Name]++
c.topsMap(hours, hour, ViewKind(KindBlockedClient, view))[client]++
}
// handleRateLimit counts a response rate limiting limited, or would have:
@@ -437,7 +483,12 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
key = "rrl:would-" + rl.Action
}
minute, hour := c.minuteOf(rl.Time), c.hourOf(rl.Time)
view := rl.View
if view == "" {
view = "_default"
}
c.count(minute, key, 1)
c.countView(minute, view, key, 1)
hours := c.hours
if hour < c.liveHour {
hours = c.late
@@ -445,6 +496,8 @@ func (c *Collector) handleRateLimit(rl RateLimit) {
hours[hour] = c.hourMaps(hours, hour)
hours[hour][KindRateLimited][rl.Prefix]++
hours[hour][KindRateLimitedName][rl.Name+" "+rl.Type]++
c.topsMap(hours, hour, ViewKind(KindRateLimited, view))[rl.Prefix]++
c.topsMap(hours, hour, ViewKind(KindRateLimitedName, view))[rl.Name+" "+rl.Type]++
}
// policySource names what a rewrite came from: a blocklist's name, "rules"
@@ -547,6 +600,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
b.AddCounters(ResHour, c.hourOf(t), vals)
b.AddCounters(ResDay, c.dayOf(t), vals)
delete(c.minutes, m)
if c.viewsSinceAt != 0 {
b.SetMeta("views_since", strconv.FormatInt(c.viewsSinceAt, 10))
c.viewsSinceAt = 0
}
if first && len(vals) > 0 {
ft := t
c.status.FirstData = &ft
+158 -10
View File
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
)
@@ -14,6 +15,110 @@ type Querier struct {
Store *Store
Opts Options
Now func() time.Time
// Views limits the figures to these views (none: every view). Only
// what named's response log says can be split by view; named's own
// counters (cache, transport, round trips, ...) can't.
Views []string
}
// ForViews returns a querier limited to views (all views when empty).
func (q *Querier) ForViews(views []string) *Querier {
c := *q
c.Views = views
return &c
}
func (q *Querier) filtered() bool { return len(q.Views) > 0 }
// perView reports whether a counter key exists per view, and its name
// there ("queries" is counted as responses).
func perView(key string) (string, bool) {
if key == "queries" || key == "responses" {
return "responses", true
}
if key == "blocked" {
return key, true
}
for _, p := range []string{"rcode:", "qtype:", "zone:", "blocklist:", "rrl:"} {
if strings.HasPrefix(key, p) || key+":" == p {
return key, true
}
}
return key, false
}
// PerViewGroups are the Breakdown groups that exist per view.
var PerViewGroups = []string{"qtype", "rcode", "view", "zone", "blocklist", "rrl"}
// ErrNotPerView is returned for figures named only keeps for the whole server.
var ErrNotPerView = fmt.Errorf("named keeps this for the whole server only, not per view")
// sums is Store.sums, limited to the querier's views.
func (q *Querier) sums(ctx context.Context, res string, from, to int64, prefix string) (map[string]int64, error) {
if !q.filtered() {
return q.Store.sums(ctx, res, from, to, prefix)
}
if prefix == "view:" {
all, err := q.Store.sums(ctx, res, from, to, prefix)
if err != nil {
return nil, err
}
out := map[string]int64{}
for _, v := range q.Views {
if n := all["view:"+v]; n != 0 {
out["view:"+v] = n
}
}
return out, nil
}
if _, ok := perView(prefix); !ok {
// Not kept per view (named's own counters): the whole server's.
return q.Store.sums(ctx, res, from, to, prefix)
}
out := map[string]int64{}
for _, v := range q.Views {
m, err := q.Store.sums(ctx, res, from, to, ViewKey(v, prefix))
if err != nil {
return nil, err
}
for k, n := range m {
out[strings.TrimPrefix(k, ViewKey(v, ""))] += n
}
}
return out, nil
}
// series is Store.series, limited to the querier's views.
func (q *Querier) series(ctx context.Context, res string, from, to int64, key string) (map[int64]int64, error) {
pv, ok := perView(key)
if !q.filtered() || !ok {
return q.Store.series(ctx, res, from, to, key)
}
out := map[int64]int64{}
for _, v := range q.Views {
m, err := q.Store.series(ctx, res, from, to, ViewKey(v, pv))
if err != nil {
return nil, err
}
for ts, n := range m {
out[ts] += n
}
}
return out, nil
}
// ViewsSince is when per-view figures start (nil before any).
func (q *Querier) ViewsSince(ctx context.Context) *time.Time {
v, err := q.Store.Meta(ctx, "views_since")
if err != nil || v == "" {
return nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil
}
t := time.Unix(n, 0)
return &t
}
// NewQuerier returns a querier for a collector's data.
@@ -105,10 +210,15 @@ func (s *Store) series(ctx context.Context, res string, from, to int64, key stri
return out, rows.Err()
}
// tops sums top lists in [from, to).
func (s *Store) tops(ctx context.Context, res, kind string, from, to int64, limit int) ([]Entry, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind = ? AND ts >= ? AND ts < ?
GROUP BY key ORDER BY n DESC, key LIMIT ?`, res, kind, from, to, limit)
// tops sums top lists (of one or more kinds) in [from, to).
func (s *Store) tops(ctx context.Context, res string, kinds []string, from, to int64, limit int) ([]Entry, error) {
args := []any{res}
for _, k := range kinds {
args = append(args, k)
}
args = append(args, from, to, limit)
rows, err := s.db.QueryContext(ctx, `SELECT key, SUM(count) AS n FROM tops WHERE res = ? AND kind IN (`+strings.TrimSuffix(strings.Repeat("?,", len(kinds)), ",")+`) AND ts >= ? AND ts < ?
GROUP BY key ORDER BY n DESC, key LIMIT ?`, args...)
if err != nil {
return nil, err
}
@@ -167,6 +277,11 @@ type Summary struct {
BlockedRatio *float64 `json:"blocked_ratio,omitempty"`
Blocklists []Entry `json:"blocklists,omitempty"`
Tops map[string][]Entry `json:"tops,omitempty"`
// Views and ViewsSince are set when the figures are for some views
// only: queries then counts responses, and named's own counters
// (answers, transport, cache) are left out.
FilterViews []string `json:"filter_views,omitempty"`
ViewsSince *time.Time `json:"views_since,omitempty"`
}
// Summary summarizes the last since.
@@ -179,9 +294,32 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
if err != nil {
return nil, err
}
if q.filtered() {
// The chosen views' counters, under their usual names.
views := map[string]int64{}
for _, v := range q.Views {
views["view:"+v] = all["view:"+v]
}
picked := map[string]int64{}
for k, n := range all {
for _, v := range q.Views {
if rest, ok := strings.CutPrefix(k, ViewKey(v, "")); ok {
picked[rest] += n
}
}
}
for k, n := range views {
picked[k] = n
}
picked["queries"] = picked["responses"]
all = picked
}
s := &Summary{From: time.Unix(from, 0), To: now, Resolution: res, Queries: all["queries"],
RCodes: sorted(all, "rcode:"), QTypes: sorted(all, "qtype:"), Views: sorted(all, "view:"), Zones: sorted(all, "zone:"),
Answers: sorted(all, "answer:"), Transport: sorted(all, "transport:")}
if q.filtered() {
s.FilterViews, s.ViewsSince = q.Views, q.ViewsSince(ctx)
}
if h, m := all["cache:hits"], all["cache:misses"]; h+m > 0 {
r := float64(h) / float64(h+m)
s.CacheHitRatio = &r
@@ -195,7 +333,7 @@ func (q *Querier) Summary(ctx context.Context, since time.Duration, topLimit int
s.BlockedRatio = &b
}
}
per, err := q.Store.series(ctx, res, from, to, "queries")
per, err := q.series(ctx, res, from, to, "queries")
if err != nil {
return nil, err
}
@@ -237,7 +375,14 @@ func (q *Querier) Top(ctx context.Context, kind string, since time.Duration, lim
}
now := q.Now()
res := q.topsResolution(since)
return q.Store.tops(ctx, res, kind, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
kinds := []string{kind}
if q.filtered() {
kinds = nil
for _, v := range q.Views {
kinds = append(kinds, ViewKind(kind, v))
}
}
return q.Store.tops(ctx, res, kinds, q.bucketStart(res, now.Add(-since)), now.Unix()+1, limit)
}
// Breakdown returns the totals of a counter group (qtype, rcode, view,
@@ -246,9 +391,12 @@ func (q *Querier) Breakdown(ctx context.Context, group string, since time.Durati
if !slices.Contains(Groups, group) {
return nil, fmt.Errorf("unknown group %q (use %s)", group, strings.Join(Groups, ", "))
}
if q.filtered() && !slices.Contains(PerViewGroups, group) {
return nil, ErrNotPerView
}
now := q.Now()
res := q.Resolution(since)
m, err := q.Store.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
m, err := q.sums(ctx, res, q.bucketStart(res, now.Add(-since)), now.Unix()+1, group+":")
if err != nil {
return nil, err
}
@@ -329,7 +477,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
var expanded []string
for _, k := range keys {
if g, ok := strings.CutSuffix(k, ":*"); ok {
m, err := q.Store.sums(ctx, st.res, from, to, g+":")
m, err := q.sums(ctx, st.res, from, to, g+":")
if err != nil {
return nil, err
}
@@ -342,7 +490,7 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
}
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
bucketed := func(key string) ([]int64, error) {
raw, err := q.Store.series(ctx, st.res, from, to, key)
raw, err := q.series(ctx, st.res, from, to, key)
if err != nil {
return nil, err
}
@@ -404,7 +552,7 @@ func (q *Querier) Heatmap(ctx context.Context, key string, since time.Duration)
since = min(since, q.Opts.RetainHour)
now := q.Now()
from := q.bucketStart(ResHour, now.Add(-since))
raw, err := q.Store.series(ctx, ResHour, from, now.Unix()+1, key)
raw, err := q.series(ctx, ResHour, from, now.Unix()+1, key)
if err != nil {
return nil, err
}
+19
View File
@@ -264,3 +264,22 @@ func (s *Store) TopsAt(ctx context.Context, res string, ts int64, kind string) (
}
return out, rows.Err()
}
// KindsAt returns the top-list kinds stored for one bucket (per-view
// kinds included).
func (s *Store) KindsAt(ctx context.Context, res string, ts int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT kind FROM tops WHERE res = ? AND ts = ?`, res, ts)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
+103
View File
@@ -0,0 +1,103 @@
package stats
import (
"context"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
)
// Figures for chosen views: from the response log, per view, and added
// up for several.
func TestViewFilter(t *testing.T) {
ctx := context.Background()
loc := time.FixedZone("test", 0)
dir := t.TempDir()
logFile := filepath.Join(dir, "responses.log")
store, err := OpenStore(filepath.Join(dir, "stats.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
named := newFakeNamed(t)
clk := &clock{time.Date(2026, 10, 5, 10, 0, 30, 0, loc)}
opts := Options{QueryLog: true, LogFile: logFile, StatisticsChannel: "unused", TopN: 10,
RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour, RetainDay: 730 * 24 * time.Hour, Location: loc}
zones := func() map[string][]string {
return map[string][]string{"internal": {"adbraun.lol"}, "external": {"adbraun.lol"}}
}
c := NewCollector(store, opts, zones, slog.New(slog.NewTextHandler(io.Discard, nil)))
c.poller.URL = named.srv.URL
c.now = clk.now
c.start(ctx)
at := time.Date(2026, 10, 5, 10, 0, 40, 0, loc)
lines := []string{
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "A", "NOERROR"),
logLine(at, "10.10.0.37", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"),
logLine(at, "10.10.0.42", "internal", "nope.adbraun.lol", "A", "NXDOMAIN"),
logLine(at, "203.0.113.9", "external", "www.adbraun.lol", "A", "NOERROR"),
logLine(at, "203.0.113.9", "external", "x.example", "A", "REFUSED"),
logLine(at, "192.0.2.5", "vps", "debian.org", "AAAA", "NOERROR"),
}
f, _ := os.Create(logFile)
for _, l := range lines {
f.WriteString(l)
}
f.Close()
c.readLog()
named.add(6, 1)
clk.t = time.Date(2026, 10, 5, 10, 1, 2, 0, loc)
c.flush(ctx, false)
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
c.flush(ctx, false)
all := &Querier{Store: store, Opts: opts, Now: clk.now}
s, err := all.Summary(ctx, 24*time.Hour, 5)
if err != nil || s.Queries != 6 || s.FilterViews != nil {
t.Fatalf("all views: %+v %v", s, err)
}
in := all.ForViews([]string{"internal"})
s, err = in.Summary(ctx, 24*time.Hour, 5)
if err != nil {
t.Fatal(err)
}
if s.Queries != 3 || len(s.Views) != 1 || s.Views[0].Key != "internal" || s.ViewsSince == nil {
t.Errorf("internal: queries %d views %v since %v", s.Queries, s.Views, s.ViewsSince)
}
if len(s.RCodes) != 2 || s.RCodes[0] != (Entry{Key: "NOERROR", Count: 2}) || len(s.QTypes) != 2 {
t.Errorf("internal rcodes %v qtypes %v", s.RCodes, s.QTypes)
}
if s.Tops[KindName][0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) || len(s.Tops[KindNXDomain]) != 1 || len(s.Answers) != 0 {
t.Errorf("internal tops %v answers %v", s.Tops, s.Answers)
}
// Linked views, say: two together.
two := all.ForViews([]string{"internal", "external"})
s, _ = two.Summary(ctx, 24*time.Hour, 5)
if s.Queries != 5 || len(s.Views) != 2 {
t.Errorf("internal+external: %d %v", s.Queries, s.Views)
}
if es, _ := two.Top(ctx, KindClient, 24*time.Hour, 5); len(es) != 3 || es[2].Key != "10.10.0.42" {
t.Errorf("clients of two views: %v", es)
}
if es, _ := two.Breakdown(ctx, "rcode", 24*time.Hour); len(es) != 3 {
t.Errorf("rcodes of two views: %v", es)
}
if _, err := two.Breakdown(ctx, "answer", 24*time.Hour); !errors.Is(err, ErrNotPerView) {
t.Errorf("answers per view: %v", err)
}
tl, err := all.ForViews([]string{"vps"}).Timeline(ctx, 2*time.Hour, "1m", []string{"queries", "rcode:*"})
if err != nil {
t.Fatal(err)
}
var total int64
for _, v := range tl.Series[0].Values {
total += v
}
if total != 1 || len(tl.Series) != 2 || tl.Series[1].Key != "rcode:NOERROR" {
t.Errorf("vps timeline: %d %+v", total, tl.Series)
}
}
+34
View File
@@ -0,0 +1,34 @@
package validate
import (
"errors"
"io/fs"
"os"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// checkTLSFiles checks that the files a top-level tls statement names
// exist: named fails to load the whole configuration otherwise. (Files
// jarld can't look at are left to named.)
func (c *checker) checkTLSFiles() {
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
if o.Name != "tls" {
continue
}
stmt, err := namedconf.ParseOptionValue("tls", o.Value)
if err != nil || stmt.FirstBlock() == nil {
continue
}
for _, s := range stmt.FirstBlock().Stmts {
switch s.Keyword() {
case "cert-file", "key-file", "ca-file", "dhparam-file":
path := s.Arg(0)
if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) {
c.errf("tls_file_missing", "option", o.ID, "tls %s: %s %s doesn't exist, and named won't load its configuration without it (a certificate Jarl obtains is written once it has been obtained: jarl cert list)", stmt.Arg(0), s.Keyword(), path)
}
}
}
}
}
+1
View File
@@ -152,6 +152,7 @@ func State(s *model.State, opts Options) []Problem {
c.checkDNSSEC()
c.checkBlocking()
c.checkLinked()
c.checkTLSFiles()
c.applyAcks()
sortProblems(c.problems)
return c.problems
+49 -14
View File
@@ -20,6 +20,7 @@ import * as stats from "./pages/stats.js";
import * as blocking from "./pages/blocking.js";
import * as exposure from "./pages/exposure.js";
import * as certificates from "./pages/certificates.js";
import * as encdns from "./pages/encdns.js";
const routes = [
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
@@ -41,22 +42,28 @@ const routes = [
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
{ pattern: /^encrypted-dns$/, page: encdns, nav: "encdns" },
];
// The top bar: the pages used most, then a menu for the server's
// configuration and security. ["key", "#/href", "Label", "admin"?]
const navItems = [
["dashboard", "#/", "Dashboard"],
["stats", "#/stats", "Statistics"],
["zones", "#/zones", "Zones"],
["blocking", "#/blocking", "Blocking"],
["views", "#/views", "Views"],
["exposure", "#/exposure", "Exposure"],
["certificates", "#/certificates", "Certificates", "admin"],
["acls", "#/acls", "ACLs"],
["keys", "#/keys", "TSIG keys"],
["options", "#/options", "Server options"],
["blocking", "#/blocking", "Blocking"],
["changes", "#/changes", "Changes"],
["history", "#/history", "History"],
["audit", "#/audit", "Audit log"],
{ menu: "Server", items: [
["options", "#/options", "Server options"],
["acls", "#/acls", "ACLs"],
["keys", "#/keys", "TSIG keys"],
["exposure", "#/exposure", "Exposure"],
["certificates", "#/certificates", "Certificates", "admin"],
["encdns", "#/encrypted-dns", "Encrypted DNS", "admin"],
["audit", "#/audit", "Audit log"],
] },
];
const app = document.getElementById("app");
@@ -165,12 +172,16 @@ function layout() {
logo(30),
wordmark({ rule: false, height: 19, cls: "brand-name" })),
navEl,
h("div", { class: "spacer" }),
modeEl = h("span"),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
h("a", { href: "#/account", class: "user", title: (me.permissions || []).join("\n") }, me.user.username),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { class: "small", onclick: logout }, "Log out"));
// The right-hand group stays together, on the right, also when it
// has to wrap below the navigation.
h("div", { class: "header-right" },
modeEl = h("span"),
h("form", { class: "search", onsubmit: (e) => { e.preventDefault(); if (q.value.trim()) location.hash = "#/search?q=" + encodeURIComponent(q.value.trim()); } }, q),
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
h("a", { href: "#/account" }, "Account"),
me.admin ? h("a", { href: "#/users" }, "Users") : null,
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
mount(app, h("div", { class: "shell" }, header, barEl, mainEl, footer(me.build)));
modeHolder = modeEl;
renderNav();
@@ -193,9 +204,33 @@ function footer(b) {
}
function renderNav() {
mount(navEl, navItems.filter((n) => n[3] !== "admin" || state.me.admin).map(([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label)));
const allowed = (n) => n[3] !== "admin" || state.me.admin;
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
mount(navEl, navItems.map((n) => {
if (!n.menu) return allowed(n) ? link(n) : null;
const items = n.items.filter(allowed);
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
}));
}
// menu is a drop-down: a button and its links. It closes when one is
// picked, on a click elsewhere and on Escape.
function menu(label, items, opts = {}) {
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
return d;
}
document.addEventListener("click", (e) => {
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
});
// renderBar shows the pending-changes bar (only while there are pending
// changes) and the small review/live switch in the header.
function renderBar() {
@@ -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, " ",
+251
View File
@@ -0,0 +1,251 @@
// DNS over TLS and HTTPS for your clients (docs/encrypted-dns.md): what is
// 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, fmtTime, relTime } from "../ui.js";
import { api, get, change, describeResult, state } from "../api.js";
import { rerender } from "../app.js";
export async function render(el) {
if (!state.me.admin) {
mount(el, h("h1", null, "Encrypted DNS"), h("div", { class: "msg warn" }, "Only administrators can set up DNS over TLS and HTTPS."));
return;
}
const s = await get("/encrypted-dns");
const parts = [h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Encrypted DNS"),
h("div", { class: "muted" }, "DNS over TLS (DoT) and DNS over HTTPS (DoH) for your own devices: phones, laptops and browsers ask this server over an encrypted connection, and get the same answers (and blocking) as over plain DNS. Clients get the view their address matches, as with plain DNS.")),
s.enabled ? h("div", { class: "actions" }, h("button", { class: "danger", onclick: disable }, "Turn off")) : null)];
if (s.enabled) parts.push(statusPanel(s));
if (s.other) parts.push(h("div", { class: "msg info" }, "named also has DoT or DoH listeners written by hand (Server options → listen-on). The setup leaves them alone."));
parts.push(setupPanel(s));
mount(el, parts);
}
function statusPanel(s) {
const eps = (list) => list.map((e) => e.address + " port " + e.port).join(", ");
const rows = [
h("dt", null, "Certificate"), h("dd", null, h("a", { href: "#/certificates" }, s.certificate || "?"), s.hostname ? " (" + s.hostname + ")" : null),
s.dot.length ? [h("dt", null, "DNS over TLS"), h("dd", { class: "mono" }, eps(s.dot))] : null,
s.doh.length ? [h("dt", null, "DNS over HTTPS"), h("dd", { class: "mono" }, eps(s.doh))] : null,
];
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
const clients = [];
if (s.dot_host) {
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
h("ul", { class: "small" },
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
h("li", null, h("strong", null, "Test: "), h("code", null, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com"))));
}
if (s.doh_url) {
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
h("ul", { class: "small" },
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
h("li", null, h("strong", null, "Test: "), h("code", null, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + addr + " example.com"), ")")));
}
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),
];
}
function setupPanel(s) {
const host = input({ value: s.hostname || "", placeholder: "dns.adbraun.lol", class: "mono", style: { maxWidth: "320px" } });
const certSel = h("select");
const dot = checkbox("DNS over TLS", s.enabled ? s.dot.length > 0 : true);
const doh = checkbox("DNS over HTTPS", s.enabled ? s.doh.length > 0 : true);
const dotPort = input({ type: "number", value: (s.dot[0] || {}).port || 853, style: { width: "90px" } });
const dohPort = input({ type: "number", value: (s.doh[0] || {}).port || 443, style: { width: "90px" } });
const current = new Set([...s.dot, ...s.doh].map((e) => e.address));
const picked = new Set(current);
const portOut = h("div");
const err = h("div");
const progress = h("div", { class: "muted small" });
// Addresses: one per row, nothing ticked (but the current setup's).
const addrRows = s.addresses.map((a) => pickRow(a.address, picked, [a.private ? h("span", { class: "muted" }, "private")
: a.address.includes(":") ? h("span", { class: "muted", title: "A global IPv6 address: reachable from the internet unless the firewall stops it" }, "global")
: badge("public", "warn")],
"Listen on " + a.address, () => checkPorts()));
// Views for the host name's records: the private ones that have its zone.
const viewPicked = new Set();
const viewBody = h("tbody");
const drawViews = (views) => {
viewPicked.clear();
mount(viewBody, views.map((v) => {
if (!v.public && v.has_zone) viewPicked.add(v.name);
return pickRow(v.name, viewPicked, [v.public ? badge("public", "warn") : h("span", { class: "muted" }, "private"), " ",
v.has_zone ? h("span", { class: "muted small" }, "has the zone") : h("span", { class: "muted small" }, "no zone for this name: skipped")], "Add the host name to view " + v.name);
}));
};
drawViews(s.views);
const fillCerts = () => {
const name = host.value.trim().toLowerCase().replace(/\.$/, "");
const fit = s.certificates.filter((c) => covers([...(c.names || []), ...(c.domains || [])], name));
mount(certSel,
fit.map((c) => h("option", { value: c.name, selected: c.name === s.certificate }, c.name + " (" + (c.names || c.domains).join(", ") + (c.obtained ? "" : ", not obtained yet") + ")")),
name ? h("option", { value: "" }, "Obtain a new certificate for " + name) : h("option", { value: "" }, "Type the host name first"));
};
fillCerts();
let viewTimer = null;
host.addEventListener("input", () => {
fillCerts();
clearTimeout(viewTimer);
viewTimer = setTimeout(async () => {
const r = await get("/encrypted-dns", { hostname: host.value.trim() }).catch(() => null);
if (r) drawViews(r.views);
}, 300);
});
let checkTimer = null, checkSeq = 0;
const checkPorts = () => {
clearTimeout(checkTimer);
checkTimer = setTimeout(async () => {
const seq = ++checkSeq;
if (!picked.size) { mount(portOut); return; }
const r = await api("POST", "/encrypted-dns/check", { body: body(false) }).catch((e) => ({ error: e.message, conflicts: [] }));
if (seq !== checkSeq) return;
const msgs = r.conflicts.map((c) => h("div", null, c.what + " listens on " + c.address + " port " + c.port + "."));
if (r.conflicts.some((c) => c.port === Number(dohPort.value)) && r.free_doh_port) {
msgs.push(h("div", null, "Port " + r.free_doh_port + " is free for DoH: ",
h("button", { type: "button", class: "small", onclick: () => { dohPort.value = r.free_doh_port; checkPorts(); } }, "Use " + r.free_doh_port)));
}
mount(portOut, r.error ? h("div", { class: "msg warn small" }, r.error)
: msgs.length ? h("div", { class: "msg err small" }, msgs)
: h("div", { class: "msg ok small" }, "The ports are free on the chosen addresses."));
}, 250);
};
for (const x of [dotPort, dohPort]) x.addEventListener("input", checkPorts);
for (const x of [dot, doh]) x.input.addEventListener("change", checkPorts);
if (picked.size) checkPorts();
const body = (full) => {
const b = { hostname: host.value.trim(), addresses: [...picked], dot: dot.input.checked, doh: doh.input.checked, dot_port: Number(dotPort.value) || 853, doh_port: Number(dohPort.value) || 443 };
if (full) { b.certificate = certSel.value; b.views = [...viewPicked]; }
return b;
};
const submit = async (btn) => {
mount(err);
const b = body(true);
if (!b.hostname) return mount(err, h("div", { class: "msg err" }, "Give the host name clients connect to."));
if (!b.addresses.length) return mount(err, h("div", { class: "msg err" }, "Choose at least one address to listen on."));
btn.disabled = true;
try {
if (!b.certificate) {
// Obtain one for the host name, for named, and wait for it.
b.certificate = b.hostname.split(".")[0].replace(/[^a-z0-9_-]/g, "-");
mount(progress, "Obtaining a certificate for " + b.hostname + " from the CA…");
await api("POST", "/certificates", { body: { name: b.certificate, domains: [b.hostname], uses: ["dns"] } });
await waitObtained(b.certificate, progress);
} else {
const c = await get("/certificates/" + encodeURIComponent(b.certificate));
if (!c.uses.includes("dns")) await api("PATCH", "/certificates/" + encodeURIComponent(b.certificate), { body: { uses: [...c.uses, "dns"] } });
if (!c.not_after) {
if (!c.issuing_since) await api("POST", "/certificates/" + encodeURIComponent(b.certificate) + "/issue", { body: {} });
await waitObtained(b.certificate, progress);
}
}
mount(progress, "Setting up named…");
const r = await change("POST", "/encrypted-dns", b);
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) {
mount(progress);
mount(err, errorBox(e));
} finally { btn.disabled = false; }
};
const go = h("button", { class: "primary", onclick: (e) => submit(e.target) }, s.enabled ? "Change setup" : "Set up");
return h("div", { class: "panel" },
h("h2", null, s.enabled ? "Change the setup" : "Set up"),
h("div", { class: "form-grid" },
field("Host name", host, "The name clients connect to. The certificate is for it, and it gets this server's addresses in the views below."),
field("Certificate", certSel, "One with this name, or a new one Jarl obtains and renews (docs/certificates.md)."),
field("Listen on", h("div", null, h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, addrRows))), portOut),
"This server's addresses on your own networks. A public address works too, but then anyone who reaches it can use these listeners, as with port 53 there."),
field("Serve", h("div", { class: "enc-ports" }, h("div", null, dot, " on port ", dotPort), h("div", null, doh, " on port ", dohPort, h("span", { class: "muted small" }, " at /dns-query"))),
"Clients expect DoT on port 853. DoH can use any free port; 443 is the default, and needs nothing else listening there."),
field("Host name in views", h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, viewBody)),
"Where the host name gets the chosen addresses (A and AAAA), local to the view in a linked zone. Clients outside these views can't find it.")),
err, progress,
h("div", { class: "buttons" }, go));
}
// pickRow is a table row that toggles value in set when clicked.
function pickRow(value, set, extra, label, onChange) {
const box = h("input", { type: "checkbox", checked: set.has(value), "aria-label": label });
const tr = h("tr", { class: "pick" + (set.has(value) ? " selected" : "") }, h("td", null, box), h("td", { class: "mono" }, value), h("td", null, extra));
box.addEventListener("change", () => {
if (box.checked) set.add(value); else set.delete(value);
tr.classList.toggle("selected", box.checked);
if (onChange) onChange();
});
tr.addEventListener("click", (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } });
return tr;
}
function covers(names, host) {
if (!host) return false;
return names.some((n) => {
n = n.toLowerCase().replace(/\.$/, "");
if (n === host) return true;
return n.startsWith("*.") && host.split(".").slice(1).join(".") === n.slice(2);
});
}
async function waitObtained(name, progress) {
const started = Date.now();
for (;;) {
await new Promise((r) => setTimeout(r, 2500));
const c = await get("/certificates/" + encodeURIComponent(name));
if (!c.issuing_since) {
if (!c.not_after) throw new Error("Obtaining the certificate failed: " + (c.last_error || "see the Certificates page"));
return c;
}
mount(progress, "Obtaining a certificate for " + (c.domains || []).join(", ") + " from the CA… " + Math.round((Date.now() - started) / 1000) + "s (the challenges must reach every name server)");
}
}
async function disable() {
if (!await confirmDialog("Turn encrypted DNS off", "named stops serving DNS over TLS and HTTPS. The certificate and the host name's records stay.", "Turn off", "danger")) return;
try {
toast(describeResult(await change("DELETE", "/encrypted-dns")));
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);
}
+61 -15
View File
@@ -1,5 +1,6 @@
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { rerender } from "../app.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
@@ -17,6 +18,20 @@ const rttOrder = ["<10ms", "10-100ms", "100-500ms", "500-800ms", "800-1600ms", "
let generation = 0;
// The views shown are remembered per browser: the ones switched off, so a
// new view shows up switched on.
const offKey = "jarl.stats.views.off";
function viewsOff() {
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
}
function saveViewsOff(set) {
try { localStorage.setItem(offKey, JSON.stringify([...set])); } catch { /* private window: not remembered */ }
}
// Breakdowns that exist per view; the others are named's own counters,
// for the whole server.
const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
export async function render(el, params, query) {
const gen = ++generation;
const want = query && query.get ? query.get("since") : null;
@@ -33,11 +48,21 @@ export async function render(el, params, query) {
return;
}
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since;
// Which views: all of them unless some are switched off.
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
const off = viewsOff();
const shown = allViews.filter((v) => !off.has(v));
const filtered = allViews.length > 0 && shown.length < allViews.length;
const vf = filtered ? { views: shown.join(",") } : {};
if (filtered && !shown.length) {
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
return;
}
let data;
try {
data = await loadAll(since, heatSince, status.query_log);
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
} catch (e) {
mount(el, head, errorBox(e));
mount(el, head, filterRow(since, allViews, off), errorBox(e));
return;
}
if (gen !== generation) return; // the user moved on
@@ -46,13 +71,17 @@ export async function render(el, params, query) {
// sudden drop in traffic.
const timeline = complete(data.timeline), errors = complete(data.errors);
const step = stepSeconds[timeline.step];
const parts = [head, filterRow(since)];
const parts = [head, filterRow(since, allViews, off)];
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
if (filtered) {
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
(s.views_since ? ", per view since " + fmtTime(s.views_since) : "") + ". named's own figures (answer sources, upstream times, cache, the server) are for the whole server."));
}
// Headline numbers.
const rangeLabel = "in the last " + ranges.find((r) => r[0] === since)[1];
parts.push(h("div", { class: "cards" },
tile("Queries", compact(s.queries), rangeLabel),
tile(filtered ? "Answers" : "Queries", compact(s.queries), rangeLabel),
tile("Per second", s.avg_qps < 10 ? s.avg_qps.toFixed(2) : Math.round(s.avg_qps).toLocaleString(), "on average"),
s.peak ? tile("Busiest " + s.peak.per, compact(s.peak.queries), fmtTime(s.peak.time)) : null,
s.nxdomain_ratio != null ? tile("NXDOMAIN", pct(s.nxdomain_ratio), "of queries named no existing name") : null,
@@ -62,7 +91,7 @@ export async function render(el, params, query) {
// Volume over time: one series, so no legend box; the title names it.
// With blocking, a second line shows how many of them were blocked.
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
const qSeries = [{ label: filtered ? "Answers" : "Queries", values: q.values, slot: 1 }];
const bl = timeline.series.find((x) => x.key === "blocked");
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
// Failed answers: three fixed series, legend plus end labels.
@@ -71,7 +100,8 @@ export async function render(el, params, query) {
// The two charts are one above the other: the same room for labels on
// both sides keeps their time axes in line.
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
const qWord = filtered ? "Answers" : "Queries";
parts.push(chartCard(qSeries.length > 1 ? qWord + " and blocked queries" : qWord, "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
@@ -117,19 +147,20 @@ export async function render(el, params, query) {
scheduleRefresh(el, params, query, gen);
}
async function loadAll(since, heatSince, queryLog) {
async function loadAll(since, heatSince, queryLog, vf, filtered) {
const p = {
summary: get("/stats/summary", { since, top: 0 }),
timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
heat: get("/stats/heatmap", { since: heatSince }),
summary: get("/stats/summary", { since, top: 0, ...vf }),
timeline: get("/stats/timeline", { since, key: "queries,blocked", ...vf }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(","), ...vf }),
heat: get("/stats/heatmap", { since: heatSince, ...vf }),
server: get("/stats/server").catch(() => null),
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
};
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
// With some views only, named's own counters aren't asked for.
groups.forEach((g) => { p["b_" + g] = filtered && !perViewGroups.includes(g) ? Promise.resolve([]) : get("/stats/breakdown", { group: g, since, ...vf }); });
if (queryLog) {
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 });
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15, ...vf });
}
const keys = Object.keys(p);
const vals = await Promise.all(Object.values(p));
@@ -162,11 +193,26 @@ function complete(tl) {
return tl;
}
function filterRow(since) {
return h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
function filterRow(since, views, off) {
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"),
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
if (!views || views.length < 2) return range;
// A toggle per view (together: linked views, say), and all or none.
const set = (next) => { saveViewsOff(next); rerender(); };
const toggles = views.map((v) => {
const on = !off.has(v);
return h("button", { type: "button", class: "chip toggle-chip" + (on ? " on" : ""), "aria-pressed": on ? "true" : "false",
title: on ? "Leave view " + v + " out" : "Include view " + v,
onclick: () => { const n = new Set(off); if (on) n.add(v); else n.delete(v); set(n); } }, v);
});
return h("div", { class: "filter-rows" }, range,
h("div", { class: "filter-row", role: "group", "aria-label": "Views" },
h("span", { class: "muted" }, "Views"),
h("div", { class: "chips" }, toggles),
h("button", { type: "button", class: "small", disabled: off.size === 0, onclick: () => set(new Set()) }, "All"),
h("button", { type: "button", class: "small", disabled: off.size === views.length, onclick: () => set(new Set(views)) }, "None")));
}
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
+40 -2
View File
@@ -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
@@ -625,7 +661,7 @@ async function recordsTab(el, zone, query) {
const eTTL = input({ type: "number", value: r.ttl ?? "", placeholder: "default", style: { width: "90px" } });
const eData = input({ value: r.type === "TXT" && r.text !== undefined ? r.text : r.data, class: "mono", style: { width: "100%" } });
const eComment = input({ value: r.comment || "", style: { width: "100%" } });
const ePTR = checkbox("update PTR", false);
const ePTR = checkbox("update PTR", false, { title: "Keep the PTR record in the reverse zone in step: moved with a new name or address, or added if it is missing" });
const eErr = h("div");
const save = async () => {
const body = {};
@@ -638,7 +674,9 @@ async function recordsTab(el, zone, query) {
else if (eTTL.value !== "" && Number(eTTL.value) !== r.ttl) body.ttl = Number(eTTL.value);
if (eComment.value !== (r.comment || "")) body.comment = eComment.value;
if (ePTR.input.checked) body.ptr = true;
if (Object.keys(body).length === 0 || (Object.keys(body).length === 1 && body.ptr)) { editing = null; draw(); return; }
// "update PTR" alone adds a missing PTR record (the reverse zone may
// be newer than the record).
if (Object.keys(body).length === 0) { editing = null; draw(); return; }
try {
const res = await change("PATCH", "/records/" + r.id, body);
toast(describeResult(res));
+49
View File
@@ -518,3 +518,52 @@ td textarea.code { min-width: min(72ch, 50vw); }
table.pick-list tr.pick { cursor: pointer; }
table.pick-list tr.pick td:first-child { width: 2em; }
table.pick-list tr.pick.selected { background: var(--accent-soft); }
/* Encrypted DNS: a protocol per line with its port. */
.enc-ports > div { margin: 2px 0; }
.enc-ports input[type=number] { display: inline-block; }
/* Statistics: views switched on and off. */
.filter-rows { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.filter-rows .filter-row { margin-bottom: 0; }
.filter-row .chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip.toggle-chip { opacity: 0.55; font-size: 13px; padding: 3px 12px; }
.chip.toggle-chip.on { opacity: 1; text-decoration: none; background: var(--accent-soft); border-color: var(--accent); }
/* Drop-down menus in the top bar (Server, the user). */
details.menu { position: relative; }
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
details.menu > summary::-webkit-details-marker { display: none; }
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
details.menu > summary .user { color: inherit; }
details.menu .menu-list {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
display: flex; flex-direction: column; padding: 4px;
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
details.menu.right .menu-list { left: auto; right: 0; }
details.menu .menu-list a, details.menu .menu-list .menu-item {
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
}
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
nav.main { align-items: center; }
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
@media (max-width: 1200px) {
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); }
+49
View File
@@ -377,6 +377,15 @@ run "user delete" 0 $J user delete certbot -y
run "audit" 0 $J audit --limit 10
contains "token.revoke"
# A reverse zone created after the record: "update PTR" alone adds it.
run "an address record" 0 $J record add adbraun.test ldn A 192.0.2.154
run "its /32 reverse zone, afterwards" 0 $J zone create 192.0.2.154/32
LDN=$($J record list adbraun.test --name ldn -o id)
run "record update --ptr adds the missing PTR" 0 $J record update $LDN --ptr
expect_dig "the PTR is served" "ldn.adbraun.test." -x 192.0.2.154
run "remove the reverse zone again" 0 $J zone delete 154.2.0.192.in-addr.arpa -y
run "and the record" 0 $J record delete adbraun.test ldn A -y
# Views
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
run "view create external" 0 $J view create external --match-clients any
@@ -409,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
@@ -764,6 +782,37 @@ if [ -n "$PEBBLE" ]; then
run "a second certificate for the web UI is refused (exit 6)" 6 $J cert create other --domain nas.adbraun.test --use web
run "certificate metrics" 0 curl -sf http://127.0.0.1:$HTTP/metrics
contains '^jarl_certificate_failing{certificate="ns1",source="acme"} 0$'
# Encrypted DNS set up by Jarl (docs/encrypted-dns.md), next to the
# hand-written listener above.
EDOT=${EDOT_PORT:-25855}; EDOH=${EDOH_PORT:-25856}
run "encrypted-dns: off" 0 $J encrypted-dns
contains "Encrypted DNS: *off"
run "encrypted-dns check: the ports are free" 0 $J encrypted-dns check --address 127.0.0.1 --doh-port $EDOH
contains "The ports are free"
run "encrypted-dns check: jarld's port is taken" 0 $J encrypted-dns check --address 127.0.0.1 --doh-port $HTTP
contains "jarld (Jarl's web UI and API) listens there"
run "encrypted-dns setup" 0 $J encrypted-dns setup --hostname ns1.adbraun.test --address 127.0.0.1 --dot-port $EDOT --doh-port $EDOH
run "status: on, with what to give clients" 0 $J encrypted-dns
contains "DNS over TLS: *127.0.0.1 port $EDOT"
contains "For DoH clients: *https://ns1.adbraun.test:$EDOH/dns-query"
run "DoT through the setup" 0 $DIG +tls +tls-ca=$E/pebble-root.pem +tls-hostname=ns1.adbraun.test +short -p $EDOT @127.0.0.1 nas.adbraun.test A
contains "^192.168.1.20$"
run "DoH through the setup" 0 $DIG +https +tls-ca=$E/pebble-root.pem +tls-hostname=ns1.adbraun.test +short -p $EDOH @127.0.0.1 nas.adbraun.test A
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
run "encrypted-dns: off again" 0 $J encrypted-dns
contains "Encrypted DNS: *off"
dot_id=$($J option list -o field=id,name,value | awk -F'\t' '$2 == "listen-on" && /tls e2e/ {print $1}')
run "named: remove the DoT listener" 0 $J option unset --id "$dot_id"
run "named: remove the tls block" 0 $J option unset tls --toplevel