Add backups, token expiry warnings and name server checks

- Daily database backups: jarl-backup.timer runs 'jarld backup', which
  writes jarl-<UTC time>.db into backup.dir, checks it before naming it,
  and prunes by the time in the name while always keeping the newest
  keep_at_least. New backup: section in jarl.yaml; the dashboard and
  'jarl status' show the newest backup and warn when it is stale or
  failed. The Debian package enables the timer.
- 'jarld restore [FILE | latest]': checks the backup, shows it next to
  the current database, keeps the current one aside and records the
  restore. 'jarld serve' holds a database lock, so a restore can't run
  under a live jarld and two jarlds can't share a database.
- Token expiry warnings from 14 days ahead: dashboard, token lists, a
  daily log line, and a CLI warning via the Jarl-Token-Expires header;
  'jarl token list --expiring'.
- 'jarl zone serials' and the zone page's Name servers tab: compare the
  serial on a primary zone's secondaries (NS records, also-notify) or a
  secondary zone's primaries with this server's.
- 'jarl zone delegation': for zones on the internet, check the parent's
  delegation and glue against the zone; an undelegated zone gets a note.
- Server: Jarl/<version> header; CLI exit code 2 for failed checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-08 19:02:34 +00:00
1 parent 1afed741bf
commit 293e6ddd33
47 files changed
+3717 -95

No files matched your search

+52
View File
@@ -7,6 +7,7 @@ import (
"time"
"jarl/internal/model"
"jarl/internal/nscheck"
)
// Version is the API path prefix.
@@ -151,6 +152,34 @@ type StatusResponse struct {
Version string `json:"version"`
Engine json.RawMessage `json:"engine"`
Time time.Time `json:"time"`
// ExpiringTokens are tokens that expire within TokenExpiryWarning or
// expired less than that long ago (not revoked): every user's for
// administrators, otherwise the caller's own.
ExpiringTokens []Token `json:"expiring_tokens"`
// Backup is the state of database backups (administrators only).
Backup *BackupStatus `json:"backup,omitempty"`
}
// TokenExpiresHeader is sent with responses to a token that expires
// soon (model.TokenExpiryWarning), holding its expiry time (RFC 3339). The
// CLI warns its user when it sees it.
const TokenExpiresHeader = "Jarl-Token-Expires"
// BackupStatus describes the newest database backup, as recorded by
// 'jarld backup'.
type BackupStatus struct {
LastAt *time.Time `json:"last_at,omitempty"`
LastFile string `json:"last_file,omitempty"`
LastSize int64 `json:"last_size,omitempty"`
LastError string `json:"last_error,omitempty"`
// LastErrorAt is when the newest failed backup ran (only reported
// when it is newer than the last success).
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
Dir string `json:"dir"`
// WarnAfter (seconds) is how old the newest backup may get before
// Stale is set (0: never).
WarnAfter int64 `json:"warn_after"`
Stale bool `json:"stale"`
}
// SearchResult is one search hit.
@@ -215,3 +244,26 @@ type ZoneImport struct {
// UseSOA also takes the SOA timers and default TTL from the file.
UseSOA bool `json:"use_soa,omitempty"`
}
// SerialsResponse is GET /zones/{zone}/serials.
type SerialsResponse struct {
Zone string `json:"zone"`
View string `json:"view,omitempty"`
Type string `json:"type"`
// Serial is what this server serves: named's loaded serial, or for a
// primary zone Jarl's when named can't be asked (SerialSource says
// which).
Serial *uint32 `json:"serial"`
SerialSource string `json:"serial_source,omitempty"`
Servers []nscheck.Result `json:"servers"`
Notes []string `json:"notes"`
CheckedAt time.Time `json:"checked_at"`
}
// DelegationResponse is GET /zones/{zone}/delegation.
type DelegationResponse struct {
*nscheck.DelegationReport
View string `json:"view,omitempty"`
Notes []string `json:"notes"`
CheckedAt time.Time `json:"checked_at"`
}
+256
View File
@@ -0,0 +1,256 @@
// Package backup writes, checks, lists and prunes copies of Jarl's SQLite
// databases ('jarld backup' and 'jarld restore').
//
// A backup made into the backup directory is named after its kind and the
// UTC time it was made, e.g. jarl-20261008T031512Z.db (the configuration
// database) or stats-20261008T031512Z.db (query statistics). Pruning goes
// by the time in the name, not the file's modification time (which a copy
// or a restore of the directory changes), and only ever touches files
// named like this: anything else in the directory is left alone.
package backup
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"time"
_ "modernc.org/sqlite" // registers the "sqlite" driver
)
// Kinds of database.
const (
KindJarl = "jarl"
KindStats = "stats"
)
const stamp = "20060102T150405Z"
var nameRe = regexp.MustCompile(`^(jarl|stats)-([0-9]{8}T[0-9]{6}Z)\.db$`)
// File is a backup in the backup directory.
type File struct {
Path string `json:"path"`
Kind string `json:"kind"`
Time time.Time `json:"time"`
Size int64 `json:"size"`
}
// Name is the file name of a backup of kind made at t.
func Name(kind string, t time.Time) string {
return kind + "-" + t.UTC().Format(stamp) + ".db"
}
// parse returns the kind and time in a backup's file name.
func parse(name string) (kind string, t time.Time, ok bool) {
m := nameRe.FindStringSubmatch(name)
if m == nil {
return "", time.Time{}, false
}
t, err := time.Parse(stamp, m[2])
return m[1], t, err == nil
}
// Write makes a consistent copy of the live SQLite database src as
// dir/Name(kind, now), and checks the copy before giving it its final
// name: a file with a backup's name is always a complete, intact backup.
func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, error) {
if _, err := os.Stat(src); err != nil {
return nil, err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
final := filepath.Join(dir, Name(kind, now))
if _, err := os.Lstat(final); err == nil {
return nil, fmt.Errorf("%s already exists", final)
}
tmp := filepath.Join(dir, "."+Name(kind, now)+".tmp")
os.Remove(tmp)
if err := Copy(ctx, src, tmp); err != nil {
os.Remove(tmp)
return nil, err
}
if err := Verify(ctx, tmp); err != nil {
os.Remove(tmp)
return nil, fmt.Errorf("the new backup failed its check: %w", err)
}
if err := syncFile(tmp); err != nil {
os.Remove(tmp)
return nil, err
}
if err := os.Rename(tmp, final); err != nil {
os.Remove(tmp)
return nil, err
}
syncFile(dir)
fi, err := os.Stat(final)
if err != nil {
return nil, err
}
return &File{Path: final, Kind: kind, Time: now.UTC().Truncate(time.Second), Size: fi.Size()}, nil
}
// Copy writes a consistent copy of the live SQLite database src to dst
// (which must not exist) with SQLite's VACUUM INTO, which is safe while
// jarld uses the database. dst is created with mode 0600: the database
// holds TSIG secrets and password hashes.
func Copy(ctx context.Context, src, dst string) error {
// mode=rw: never create src if it's missing.
db, err := sql.Open("sqlite", "file:"+escape(src)+"?mode=rw&_pragma=busy_timeout(10000)")
if err != nil {
return err
}
defer db.Close()
old := syscall.Umask(0o077)
_, err = db.ExecContext(ctx, `VACUUM INTO ?`, dst)
syscall.Umask(old)
if err != nil {
return err
}
return os.Chmod(dst, 0o600)
}
// Verify checks that path is an intact SQLite database.
func Verify(ctx context.Context, path string) error {
db, err := openCopy(path)
if err != nil {
return err
}
defer db.Close()
var res string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&res); err != nil {
return err
}
if res != "ok" {
return fmt.Errorf("integrity check: %s", res)
}
return nil
}
// openCopy opens a backup read-only. immutable=1 tells SQLite the file
// can't change, so it takes no locks and writes nothing next to it.
func openCopy(path string) (*sql.DB, error) {
if _, err := os.Stat(path); err != nil {
return nil, err
}
return sql.Open("sqlite", "file:"+escape(path)+"?mode=ro&immutable=1")
}
// Info describes the contents of a configuration database (KindJarl).
type Info struct {
Schema int `json:"schema"`
Zones int `json:"zones"`
Records int `json:"records"`
Users int `json:"users"`
LastChange *time.Time `json:"last_change,omitempty"`
}
// Inspect reads a configuration database's schema version and size. It
// works on backups and, read-only, on the live database.
func Inspect(ctx context.Context, path string) (*Info, error) {
db, err := openCopy(path)
if err != nil {
return nil, err
}
defer db.Close()
in := &Info{}
if err := db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&in.Schema); err != nil {
return nil, fmt.Errorf("%s doesn't look like a Jarl database: %w", path, err)
}
db.QueryRowContext(ctx, `SELECT count(*) FROM zones`).Scan(&in.Zones)
db.QueryRowContext(ctx, `SELECT count(*) FROM records`).Scan(&in.Records)
db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&in.Users)
var last sql.NullString
db.QueryRowContext(ctx, `SELECT max(applied_at) FROM changesets`).Scan(&last)
if t, err := time.Parse(time.RFC3339Nano, last.String); err == nil {
in.LastChange = &t
}
return in, nil
}
// List returns the backups in dir, newest first. A missing directory has
// none.
func List(dir string) ([]File, error) {
ents, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var out []File
for _, e := range ents {
kind, t, ok := parse(e.Name())
if !ok || !e.Type().IsRegular() {
continue
}
f := File{Path: filepath.Join(dir, e.Name()), Kind: kind, Time: t}
if fi, err := e.Info(); err == nil {
f.Size = fi.Size()
}
out = append(out, f)
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) })
return out, nil
}
// Prune deletes backups of kind in dir that are older than retention,
// always keeping the newest keepAtLeast of them however old they are, so
// a backup that stopped running for a while never leaves you with none.
// retention 0 keeps everything. It returns the deleted files.
func Prune(dir, kind string, now time.Time, retention time.Duration, keepAtLeast int) ([]File, error) {
if retention <= 0 {
return nil, nil
}
all, err := List(dir)
if err != nil {
return nil, err
}
var removed []File
n := 0
for _, f := range all {
if f.Kind != kind {
continue
}
n++
if n <= keepAtLeast || now.Sub(f.Time) <= retention {
continue
}
if err := os.Remove(f.Path); err != nil {
return removed, err
}
removed = append(removed, f)
}
// Temporary files left by a backup that was killed halfway.
ents, _ := os.ReadDir(dir)
for _, e := range ents {
if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".db.tmp") {
if fi, err := e.Info(); err == nil && now.Sub(fi.ModTime()) > 24*time.Hour {
os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return removed, nil
}
func syncFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return f.Sync()
}
// escape makes a path safe inside a file: URI.
func escape(path string) string {
return (&url.URL{Path: path}).EscapedPath()
}
+151
View File
@@ -0,0 +1,151 @@
package backup
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"jarl/internal/store"
)
func newDB(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "jarl.db")
st, err := store.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return path
}
func TestWriteVerifyInspect(t *testing.T) {
ctx := context.Background()
src := newDB(t)
dir := filepath.Join(t.TempDir(), "backups")
now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC)
f, err := Write(ctx, src, dir, KindJarl, now)
if err != nil {
t.Fatal(err)
}
if filepath.Base(f.Path) != "jarl-20261008T031512Z.db" || f.Size == 0 {
t.Errorf("backup = %+v", f)
}
if fi, _ := os.Stat(f.Path); fi.Mode().Perm() != 0o600 {
t.Errorf("mode %v", fi.Mode())
}
if err := Verify(ctx, f.Path); err != nil {
t.Error(err)
}
in, err := Inspect(ctx, f.Path)
if err != nil || in.Schema != store.SchemaVersion() {
t.Errorf("inspect: %+v %v", in, err)
}
// The same second again is refused rather than overwritten.
if _, err := Write(ctx, src, dir, KindJarl, now); err == nil {
t.Error("overwrote a backup")
}
// No temporary files are left.
ents, _ := os.ReadDir(dir)
if len(ents) != 1 {
t.Errorf("files: %v", ents)
}
// A missing database is an error, and isn't created.
missing := filepath.Join(t.TempDir(), "nope.db")
if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second)); err == nil {
t.Error("backed up a missing database")
}
if _, err := os.Stat(missing); err == nil {
t.Error("created the missing database")
}
}
func TestVerifyCorrupt(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "bad.db")
os.WriteFile(path, []byte("SQLite format 3\x00 but not really a database, just text that goes on"), 0o600)
if err := Verify(ctx, path); err == nil {
t.Error("a corrupt file passed")
}
if _, err := Inspect(ctx, path); err == nil {
t.Error("inspected a corrupt file")
}
}
func TestPrune(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 10, 8, 3, 0, 0, 0, time.UTC)
touch := func(name string) {
os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600)
}
// Daily backups from 0 to 39 days old, plus files that aren't ours.
for d := 0; d < 40; d++ {
touch(Name(KindJarl, now.Add(-time.Duration(d)*24*time.Hour)))
}
touch(Name(KindStats, now.Add(-100*24*time.Hour)))
touch("jarl-before-upgrade.db")
touch("jarl-20200101T000000Z.db.bak")
touch("notes.txt")
removed, err := Prune(dir, KindJarl, now, 30*24*time.Hour, 7)
if err != nil {
t.Fatal(err)
}
if len(removed) != 9 { // 31..39 days old
t.Errorf("removed %d: %v", len(removed), removed)
}
files, _ := List(dir)
jarl := 0
for _, f := range files {
if f.Kind == KindJarl {
jarl++
if now.Sub(f.Time) > 30*24*time.Hour {
t.Errorf("kept %s", f.Path)
}
}
}
if jarl != 31 {
t.Errorf("%d jarl backups left", jarl)
}
for _, name := range []string{"jarl-before-upgrade.db", "jarl-20200101T000000Z.db.bak", "notes.txt", Name(KindStats, now.Add(-100*24*time.Hour))} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("%s was deleted", name)
}
}
// After a long outage every backup is old: the newest keepAtLeast stay.
removed, _ = Prune(dir, KindJarl, now.Add(365*24*time.Hour), 30*24*time.Hour, 7)
files, _ = List(dir)
n := 0
for _, f := range files {
if f.Kind == KindJarl {
n++
}
}
if n != 7 || len(removed) != 24 {
t.Errorf("after a year: %d left, %d removed", n, len(removed))
}
// Retention 0 keeps everything.
if removed, _ := Prune(dir, KindJarl, now.Add(3650*24*time.Hour), 0, 1); len(removed) != 0 {
t.Errorf("retention 0 removed %v", removed)
}
}
func TestPruneTempFiles(t *testing.T) {
dir := t.TempDir()
now := time.Now()
old := filepath.Join(dir, "."+Name(KindJarl, now.Add(-48*time.Hour))+".tmp")
fresh := filepath.Join(dir, "."+Name(KindJarl, now)+".tmp")
os.WriteFile(old, nil, 0o600)
os.WriteFile(fresh, nil, 0o600)
os.Chtimes(old, now.Add(-48*time.Hour), now.Add(-48*time.Hour))
Prune(dir, KindJarl, now, 24*time.Hour, 1)
if _, err := os.Stat(old); err == nil {
t.Error("old temporary file kept")
}
if _, err := os.Stat(fresh); err != nil {
t.Error("a running backup's temporary file was deleted")
}
}
+8
View File
@@ -38,6 +38,9 @@ type Client struct {
Preflight bool
// Mutated records that a preflight run sent a (dry-run) change.
Mutated bool
// OnTokenExpiry, if set, is called with the token's expiry time when
// jarld says the token expires soon (api.TokenExpiresHeader).
OnTokenExpiry func(time.Time)
}
// ErrNoPreflight is returned in Preflight mode for a request that would
@@ -189,6 +192,11 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
return err
}
defer resp.Body.Close()
if v := resp.Header.Get(api.TokenExpiresHeader); v != "" && c.OnTokenExpiry != nil {
if t, err := time.Parse(time.RFC3339, v); err == nil {
c.OnTokenExpiry(t)
}
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
+52 -1
View File
@@ -31,10 +31,28 @@ type Config struct {
Session Session `yaml:"session"`
BIND BIND `yaml:"bind"`
Audit Audit `yaml:"audit"`
Backup Backup `yaml:"backup"`
Stats Stats `yaml:"stats"`
ACME ACME `yaml:"acme"`
}
// Backup configures 'jarld backup' run without a file name, which is what
// the jarl-backup.timer systemd timer runs (daily by default; the schedule
// is the timer's OnCalendar=). Each run writes a new backup into Dir and
// then deletes the ones Retention and KeepAtLeast no longer want.
type Backup struct {
Dir string `yaml:"dir"`
// Retention deletes backups older than this (0 keeps them all)...
Retention Duration `yaml:"retention"`
// ...but the newest KeepAtLeast are always kept, however old.
KeepAtLeast int `yaml:"keep_at_least"`
// Stats also backs up the query statistics database.
Stats bool `yaml:"stats"`
// WarnAfter makes the dashboard warn when the newest backup is older
// than this (0: never). Keep it above the timer's interval.
WarnAfter Duration `yaml:"warn_after"`
}
// ACME configures the ACME helpers.
type ACME struct {
HTTPReq HTTPReq `yaml:"httpreq"`
@@ -197,7 +215,8 @@ func Default() *Config {
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
TopN: 100,
},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}},
Backup: Backup{Dir: "/var/backups/jarl", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7, WarnAfter: Duration(2 * 24 * time.Hour)},
}
}
@@ -251,6 +270,9 @@ func (c *Config) check() error {
if err := c.Stats.check(); err != nil {
return fmt.Errorf("stats: %w", err)
}
if err := c.Backup.check(); err != nil {
return fmt.Errorf("backup: %w", err)
}
switch c.Log.Format {
case "text", "json":
default:
@@ -259,6 +281,22 @@ func (c *Config) check() error {
return nil
}
func (b *Backup) check() error {
if b.Dir == "" || !filepath.IsAbs(b.Dir) {
return fmt.Errorf("dir must be an absolute path")
}
if b.Retention < 0 || (b.Retention > 0 && b.Retention < Duration(24*time.Hour)) {
return fmt.Errorf("retention must be at least 1d (or 0 to keep every backup)")
}
if b.KeepAtLeast < 1 {
return fmt.Errorf("keep_at_least must be at least 1")
}
if b.WarnAfter < 0 || (b.WarnAfter > 0 && b.WarnAfter < Duration(time.Hour)) {
return fmt.Errorf("warn_after must be at least 1h (or 0 for no warning)")
}
return nil
}
func (s *Stats) check() error {
if !s.Enabled {
return nil
@@ -391,6 +429,19 @@ audit:
# Delete audit events older than this many days (0 keeps them forever).
retention_days: 0
# Database backups (docs/operations.md#backups). The jarl-backup.timer
# systemd timer runs 'jarld backup' daily; change when with
# 'systemctl edit jarl-backup.timer'. Each run writes
# DIR/jarl-<UTC time>.db, checks it, then deletes backups older than
# 'retention', always keeping the newest 'keep_at_least'. Only files named
# like that are ever deleted. Restore with 'jarld restore'.
backup:
dir: /var/backups/jarl # the timer's sandbox may write only here (see the docs to move it)
retention: 30d # 0 keeps every backup
keep_at_least: 7
stats: false # also back up the statistics database
warn_after: 2d # dashboard warning when the newest backup is older (0: never)
# Query statistics (docs/stats.md). Off by default. When enabled, Jarl makes
# named publish its counters on a local statistics channel and log every
# response, and jarld aggregates both into a separate database. Changing
+14
View File
@@ -1190,3 +1190,17 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
}
return work, base, nil
}
// LoadedSerial returns the serial named serves for zone z, from rndc
// zonestatus. ok is false when named isn't asked (no_reload) or doesn't
// report a serial (the zone isn't loaded yet).
func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zone) (serial uint32, ok bool, err error) {
if e.cfg.NoReload || e.tools == nil {
return 0, false, nil
}
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
if err != nil {
return 0, false, err
}
return zs.Serial, zs.HasSerial, nil
}
+10
View File
@@ -97,6 +97,16 @@ type Token struct {
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
}
// TokenExpiryWarning is how long before a token expires Jarl starts to
// warn about it: on the dashboard, in jarld's log, and to the CLI using it.
const TokenExpiryWarning = 14 * 24 * time.Hour
// ExpiresWithin reports whether the token is still in use (not revoked)
// and expires within d from now. A token that has already expired counts.
func (t *Token) ExpiresWithin(now time.Time, d time.Duration) bool {
return t.RevokedAt == nil && t.ExpiresAt != nil && t.ExpiresAt.Sub(now) < d
}
// IssuedAt is when the token's current secret was made.
func (t *Token) IssuedAt() time.Time {
if t.RotatedAt != nil {
+449
View File
@@ -0,0 +1,449 @@
package nscheck
import (
"context"
"fmt"
"net/netip"
"sort"
"strings"
"sync"
"github.com/miekg/dns"
)
// Finding is one conclusion of a delegation check. Level is "ok",
// "info", "warning" or "error".
type Finding struct {
Level string `json:"level"`
Message string `json:"message"`
}
// ParentAnswer is what one of the parent zone's servers says.
type ParentAnswer struct {
Name string `json:"name"`
Address string `json:"address,omitempty"`
// Status: "delegated", "not_delegated" (NXDOMAIN, or the name exists
// without a delegation), "unreachable" or "error".
Status string `json:"status"`
NS []string `json:"ns,omitempty"`
// Glue is the addresses the parent hands out for in-zone name
// servers.
Glue map[string][]string `json:"glue,omitempty"`
Error string `json:"error,omitempty"`
}
// GlueCheck compares the parent's glue for an in-zone name server with
// Jarl's addresses for it.
type GlueCheck struct {
Name string `json:"name"`
Parent []string `json:"parent"`
Jarl []string `json:"jarl"`
Match bool `json:"match"`
}
// DelegationReport is the result of Delegation.
type DelegationReport struct {
Zone string `json:"zone"`
Parent string `json:"parent,omitempty"`
Resolver string `json:"resolver"`
// Delegated: at least one parent server delegates the zone.
Delegated bool `json:"delegated"`
ParentNS []string `json:"parent_ns"`
JarlNS []string `json:"jarl_ns"`
Parents []ParentAnswer `json:"parent_servers"`
Glue []GlueCheck `json:"glue"`
// Servers are the delegated name servers' SOA answers, as resolvers
// on the internet would reach them.
Servers []Result `json:"servers"`
Findings []Finding `json:"findings"`
}
// Delegation checks how the parent zone delegates zone: it finds the
// parent through the resolver, asks each of the parent's servers for the
// zone's NS records and glue, compares those with jarlNS and jarlAddrs
// (Jarl's NS records and its addresses for in-zone name servers), and
// asks every delegated server for the zone's serial (compared with ref).
func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string, jarlAddrs map[string][]string, ref *uint32) *DelegationReport {
zone = dns.CanonicalName(zone)
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
sort.Strings(rep.JarlNS)
add := func(level, format string, args ...any) {
for i, a := range args {
switch v := a.(type) {
case string:
args[i] = show(v)
case []string:
args[i] = showAll(v)
}
}
rep.Findings = append(rep.Findings, Finding{level, fmt.Sprintf(format, args...)})
}
res, err := c.resolver()
if err != nil {
add("error", "No resolver to find the parent zone with: %v", err)
return rep
}
rep.Resolver = res
if zone == "." {
add("info", "The root zone has no parent to delegate it.")
return rep
}
// The parent zone: the zone that holds the name just above ours.
parent, err := c.enclosingZone(ctx, res, parentName(zone))
if err != nil {
add("error", "Couldn't find the parent zone of %s through %s: %v", zone, res, err)
return rep
}
rep.Parent = parent
pns, err := c.nsNames(ctx, res, parent)
if err != nil || len(pns) == 0 {
add("error", "Couldn't find the name servers of the parent zone %s through %s: %v", parent, res, errOr(err, "none listed"))
return rep
}
// Ask every parent server, in parallel.
var wg sync.WaitGroup
answers := make([]ParentAnswer, len(pns))
for i, name := range pns {
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
answers[i] = c.askParent(ctx, zone, name)
}(i, name)
}
wg.Wait()
rep.Parents = answers
parentNS := map[string]bool{}
glue := map[string]map[string]bool{}
reached, delegated, notDelegated := 0, 0, 0
nsSets := map[string]bool{}
for _, a := range answers {
switch a.Status {
case "delegated":
reached++
delegated++
nsSets[strings.Join(a.NS, " ")] = true
for _, n := range a.NS {
parentNS[n] = true
}
for n, addrs := range a.Glue {
if glue[n] == nil {
glue[n] = map[string]bool{}
}
for _, ad := range addrs {
glue[n][ad] = true
}
}
case "not_delegated":
reached++
notDelegated++
}
}
if reached == 0 {
add("error", "None of the parent zone's servers (%s) answered; this server may not be able to reach them.", pns)
return rep
}
if delegated == 0 {
rep.Delegated = false
where := "the parent zone " + show(parent)
if parent == "." {
where = "the root zone"
}
add("info", "%s isn't delegated from %s, so the internet can't see it. That's expected for a zone used only on your own network. To publish it, register the domain and give your registrar these name servers: %s.", zone, where, rep.JarlNS)
return rep
}
rep.Delegated = true
if notDelegated > 0 {
add("warning", "%d of the parent's %d servers don't know the delegation yet (a recent change at the registrar can take a while to reach them all).", notDelegated, reached)
}
if len(nsSets) > 1 {
add("warning", "The parent's servers give different name server lists for %s.", zone)
}
rep.ParentNS = keys(parentNS)
// The NS lists.
jarlSet := map[string]bool{}
for _, n := range rep.JarlNS {
jarlSet[n] = true
}
var missing, extra []string
for _, n := range rep.JarlNS {
if !parentNS[n] {
missing = append(missing, n)
}
}
for _, n := range rep.ParentNS {
if !jarlSet[n] {
extra = append(extra, n)
}
}
if len(missing) == 0 && len(extra) == 0 {
add("ok", "The parent delegates %s to the same name servers as its NS records: %s.", zone, rep.ParentNS)
}
if len(extra) > 0 {
add("warning", "The parent delegates to %s, which the zone's NS records don't list. Resolvers ask them too; remove them at the registrar if they no longer serve the zone, or add NS records for them.", extra)
}
if len(missing) > 0 {
add("warning", "The zone's NS records list %s, which the parent doesn't delegate to. Resolvers that start from the parent won't use them; add them at the registrar.", missing)
}
// Glue for name servers inside the zone.
for _, n := range rep.ParentNS {
if !dns.IsSubDomain(zone, n) {
continue
}
g := GlueCheck{Name: n, Parent: keys(glue[n]), Jarl: canonAddrs(jarlAddrs[n])}
g.Match = len(g.Parent) > 0 && strings.Join(g.Parent, " ") == strings.Join(g.Jarl, " ")
rep.Glue = append(rep.Glue, g)
switch {
case len(g.Parent) == 0:
add("error", "The parent has no glue address for %s, which is inside the zone, so resolvers can't find it. Add its address at the registrar (%s).", n, strings.Join(g.Jarl, ", "))
case len(g.Jarl) == 0:
add("warning", "The parent's glue for %s is %s, but the zone has no address record for it.", n, strings.Join(g.Parent, ", "))
case !g.Match:
add("warning", "The parent's glue for %s is %s, but the zone says %s. Update the glue at the registrar.", n, strings.Join(g.Parent, ", "), strings.Join(g.Jarl, ", "))
}
}
// Every delegated server, reached the way resolvers reach it: by its
// glue, or by looking its name up.
var targets []Target
for _, n := range rep.ParentNS {
t := Target{Name: n, Source: "delegation"}
if g := keys(glue[n]); len(g) > 0 {
t.Addresses = g
}
targets = append(targets, t)
}
// Here every address is asked, this server's too: what matters is
// what it answers from the outside.
pub := *c
pub.IsLocal = func(netip.Addr) bool { return false }
rep.Servers = pub.Serials(ctx, zone, ref, targets)
// One finding per server and outcome, however many addresses it has.
type outcome struct{ name, status, detail string }
var order []outcome
addrs := map[outcome][]string{}
bad := 0
for _, r := range rep.Servers {
var o outcome
switch r.Status {
case "in_sync", "answered", "no_route":
continue
case "behind", "ahead":
o = outcome{r.Name, r.Status, fmt.Sprintf("serves serial %d, not %d", *r.Serial, *ref)}
case "not_authoritative":
o = outcome{r.Name, r.Status, ""}
default:
o = outcome{r.Name, r.Status, describe(r)}
}
bad++
if addrs[o] == nil {
order = append(order, o)
}
addrs[o] = append(addrs[o], nonEmpty(r.Address, "no address"))
}
for _, o := range order {
at := strings.Join(addrs[o], ", ")
if len(addrs[o]) > 2 {
at = fmt.Sprintf("all %d addresses", len(addrs[o]))
}
if o.status == "not_authoritative" {
add("error", "%s (%s) is delegated the zone but doesn't serve it (a lame delegation).", o.name, at)
} else {
add("warning", "%s (%s): %s.", o.name, at, o.detail)
}
}
if bad == 0 && len(rep.Servers) > 0 {
add("ok", "Every delegated name server answers for the zone with the current serial.")
}
return rep
}
// askParent asks one of the parent's servers (by name) for zone's NS.
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
pa := ParentAnswer{Name: server}
addrs, err := c.lookup(ctx, server)
if err != nil || len(addrs) == 0 {
pa.Status, pa.Error = "error", "looking up its address: "+errOr(err, "no address").Error()
return pa
}
m := new(dns.Msg)
m.SetQuestion(zone, dns.TypeNS)
m.RecursionDesired = false
// IPv4 first: try each address until one answers.
sort.SliceStable(addrs, func(i, j int) bool { return !strings.Contains(addrs[i], ":") && strings.Contains(addrs[j], ":") })
var resp *dns.Msg
for _, a := range addrs {
pa.Address = a
resp, _, err = c.exchange(ctx, m.Copy(), a, nil)
if err == nil {
break
}
}
if err != nil {
pa.Status, pa.Error = "unreachable", err.Error()
return pa
}
if resp.Rcode == dns.RcodeNameError {
pa.Status = "not_delegated"
return pa
}
if resp.Rcode != dns.RcodeSuccess {
pa.Status, pa.Error = "error", "answered "+dns.RcodeToString[resp.Rcode]
return pa
}
// A referral puts the NS set in the authority section; a server that
// also serves the child zone answers it directly.
ns := map[string]bool{}
for _, sec := range [][]dns.RR{resp.Ns, resp.Answer} {
for _, rr := range sec {
if n, ok := rr.(*dns.NS); ok && dns.CanonicalName(n.Hdr.Name) == zone {
ns[dns.CanonicalName(n.Ns)] = true
}
}
}
if len(ns) == 0 {
pa.Status = "not_delegated"
return pa
}
pa.Status, pa.NS = "delegated", keys(ns)
for _, rr := range resp.Extra {
var name, addr string
switch v := rr.(type) {
case *dns.A:
name, addr = v.Hdr.Name, v.A.String()
case *dns.AAAA:
name, addr = v.Hdr.Name, v.AAAA.String()
default:
continue
}
name = dns.CanonicalName(name)
if ns[name] {
if pa.Glue == nil {
pa.Glue = map[string][]string{}
}
pa.Glue[name] = append(pa.Glue[name], addr)
}
}
for n := range pa.Glue {
pa.Glue[n] = canonAddrs(pa.Glue[n])
}
return pa
}
// enclosingZone finds the apex of the zone that holds name, from the
// owner of the SOA record the resolver returns (in the answer for an apex,
// in the authority section otherwise).
func (c *Checker) enclosingZone(ctx context.Context, res, name string) (string, error) {
m := new(dns.Msg)
m.SetQuestion(name, dns.TypeSOA)
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
if err != nil {
return "", err
}
for _, sec := range [][]dns.RR{resp.Answer, resp.Ns} {
for _, rr := range sec {
if s, ok := rr.(*dns.SOA); ok {
return dns.CanonicalName(s.Hdr.Name), nil
}
}
}
return "", fmt.Errorf("no SOA record in the answer for %s (%s)", name, dns.RcodeToString[resp.Rcode])
}
// nsNames asks the resolver for a zone's NS names.
func (c *Checker) nsNames(ctx context.Context, res, zone string) ([]string, error) {
m := new(dns.Msg)
m.SetQuestion(zone, dns.TypeNS)
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
if err != nil {
return nil, err
}
set := map[string]bool{}
for _, rr := range resp.Answer {
if n, ok := rr.(*dns.NS); ok {
set[dns.CanonicalName(n.Ns)] = true
}
}
return keys(set), nil
}
// show is a name as people write it, without the trailing dot.
func show(name string) string {
if name == "." || !strings.HasSuffix(name, ".") {
return name
}
return strings.TrimSuffix(name, ".")
}
func showAll(names []string) string {
out := make([]string, len(names))
for i, n := range names {
out[i] = show(n)
}
return strings.Join(out, ", ")
}
func parentName(zone string) string {
labels := dns.SplitDomainName(zone)
if len(labels) <= 1 {
return "."
}
return dns.Fqdn(strings.Join(labels[1:], "."))
}
func describe(r Result) string {
switch r.Status {
case "refused":
return "refuses queries for the zone"
case "unreachable":
return "no answer (" + r.Error + ")"
case "no_address":
return r.Error
}
return nonEmpty(r.Error, r.Status)
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func canonAll(names []string) []string {
out := make([]string, 0, len(names))
for _, n := range names {
out = append(out, dns.CanonicalName(n))
}
return out
}
func canonAddrs(addrs []string) []string {
set := map[string]bool{}
for _, a := range addrs {
if ip, err := netip.ParseAddr(a); err == nil {
set[ip.String()] = true
}
}
return keys(set)
}
func errOr(err error, msg string) error {
if err != nil {
return err
}
return fmt.Errorf("%s", msg)
}
func nonEmpty(s, def string) string {
if s != "" {
return s
}
return def
}
+338
View File
@@ -0,0 +1,338 @@
// Package nscheck asks other name servers about a zone over DNS:
//
// - Serials: is every name server of a zone serving the serial this
// server serves? For a primary zone those are its secondaries (the NS
// records and also-notify targets); for a secondary zone, its
// primaries.
// - Delegation: does the parent zone (e.g. "lol." for adbraun.lol)
// delegate the zone to the name servers, with the glue addresses, that
// Jarl has? Only meaningful for zones on the internet, so it only runs
// when asked and reports a missing delegation as information, not as a
// problem.
//
// It only sends ordinary non-recursive SOA and NS queries.
package nscheck
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/miekg/dns"
)
// Key is a TSIG key to sign queries with, for servers that pick the view
// (or allow the query) by key.
type Key struct {
Name string
Algorithm string // e.g. hmac-sha256
Secret string // base64
}
// Target is a server to ask.
type Target struct {
Name string // host name (FQDN), "" when only an address is known
Addresses []string // known addresses; nil: look the name up
Source string // why it is asked: "NS record", "also-notify", "primaries", ...
Port string // "" for the default
Key *Key
}
// Result is one server's answer.
type Result struct {
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Port string `json:"port,omitempty"`
Source string `json:"source"`
// Status is one of:
// self this server (not queried)
// in_sync serves the same serial
// answered serves the zone (no serial here to compare with)
// behind, ahead serves an older or newer serial (RFC 1982)
// not_authoritative answers, but doesn't serve the zone
// refused refuses the query (allow-query, or its view
// doesn't have the zone for this server)
// unreachable no answer in time
// no_route this host has no route to the address (e.g.
// IPv6 on a host without it); not a problem
// no_address the name has no address
// error anything else (see Error)
Status string `json:"status"`
Serial *uint32 `json:"serial,omitempty"`
// Diff is Serial minus the reference serial (negative: behind).
Diff int64 `json:"diff,omitempty"`
RTTMs int64 `json:"rtt_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// OK reports whether the result is good news.
func (r *Result) OK() bool {
switch r.Status {
case "in_sync", "answered", "self", "no_route":
return true
}
return false
}
// Checker sends the queries.
type Checker struct {
// Timeout per query (default 2s; two tries per address).
Timeout time.Duration
// Port is the DNS port (default 53; tests use another).
Port string
// Resolver is the recursive resolver used to look up names
// (host:port); default: the first nameserver in /etc/resolv.conf.
Resolver string
// IsLocal reports whether an address belongs to this host (default:
// its interface addresses and loopback).
IsLocal func(netip.Addr) bool
}
func (c *Checker) timeout() time.Duration {
if c.Timeout > 0 {
return c.Timeout
}
return 2 * time.Second
}
func (c *Checker) port() string {
if c.Port != "" {
return c.Port
}
return "53"
}
func (c *Checker) isLocal(a netip.Addr) bool {
if c.IsLocal != nil {
return c.IsLocal(a)
}
return IsLocalAddr(a)
}
// IsLocalAddr reports whether a is a loopback address or one of this
// host's interface addresses.
func IsLocalAddr(a netip.Addr) bool {
if a.IsLoopback() {
return true
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, ia := range addrs {
if n, ok := ia.(*net.IPNet); ok {
if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a.Unmap() {
return true
}
}
}
return false
}
// SystemResolver returns the first nameserver in /etc/resolv.conf as
// host:port.
func SystemResolver() (string, error) {
cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil {
return "", fmt.Errorf("reading /etc/resolv.conf: %w", err)
}
if len(cc.Servers) == 0 {
return "", errors.New("no nameserver in /etc/resolv.conf")
}
return net.JoinHostPort(cc.Servers[0], cc.Port), nil
}
func (c *Checker) resolver() (string, error) {
if c.Resolver != "" {
return c.Resolver, nil
}
return SystemResolver()
}
// exchange sends one query to addr (an IP address) with a retry, and
// over TCP when the UDP answer is truncated.
func (c *Checker) exchange(ctx context.Context, m *dns.Msg, addr string, key *Key) (*dns.Msg, time.Duration, error) {
return c.exchangeHostPort(ctx, m, net.JoinHostPort(addr, c.port()), key)
}
func (c *Checker) exchangeHostPort(ctx context.Context, m *dns.Msg, hostport string, key *Key) (*dns.Msg, time.Duration, error) {
cl := &dns.Client{Timeout: c.timeout()}
if key != nil {
name := dns.Fqdn(key.Name)
cl.TsigSecret = map[string]string{name: key.Secret}
m.SetTsig(name, dns.Fqdn(key.Algorithm), 300, time.Now().Unix())
}
var resp *dns.Msg
var rtt time.Duration
var err error
for try := 0; try < 2; try++ {
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
if err == nil || ctx.Err() != nil {
break
}
}
if err == nil && resp.Truncated {
cl.Net = "tcp"
resp, rtt, err = cl.ExchangeContext(ctx, m, hostport)
}
return resp, rtt, err
}
// lookup returns a name's IPv4 and IPv6 addresses through the resolver.
func (c *Checker) lookup(ctx context.Context, name string) ([]string, error) {
res, err := c.resolver()
if err != nil {
return nil, err
}
var out []string
var firstErr error
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(name), t)
resp, _, err := c.exchangeHostPort(ctx, m, res, nil)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
for _, rr := range resp.Answer {
switch v := rr.(type) {
case *dns.A:
out = append(out, v.A.String())
case *dns.AAAA:
out = append(out, v.AAAA.String())
}
}
}
if len(out) == 0 && firstErr != nil {
return nil, firstErr
}
return out, nil
}
// soa asks addr for zone's SOA and fills in r (whose Name, Address and
// Source are set).
func (c *Checker) soa(ctx context.Context, zone string, key *Key, ref *uint32, r *Result) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(zone), dns.TypeSOA)
m.RecursionDesired = false
port := r.Port
if port == "" {
port = c.port()
}
resp, rtt, err := c.exchangeHostPort(ctx, m, net.JoinHostPort(r.Address, port), key)
if err != nil {
var ne net.Error
switch {
case errors.Is(err, syscall.ENETUNREACH):
// Not the server's fault: typically IPv6 on a host without it.
r.Status, r.Error = "no_route", "this server has no route to that address"
case errors.As(err, &ne) && ne.Timeout(), errors.Is(err, context.DeadlineExceeded):
r.Status, r.Error = "unreachable", "no answer within "+c.timeout().String()
default:
r.Status, r.Error = "unreachable", err.Error()
}
return
}
r.RTTMs = rtt.Milliseconds()
switch {
case resp.Rcode == dns.RcodeRefused:
r.Status = "refused"
return
case resp.Rcode != dns.RcodeSuccess:
r.Status, r.Error = "error", "answered "+dns.RcodeToString[resp.Rcode]
return
case !resp.Authoritative:
r.Status = "not_authoritative"
return
}
for _, rr := range resp.Answer {
if s, ok := rr.(*dns.SOA); ok && strings.EqualFold(s.Hdr.Name, dns.Fqdn(zone)) {
serial := s.Serial
r.Serial = &serial
r.Status = "answered"
if ref != nil {
r.Status = "in_sync"
r.Diff = int64(int32(serial - *ref))
switch {
case r.Diff < 0:
r.Status = "behind"
case r.Diff > 0:
r.Status = "ahead"
}
}
return
}
}
r.Status, r.Error = "error", "the answer has no SOA record"
}
// Serials asks each target for zone's SOA serial and compares it with
// ref, the serial this server serves (nil when unknown). Targets with
// local addresses are this server and aren't asked. Results are sorted by
// name and address.
func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets []Target) []Result {
var mu sync.Mutex
var wg sync.WaitGroup
var out []Result
add := func(r Result) {
mu.Lock()
out = append(out, r)
mu.Unlock()
}
seen := map[string]bool{}
for _, t := range targets {
addrs := t.Addresses
if addrs == nil && t.Name != "" {
found, err := c.lookup(ctx, t.Name)
if err != nil || len(found) == 0 {
msg := "no A or AAAA record"
if err != nil {
msg = "looking up its address: " + err.Error()
}
add(Result{Name: t.Name, Source: t.Source, Status: "no_address", Error: msg})
continue
}
addrs = found
}
for _, a := range addrs {
ip, err := netip.ParseAddr(a)
if err != nil || seen[ip.String()+"#"+t.Port] {
continue
}
seen[ip.String()+"#"+t.Port] = true
r := Result{Name: t.Name, Address: ip.String(), Port: t.Port, Source: t.Source}
if c.isLocal(ip) {
r.Status = "self"
if ref != nil {
s := *ref
r.Serial = &s
}
add(r)
continue
}
wg.Add(1)
go func(r Result, key *Key) {
defer wg.Done()
c.soa(ctx, zone, key, ref, &r)
add(r)
}(r, t.Key)
}
}
wg.Wait()
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].Address < out[j].Address
})
return out
}
+245
View File
@@ -0,0 +1,245 @@
package nscheck
import (
"context"
"net"
"net/netip"
"strconv"
"strings"
"testing"
"time"
"github.com/miekg/dns"
)
// fake is a DNS server on one loopback address that answers with fn.
type fake func(q dns.Question) *dns.Msg
// serve starts fake servers on 127.0.0.x addresses, all on the same port,
// and returns the port.
func serve(t *testing.T, servers map[string]fake) string {
t.Helper()
port := ""
for addr, fn := range servers {
hp := net.JoinHostPort(addr, "0")
if port != "" {
hp = net.JoinHostPort(addr, port)
}
pc, err := net.ListenPacket("udp", hp)
if err != nil {
t.Skipf("can't listen on %s: %v", hp, err)
}
if port == "" {
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
}
fn := fn
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
resp := fn(req.Question[0])
if resp == nil {
return // no answer: a timeout
}
rcode, aa := resp.Rcode, resp.Authoritative
resp.SetReply(req) // resets both
resp.Rcode, resp.Authoritative = rcode, aa
w.WriteMsg(resp)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
}
return port
}
func rr(s string) dns.RR {
r, err := dns.NewRR(s)
if err != nil {
panic(err)
}
return r
}
// authSOA answers SOA queries for zone authoritatively with serial.
func authSOA(zone string, serial uint32) fake {
return func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
m.Authoritative = true
if q.Qtype == dns.TypeSOA && strings.EqualFold(q.Name, zone) {
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + itoa(serial) + " 3600 900 1209600 300")}
}
return m
}
}
func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
func checker(port, resolver string) *Checker {
return &Checker{
Timeout: 300 * time.Millisecond,
Port: port,
Resolver: resolver,
IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.9" },
}
}
func TestSerials(t *testing.T) {
zone := "example.com."
port := serve(t, map[string]fake{
"127.0.0.2": authSOA(zone, 2026100805),
"127.0.0.3": authSOA(zone, 2026100803),
"127.0.0.4": func(dns.Question) *dns.Msg { return new(dns.Msg) }, // answers, not authoritative
"127.0.0.5": func(dns.Question) *dns.Msg { m := new(dns.Msg); m.Rcode = dns.RcodeRefused; return m },
"127.0.0.6": func(dns.Question) *dns.Msg { return nil }, // never answers
})
ref := uint32(2026100805)
res := checker(port, "").Serials(context.Background(), zone, &ref, []Target{
{Name: "ns1.example.com.", Addresses: []string{"127.0.0.9"}, Source: "NS record"},
{Name: "ns2.example.com.", Addresses: []string{"127.0.0.2"}, Source: "NS record"},
{Name: "ns3.example.com.", Addresses: []string{"127.0.0.3"}, Source: "NS record"},
{Name: "ns4.example.com.", Addresses: []string{"127.0.0.4"}, Source: "NS record"},
{Addresses: []string{"127.0.0.5"}, Source: "also-notify"},
{Addresses: []string{"127.0.0.6", "127.0.0.2"}, Source: "also-notify"}, // 127.0.0.2 again: asked once
})
got := map[string]Result{}
for _, r := range res {
got[r.Address] = r
}
if len(res) != 6 {
t.Errorf("%d results: %+v", len(res), res)
}
check := func(addr, status string) {
t.Helper()
if got[addr].Status != status {
t.Errorf("%s: %+v, want %s", addr, got[addr], status)
}
}
check("127.0.0.9", "self")
check("127.0.0.2", "in_sync")
check("127.0.0.3", "behind")
check("127.0.0.4", "not_authoritative")
check("127.0.0.5", "refused")
check("127.0.0.6", "unreachable")
if got["127.0.0.3"].Diff != -2 || *got["127.0.0.3"].Serial != 2026100803 {
t.Errorf("behind: %+v", got["127.0.0.3"])
}
// Without a serial to compare with, an answer is just an answer.
res = checker(port, "").Serials(context.Background(), zone, nil, []Target{{Addresses: []string{"127.0.0.2"}}})
if res[0].Status != "answered" || !res[0].OK() {
t.Errorf("no reference: %+v", res[0])
}
}
func TestSerialWraparound(t *testing.T) {
// RFC 1982: 1 is ahead of 4294967295.
zone := "example.com."
port := serve(t, map[string]fake{"127.0.0.2": authSOA(zone, 1)})
ref := uint32(4294967295)
res := checker(port, "").Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.2"}}})
if res[0].Status != "ahead" || res[0].Diff != 2 {
t.Errorf("%+v", res[0])
}
}
// internet builds a small fake DNS tree: a resolver, a parent server for
// "lol." and the child's servers.
func internet(t *testing.T, parent fake) (port, resolver string) {
port = serve(t, map[string]fake{
// The recursive resolver.
"127.0.0.10": func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
m.RecursionAvailable = true
switch {
case q.Qtype == dns.TypeSOA && q.Name == "lol.":
m.Answer = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
case q.Qtype == dns.TypeNS && q.Name == "lol.":
m.Answer = []dns.RR{rr("lol. 300 IN NS ns.nic.lol.")}
case q.Qtype == dns.TypeA && q.Name == "ns.nic.lol.":
m.Answer = []dns.RR{rr("ns.nic.lol. 300 IN A 127.0.0.11")}
case q.Qtype == dns.TypeA && q.Name == "ns.elsewhere.net.":
m.Answer = []dns.RR{rr("ns.elsewhere.net. 300 IN A 127.0.0.13")}
}
return m
},
"127.0.0.11": parent,
// ns1.adbraun.lol: serves the zone.
"127.0.0.12": authSOA("adbraun.lol.", 7),
// ns.elsewhere.net: doesn't serve it (lame).
"127.0.0.13": func(dns.Question) *dns.Msg { m := new(dns.Msg); m.Rcode = dns.RcodeRefused; return m },
})
return port, net.JoinHostPort("127.0.0.10", port)
}
func levels(rep *DelegationReport) map[string][]string {
out := map[string][]string{}
for _, f := range rep.Findings {
out[f.Level] = append(out[f.Level], f.Message)
}
return out
}
func TestDelegationMismatch(t *testing.T) {
port, res := internet(t, func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol."), rr("adbraun.lol. 3600 IN NS ns.elsewhere.net.")}
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
}
return m
})
ref := uint32(7)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.",
[]string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref)
if rep.Parent != "lol." || !rep.Delegated {
t.Fatalf("report: %+v", rep)
}
l := levels(rep)
all := strings.Join(append(append(l["error"], l["warning"]...), l["info"]...), "\n")
for _, want := range []string{
"delegates to ns.elsewhere.net, which the zone's NS records don't list",
"list ns2.adbraun.lol, which the parent doesn't delegate to",
"glue for ns1.adbraun.lol is 127.0.0.12, but the zone says 127.0.0.99",
"ns.elsewhere.net (127.0.0.13): refuses queries",
} {
if !strings.Contains(all, want) {
t.Errorf("missing finding %q in:\n%s", want, all)
}
}
if len(rep.Glue) != 1 || rep.Glue[0].Match {
t.Errorf("glue: %+v", rep.Glue)
}
}
func TestDelegationGood(t *testing.T) {
port, res := internet(t, func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
if q.Qtype == dns.TypeNS && q.Name == "adbraun.lol." {
m.Ns = []dns.RR{rr("adbraun.lol. 3600 IN NS ns1.adbraun.lol.")}
m.Extra = []dns.RR{rr("ns1.adbraun.lol. 3600 IN A 127.0.0.12")}
}
return m
})
ref := uint32(7)
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref)
l := levels(rep)
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
t.Errorf("findings: %v", l)
}
}
func TestNotDelegated(t *testing.T) {
// An internal-only zone: one calm note, no warnings.
port, res := internet(t, func(q dns.Question) *dns.Msg {
m := new(dns.Msg)
m.Authoritative = true
m.Rcode = dns.RcodeNameError
m.Ns = []dns.RR{rr("lol. 300 IN SOA ns.nic.lol. hostmaster.lol. 1 3600 900 1209600 300")}
return m
})
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil)
l := levels(rep)
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
t.Errorf("findings: %v", l)
}
if !strings.Contains(l["info"][0], "adbraun.lol isn't delegated from the parent zone lol, so the internet can't see it. That's expected for a zone used only on your own network") {
t.Errorf("note: %s", l["info"][0])
}
}
+51 -6
View File
@@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/store"
@@ -79,11 +81,12 @@ func (l *rateLimiter) cleanup() {
}
}
// throttle allows an event once a minute per key (for log lines that
// could otherwise repeat on every request).
// throttle allows an event once per interval (every, a minute if unset)
// per key, for log lines that could otherwise repeat on every request.
type throttle struct {
mu sync.Mutex
last map[string]time.Time
every time.Duration
mu sync.Mutex
last map[string]time.Time
}
func (t *throttle) allow(key string, now time.Time) bool {
@@ -92,11 +95,15 @@ func (t *throttle) allow(key string, now time.Time) bool {
if t.last == nil {
t.last = map[string]time.Time{}
}
if now.Sub(t.last[key]) < time.Minute {
every := t.every
if every == 0 {
every = time.Minute
}
if now.Sub(t.last[key]) < every {
return false
}
for k, v := range t.last {
if now.Sub(v) > time.Hour {
if now.Sub(v) > every+time.Hour {
delete(t.last, k)
}
}
@@ -206,12 +213,46 @@ func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*au
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
s.store.TouchToken(ctx, t.ID, ip)
}
s.warnTokenExpiry(t, ip, now)
if previous && s.oldSecretWarn.allow(fmt.Sprintf("%d %s", t.ID, ip), now) {
s.log.Warn("token used with its old secret; update this client before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
}
// warnTokenExpiry logs, at most once a day per token, that a token in use
// expires soon (or has expired). ip is where it was used ("" when found by
// the daily check).
func (s *Server) warnTokenExpiry(t *model.Token, ip string, now time.Time) {
if !t.ExpiresWithin(now, model.TokenExpiryWarning) || !s.expiryWarn.allow(strconv.FormatInt(t.ID, 10), now) {
return
}
args := []any{"token_id", t.ID, "token", t.Username + "/" + t.Name, "expires_at", t.ExpiresAt.Format(time.RFC3339)}
if ip != "" {
args = append(args, "ip", ip)
}
msg := "API token expires soon; give it a new lifetime with 'jarl token rotate'"
if now.After(*t.ExpiresAt) {
msg = "API token has expired; anything still using it is refused (rotate it with 'jarl token rotate')"
}
s.log.Warn(msg, args...)
}
// warnExpiringTokens runs warnTokenExpiry for every token (from
// housekeeping), so tokens that are rarely used are reported too. Tokens
// that expired more than TokenExpiryWarning ago are no longer reported.
func (s *Server) warnExpiringTokens(ctx context.Context, now time.Time) {
ts, err := s.store.ListTokens(ctx, 0)
if err != nil {
return
}
for _, t := range ts {
if t.ExpiresAt != nil && now.Sub(*t.ExpiresAt) < model.TokenExpiryWarning {
s.warnTokenExpiry(t, "", now)
}
}
}
// authed wraps a handler with authentication and error handling.
func (s *Server) authed(h handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -221,6 +262,10 @@ func (s *Server) authed(h handler) http.HandlerFunc {
s.writeErr(w, r, err, nil)
return
}
if p.Token != nil && p.Token.ExpiresWithin(time.Now(), model.TokenExpiryWarning) {
// The CLI warns its user when it sees this.
w.Header().Set(api.TokenExpiresHeader, p.Token.ExpiresAt.UTC().Format(time.RFC3339))
}
ctx := context.WithValue(r.Context(), keyPrincipal, p)
if se != nil {
ctx = context.WithValue(ctx, keySession, se)
+117
View File
@@ -0,0 +1,117 @@
package server
import (
"context"
"net/http"
"testing"
"time"
"jarl/internal/api"
"jarl/internal/store"
)
// header sends a GET with a token and returns a response header.
func (e *apiEnv) header(path, token, name string) string {
e.t.Helper()
r, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+path, nil)
r.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(r)
if err != nil {
e.t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 200 {
e.t.Fatalf("GET %s = %d", path, resp.StatusCode)
}
return resp.Header.Get(name)
}
func TestTokenExpiryWarnings(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/users", e.token, map[string]any{"username": "certbot", "password": "", "grant_strings": []string{"records:write"}}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "certbot" {
uid = int64(u["id"].(float64))
}
}
soon := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "soon", "expires_in": "5d"}, 201)
later := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "later", "expires_in": "90d"}, 201)
revoked := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "gone", "expires_in": "1d"}, 201)
e.do("DELETE", "/tokens/"+itoa(int64(revoked["id"].(float64))), e.token, nil, 204)
// The dashboard lists the expiring token for the administrator...
names := func(token string) []string {
var out []string
for _, t := range e.do("GET", "/status", token, nil, 200)["expiring_tokens"].([]any) {
out = append(out, t.(map[string]any)["name"].(string))
}
return out
}
if got := names(e.token); len(got) != 1 || got[0] != "soon" {
t.Errorf("admin's expiring tokens = %v", got)
}
// ...and for its own user.
if got := names(soon["token"].(string)); len(got) != 1 || got[0] != "soon" {
t.Errorf("certbot's expiring tokens = %v", got)
}
// Requests with the expiring token say so (the CLI warns); others don't.
if h := e.header("/auth/whoami", soon["token"].(string), api.TokenExpiresHeader); h == "" {
t.Error("no expiry header for a token that expires in 5 days")
} else if exp, err := time.Parse(time.RFC3339, h); err != nil || time.Until(exp) < 4*24*time.Hour {
t.Errorf("expiry header %q", h)
}
if h := e.header("/auth/whoami", later["token"].(string), api.TokenExpiresHeader); h != "" {
t.Errorf("expiry header for a 90-day token: %q", h)
}
if h := e.header("/auth/whoami", e.token, api.TokenExpiresHeader); h != "" {
t.Errorf("expiry header for a token without expiry: %q", h)
}
}
func TestBackupStatus(t *testing.T) {
e := newAPIEnvConfig(t, Config{Version: "test", BackupDir: "/var/backups/jarl", BackupWarnAfter: 48 * time.Hour})
backup := func() map[string]any {
b, _ := e.do("GET", "/status", e.token, nil, 200)["backup"].(map[string]any)
return b
}
if b := backup(); b == nil || b["last_at"] != nil || b["stale"] != false || b["dir"] != "/var/backups/jarl" {
t.Errorf("before any backup: %v", b)
}
ctx := context.Background()
now := time.Now()
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-3 * time.Hour), File: "/var/backups/jarl/jarl-x.db", Size: 1234})
if b := backup(); b["last_file"] != "/var/backups/jarl/jarl-x.db" || b["stale"] != false || b["last_error"] != nil {
t.Errorf("fresh backup: %v", b)
}
// A failure newer than the last success is reported; an older one isn't.
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-time.Hour), Error: "disk full"})
if b := backup(); b["last_error"] != "disk full" {
t.Errorf("failed backup: %v", b)
}
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-72 * time.Hour), File: "/var/backups/jarl/jarl-y.db"})
e.st.RecordBackup(ctx, store.BackupRecord{At: now.Add(-80 * time.Hour), Error: "old"})
if b := backup(); b["stale"] != true || b["last_error"] != nil {
t.Errorf("old backup: %v", b)
}
// Only administrators see it.
e.do("POST", "/users", e.token, map[string]any{"username": "viewer", "password": "", "role": "readonly"}, 201)
var uid int64
for _, u := range e.list("/users", e.token) {
if u["username"] == "viewer" {
uid = int64(u["id"].(float64))
}
}
tok := e.do("POST", "/users/"+itoa(uid)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
if st := e.do("GET", "/status", tok, nil, 200); st["backup"] != nil {
t.Errorf("non-admin sees backups: %v", st["backup"])
}
}
func TestServerHeader(t *testing.T) {
e := newAPIEnv(t)
if h := e.header("/auth/whoami", e.token, "Server"); h != "Jarl/test" {
t.Errorf("Server: %q", h)
}
}
+47 -1
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -271,10 +272,55 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
st.Named.Raw = ""
}
}
writeJSON(w, http.StatusOK, api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: time.Now()})
now := time.Now()
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now)
}
writeJSON(w, http.StatusOK, resp)
return nil
}
// backupStatus reports the newest backup, as 'jarld backup' recorded it,
// and whether it is older than backup.warn_after. No backup at all is
// reported but not flagged: backups may be made some other way.
func (s *Server) backupStatus(ctx context.Context, now time.Time) *api.BackupStatus {
ok, failed := s.store.LastBackups(ctx)
bs := &api.BackupStatus{Dir: s.cfg.BackupDir, WarnAfter: int64(s.cfg.BackupWarnAfter / time.Second)}
if ok != nil {
bs.LastAt, bs.LastFile, bs.LastSize = &ok.At, ok.File, ok.Size
bs.Stale = s.cfg.BackupWarnAfter > 0 && now.Sub(ok.At) > s.cfg.BackupWarnAfter
}
if failed != nil && (ok == nil || failed.At.After(ok.At)) {
bs.LastError, bs.LastErrorAt = failed.Error, &failed.At
}
return bs
}
// expiringTokens lists the tokens the dashboard warns about: those that
// expire within model.TokenExpiryWarning or expired less than that long
// ago. Administrators see everyone's, others their own.
func (s *Server) expiringTokens(ctx context.Context, p *auth.Principal, now time.Time) []api.Token {
out := []api.Token{}
uid := p.User.ID
if p.IsAdmin() {
uid = 0
} else if uid == 0 {
return out
}
ts, err := s.store.ListTokens(ctx, uid)
if err != nil {
s.log.Warn("listing tokens failed", "err", err)
return out
}
for _, t := range ts {
if t.ExpiresWithin(now, model.TokenExpiryWarning) && now.Sub(*t.ExpiresAt) < model.TokenExpiryWarning {
out = append(out, presentToken(t))
}
}
return out
}
func (s *Server) systemCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
+269
View File
@@ -0,0 +1,269 @@
package server
// Name server checks (internal/nscheck): GET /zones/{zone}/serials and
// GET /zones/{zone}/delegation. Both only run when asked for (the zone
// page's Name servers tab, 'jarl zone serials' and 'jarl zone
// delegation'); nothing checks in the background.
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
"jarl/internal/nscheck"
)
// checker is the nscheck.Checker the handlers use (tests replace it).
func (s *Server) checker() *nscheck.Checker {
if s.nsChecker != nil {
c := *s.nsChecker
return &c
}
return &nscheck.Checker{}
}
func (s *Server) zoneSerials(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 {
return err
}
resp := api.SerialsResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Servers: []nscheck.Result{}, Notes: []string{}}
var targets []nscheck.Target
switch z.Type {
case model.ZonePrimary:
targets = append(targets, nsTargets(st, z)...)
an, notes := remoteTargets(st, inheritedOption(st, z, "also-notify"), "also-notify")
targets = append(targets, an...)
resp.Notes = append(resp.Notes, notes...)
case model.ZoneSecondary, model.ZoneMirror:
pr, notes := remoteTargets(st, st.FindOption(model.ScopeZone, z.ID, "primaries"), "primaries")
targets = append(targets, pr...)
resp.Notes = append(resp.Notes, notes...)
default:
return badRequest("only primary, secondary and mirror zones have serials to compare (this is a %s zone)", z.Type)
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
resp.Serial, resp.SerialSource, err = s.localSerial(ctx, st, z)
if err != nil {
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
}
if len(targets) == 0 {
if z.Type == model.ZonePrimary {
resp.Notes = append(resp.Notes, "The zone has no NS records or also-notify targets to check.")
} else {
resp.Notes = append(resp.Notes, "The zone has no primaries to check.")
}
}
if resp.View != "" {
resp.Notes = append(resp.Notes, fmt.Sprintf("The zone is in view %s. Other servers answer from the view that matches this server's address (or key), as they do for its zone transfers and NOTIFYs.", resp.View))
}
resp.Servers = s.checker().Serials(ctx, z.Name, resp.Serial, targets)
if len(resp.Servers) > 0 && !hasOther(resp.Servers) {
resp.Notes = append(resp.Notes, "Only this server is listed, so there is nothing to compare with.")
}
resp.CheckedAt = time.Now()
writeJSON(w, http.StatusOK, resp)
return nil
}
func (s *Server) zoneDelegation(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 {
return err
}
if z.Type != model.ZonePrimary {
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
}
c := s.checker()
if v := r.URL.Query().Get("resolver"); v != "" {
// An address only, always port 53: this is a DNS resolver.
a, err := netip.ParseAddr(v)
if err != nil {
return badRequest("resolver must be an IP address, e.g. 1.1.1.1 or 9.9.9.9")
}
c.Resolver = net.JoinHostPort(a.String(), "53")
}
var ns []string
for _, rec := range st.ZoneRecords(z.ID) {
if rec.Type == "NS" && rec.Name == z.Name {
ns = append(ns, rec.Data)
}
}
addrs := map[string][]string{}
for _, n := range ns {
if a := jarlAddresses(st, z.ViewID, dnsname.Fqdn(n)); len(a) > 0 {
addrs[dnsname.Fqdn(n)] = a
}
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
ref, _, _ := s.localSerial(ctx, st, z)
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref), View: st.ViewName(z), Notes: []string{}, CheckedAt: time.Now()}
if resp.View != "" {
resp.Notes = append(resp.Notes, fmt.Sprintf("Compared with the zone in view %s. Resolvers on the internet get the view that matches their address; make sure that's this one.", resp.View))
}
writeJSON(w, http.StatusOK, resp)
return nil
}
// localSerial is the serial this server serves for z: named's, or for a
// primary zone Jarl's when named isn't asked.
func (s *Server) localSerial(ctx context.Context, st *model.State, z *model.Zone) (*uint32, string, error) {
serial, ok, err := s.eng.LoadedSerial(ctx, st, z)
if ok {
return &serial, "named", nil
}
if z.Type == model.ZonePrimary {
v := z.SOA.Serial
return &v, "jarl", err
}
return nil, "", err
}
// nsTargets are the name servers in the zone's apex NS records, with the
// addresses Jarl has for them in the same view (or nil, to be looked up).
func nsTargets(st *model.State, z *model.Zone) []nscheck.Target {
var out []nscheck.Target
for _, rec := range st.ZoneRecords(z.ID) {
if rec.Type != "NS" || rec.Name != z.Name {
continue
}
name := dnsname.Fqdn(rec.Data)
out = append(out, nscheck.Target{Name: name, Addresses: jarlAddresses(st, z.ViewID, name), Source: "NS record"})
}
return out
}
// jarlAddresses returns the A and AAAA records for name in the primary
// zones of a view (nil when Jarl has none).
func jarlAddresses(st *model.State, viewID int64, name string) []string {
var out []string
for _, z := range st.ZonesInView(viewID) {
if !z.HasRecords() || !dnsname.IsSubdomain(name, z.Name) {
continue
}
for _, rec := range st.ZoneRecords(z.ID) {
if rec.Name == name && (rec.Type == "A" || rec.Type == "AAAA") {
out = append(out, rec.Data)
}
}
}
return out
}
// inheritedOption finds an option for z as named does: the zone's own,
// else its view's, else the server's.
func inheritedOption(st *model.State, z *model.Zone, name string) *model.Option {
if o := st.FindOption(model.ScopeZone, z.ID, name); o != nil {
return o
}
if z.ViewID != 0 {
if o := st.FindOption(model.ScopeView, z.ViewID, name); o != nil {
return o
}
}
return st.FindOption(model.ScopeOptions, 0, name)
}
// remoteTargets turns an also-notify or primaries option into targets:
// addresses with their port and key, following named remote-servers
// lists.
func remoteTargets(st *model.State, o *model.Option, source string) ([]nscheck.Target, []string) {
if o == nil {
return nil, nil
}
var out []nscheck.Target
var notes []string
var walk func(name, value string, port string, depth int)
walk = func(name, value, port string, depth int) {
stmt, err := namedconf.ParseOptionValue(name, value)
if err != nil {
notes = append(notes, fmt.Sprintf("couldn't read %s: %v", name, err))
return
}
vals := stmt.Values()
for i := 0; i+1 < len(vals); i++ {
if strings.EqualFold(vals[i], "port") {
port = vals[i+1]
}
}
blk := stmt.FirstBlock()
if blk == nil {
return
}
for _, el := range blk.Stmts {
if len(el.Items) == 0 || el.Items[0].Value == nil {
continue
}
first := el.Items[0].Value.Text
t := nscheck.Target{Source: source, Port: port}
words := el.Values()
for i := 0; i+1 < len(words); i++ {
switch strings.ToLower(words[i]) {
case "port":
t.Port = words[i+1]
case "key":
if k := st.KeyByName(words[i+1]); k != nil {
t.Key = &nscheck.Key{Name: k.Name, Algorithm: k.Algorithm, Secret: k.Secret}
}
}
}
if a, err := netip.ParseAddr(first); err == nil {
t.Addresses = []string{a.String()}
out = append(out, t)
continue
}
// A named list of servers.
if depth >= 5 {
continue
}
if lo := remoteList(st, first); lo != nil {
walk(lo.Name, lo.Value, port, depth+1)
} else {
notes = append(notes, fmt.Sprintf("%s refers to %q, which isn't an address or a server list Jarl knows", source, first))
}
}
}
walk(o.Name, o.Value, "", 0)
return out, notes
}
// remoteList finds a top-level remote-servers (or primaries) list.
func remoteList(st *model.State, name string) *model.Option {
for _, o := range st.ScopedOptions(model.ScopeTopLevel, 0) {
switch o.Name {
case "remote-servers", "primaries", "masters":
if f := strings.Fields(o.Value); len(f) > 0 && strings.Trim(f[0], `"`) == name {
return o
}
}
}
return nil
}
func hasOther(rs []nscheck.Result) bool {
for _, r := range rs {
if r.Status != "self" {
return true
}
}
return false
}
+71
View File
@@ -0,0 +1,71 @@
package server
import (
"net/netip"
"testing"
"time"
"jarl/internal/nscheck"
)
func TestZoneSerialsTargets(t *testing.T) {
e := newAPIEnv(t)
// Nothing listens on these addresses' DNS port: every server is
// "unreachable" quickly, and the test checks who is asked.
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(a netip.Addr) bool { return a.String() == "127.0.0.21" }}
e.do("POST", "/tsig-keys", e.token, map[string]any{"name": "xfer"}, 201)
e.do("POST", "/options", e.token, map[string]any{"scope": "toplevel", "name": "remote-servers", "value": "upstream port 5353 { 127.0.0.31; 127.0.0.32 key xfer; }"}, 201)
e.do("POST", "/zones", e.token, map[string]any{
"name": "example.com",
"nameservers": []string{"ns1.example.com.", "ns2.example.com."},
"records": []map[string]any{{"name": "ns1", "type": "A", "data": "127.0.0.21"}, {"name": "ns2", "type": "A", "data": "127.0.0.22"}},
"options": []map[string]any{{"name": "also-notify", "value": "{ 127.0.0.23; 127.0.0.24 port 5300 key xfer; }"}},
}, 201)
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ upstream; 127.0.0.33; }"}}}, 201)
type row struct{ source, status, port string }
servers := func(zone string) map[string]row {
out := map[string]row{}
for _, s := range e.do("GET", "/zones/"+zone+"/serials", e.token, nil, 200)["servers"].([]any) {
m := s.(map[string]any)
port, _ := m["port"].(string)
out[m["address"].(string)] = row{m["source"].(string), m["status"].(string), port}
}
return out
}
got := servers("example.com")
want := map[string]row{
"127.0.0.21": {"NS record", "self", ""},
"127.0.0.22": {"NS record", "unreachable", ""},
"127.0.0.23": {"also-notify", "unreachable", ""},
"127.0.0.24": {"also-notify", "unreachable", "5300"},
}
if len(got) != len(want) {
t.Errorf("primary zone: %v", got)
}
for a, w := range want {
if got[a] != w {
t.Errorf("primary zone, %s: %+v, want %+v", a, got[a], w)
}
}
got = servers("copy.example")
want = map[string]row{
"127.0.0.31": {"primaries", "unreachable", "5353"},
"127.0.0.32": {"primaries", "unreachable", "5353"},
"127.0.0.33": {"primaries", "unreachable", ""},
}
if len(got) != len(want) {
t.Errorf("secondary zone: %v", got)
}
for a, w := range want {
if got[a] != w {
t.Errorf("secondary zone, %s: %+v, want %+v", a, got[a], w)
}
}
// Zone types without serials, and the delegation check's limits.
e.do("POST", "/zones", e.token, map[string]any{"name": "fwd.example", "type": "forward", "options": []map[string]any{{"name": "forwarders", "value": "{ 192.0.2.1; }"}}}, 201)
e.do("GET", "/zones/fwd.example/serials", e.token, nil, 400)
e.do("GET", "/zones/copy.example/delegation", e.token, nil, 400)
e.do("GET", "/zones/example.com/delegation?resolver=not-an-ip", e.token, nil, 400)
}
+2
View File
@@ -65,6 +65,8 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/zones/{zone}", s.authed(s.getZone))
m.HandleFunc("PATCH "+p+"/zones/{zone}", s.authed(s.updateZone))
m.HandleFunc("DELETE "+p+"/zones/{zone}", s.authed(s.deleteZone))
m.HandleFunc("GET "+p+"/zones/{zone}/serials", s.authed(s.zoneSerials))
m.HandleFunc("GET "+p+"/zones/{zone}/delegation", s.authed(s.zoneDelegation))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
+14
View File
@@ -19,6 +19,7 @@ import (
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/nscheck"
"jarl/internal/stats"
"jarl/internal/store"
)
@@ -42,6 +43,10 @@ type Config struct {
HTTPReq HTTPReq
// Socket is the local unix socket's path ("" for none); see socket.go.
Socket string
// BackupDir and BackupWarnAfter are the backup settings the dashboard
// reports on (backup.dir and backup.warn_after).
BackupDir string
BackupWarnAfter time.Duration
}
// Server is the HTTP server.
@@ -57,6 +62,11 @@ type Server struct {
statsQ *stats.Querier
// oldSecretWarn limits warnings about rotated tokens' old secrets.
oldSecretWarn throttle
// expiryWarn limits warnings about expiring tokens to one a day each.
expiryWarn throttle
// nsChecker, when set, replaces the default name server checker
// (tests point it at local DNS servers).
nsChecker *nscheck.Checker
mu sync.Mutex
srvs []*http.Server
@@ -80,6 +90,7 @@ func New(cfg Config, eng *engine.Engine, log *slog.Logger, web fs.FS) *Server {
log = slog.Default()
}
s := &Server{cfg: cfg, eng: eng, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web}
s.expiryWarn.every = 24 * time.Hour
if cfg.Stats != nil {
s.statsQ = stats.NewQuerier(cfg.Stats)
}
@@ -236,6 +247,7 @@ func (s *Server) housekeeping(ctx context.Context) {
s.log.Warn("purging sessions failed", "err", err)
}
s.limiter.cleanup()
s.warnExpiringTokens(ctx, time.Now())
if s.certs != nil {
s.certs.reloadIfChanged()
}
@@ -330,6 +342,8 @@ func (s *Server) recoverer(next http.Handler) http.Handler {
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// The version is no secret: /healthz reports it too.
h.Set("Server", "Jarl/"+s.cfg.Version)
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
+63
View File
@@ -54,6 +54,24 @@ func Open(path string) (*Store, error) {
return s, nil
}
// OpenExisting opens an existing database without creating it or
// applying migrations, for commands that work alongside a running jarld
// that may be older than they are ('jarld backup').
func OpenExisting(path string) (*Store, error) {
db, err := sql.Open("sqlite", "file:"+path+"?mode=rw&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_txlock=immediate")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// SchemaVersion is the database schema version this Jarl uses.
func SchemaVersion() int { return len(migrations) }
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
@@ -369,3 +387,48 @@ func boolInt(b bool) int {
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
}
// BackupRecord is a backup that 'jarld backup' made (or failed to make),
// kept in the meta table for the dashboard.
type BackupRecord struct {
At time.Time `json:"at"`
File string `json:"file,omitempty"`
Size int64 `json:"size,omitempty"`
Error string `json:"error,omitempty"`
}
const (
metaBackupOK = "backup.last_ok"
metaBackupFailed = "backup.last_failed"
)
// RecordBackup stores the outcome of a backup: r.Error empty for a
// successful one.
func (s *Store) RecordBackup(ctx context.Context, r BackupRecord) error {
key := metaBackupOK
if r.Error != "" {
key = metaBackupFailed
}
b, err := json.Marshal(r)
if err != nil {
return err
}
return s.SetMeta(ctx, key, string(b))
}
// LastBackups returns the newest successful and failed backups (nil for
// none).
func (s *Store) LastBackups(ctx context.Context) (ok, failed *BackupRecord) {
get := func(key string) *BackupRecord {
v, err := s.GetMeta(ctx, key)
if err != nil {
return nil
}
var r BackupRecord
if json.Unmarshal([]byte(v), &r) != nil {
return nil
}
return &r
}
return get(metaBackupOK), get(metaBackupFailed)
}
+31 -1
View File
@@ -1,4 +1,4 @@
import { h, mount, time, problemsList, badge, plural } from "../ui.js";
import { h, mount, time, relTime, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js";
export async function render(el) {
@@ -14,6 +14,7 @@ export async function render(el) {
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key") + " · " + plural((s.counts || {}).options || 0, "option")),
st.backup ? backupCard(st.backup) : null,
);
const last = s.last_applied;
const parts = [h("div", { class: "page-head" },
@@ -28,6 +29,26 @@ export async function render(el) {
s.drift.map((d) => d.path + " (" + d.reason + ")").join(", "),
h("div", { class: "small" }, "The next apply refuses to overwrite them unless forced. An administrator can rewrite them with 'jarld resync' (or System → Resync).")));
}
const b = st.backup;
if (b && b.stale) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "The newest backup is " + relTime(b.last_at).replace(" ago", "") + " old. "),
"Backups should run daily: check 'systemctl status jarl-backup.timer' and 'journalctl -u jarl-backup' on the server."));
}
if (b && b.last_error) {
parts.push(h("div", { class: "msg err" },
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.")));
}
const exp = st.expiring_tokens || [];
if (exp.length) {
const now = new Date();
parts.push(h("div", { class: "msg warn" },
h("strong", null, exp.length === 1 ? "An API token expires soon: " : "API tokens expire soon: "),
exp.map((t, i) => [i ? ", " : "", h("a", { href: state.me.admin ? "#/users" : "#/account" }, t.username + "/" + t.name), " ",
new Date(t.expires_at) < now ? h("span", null, "expired ", time(t.expires_at)) : h("span", null, "expires ", time(t.expires_at))]),
h("div", { class: "small" }, "Rotate a token to give it a new lifetime and secret, keeping its permissions (API tokens → Rotate, or 'jarl token rotate'). Then update whatever uses it.")));
}
parts.push(h("div", { class: "panel" },
h("h2", null, "Last change"),
last ? h("div", null,
@@ -50,6 +71,15 @@ export async function render(el) {
mount(el, parts);
}
function backupCard(b) {
let value;
if (b.last_error) value = badge("failed", "err");
else if (!b.last_at) value = badge("none yet", "");
else value = badge(relTime(b.last_at), b.stale ? "warn" : "ok");
const sub = b.last_at ? h("span", { class: "mono small", title: b.last_file }, b.last_file.split("/").pop()) : "jarl-backup.timer writes to " + b.dir;
return card("Backup", value, sub);
}
function card(label, value, sub) {
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
}
+11 -1
View File
@@ -104,7 +104,7 @@ export function tokenTable(tokens, redraw) {
{ label: "Name", render: (t) => [h("strong", null, t.name), rotationNote(t)] },
{ label: "ID", render: (t) => "jarl_" + t.public_id + "_…", tdClass: "mono small" },
{ label: "Restricted to", render: (t) => t.grant_strings && t.grant_strings.length ? h("div", { class: "grants" }, t.grant_strings.map((g) => h("div", null, g))) : h("span", { class: "muted" }, "all of the user's permissions"), tdClass: "small" },
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : h("span", { class: "muted" }, "never") },
{ label: "Expires", render: (t) => t.expires_at ? [time(t.expires_at), expiryBadge(t)] : h("span", { class: "muted" }, "never") },
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), " ", h("span", { class: "muted small" }, t.last_used_ip || "")] : h("span", { class: "muted" }, "never") },
{ label: "", render: (t) => [
h("button", { class: "small", onclick: () => rotateDialog(t, redraw) }, "Rotate"), " ",
@@ -117,6 +117,16 @@ export function tokenTable(tokens, redraw) {
], tokens, { empty: "No tokens." });
}
// expiryBadge flags a token that expires within 14 days (the server's
// TokenExpiryWarning) or has expired.
function expiryBadge(t) {
if (t.revoked_at) return null;
const left = new Date(t.expires_at) - Date.now();
if (left < 0) return [" ", badge("expired", "err")];
if (left < 14 * 86400 * 1000) return [" ", badge("soon", "warn")];
return null;
}
// rotationNote says when a token last got a new secret, and whether its
// old secret still works.
function rotationNote(t) {
+101
View File
@@ -14,6 +14,7 @@ export async function render(el, params, query) {
["settings", "Settings"],
["options", "Options"],
zone.type === "primary" ? ["file", "Zone file"] : null,
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
].filter(Boolean);
const body = h("div");
mount(el,
@@ -34,6 +35,106 @@ export async function render(el, params, query) {
else if (tab === "settings") await settingsTab(body, zone);
else if (tab === "options") await optionsEditor(body, { scope: "zone", scope_id: zone.id, catalogScope: "zone:" + zone.type, title: "Zone options" });
else if (tab === "file") await fileTab(body, zone);
else if (tab === "servers") await serversTab(body, zone);
}
// ---- name servers: serials on the other servers, and the delegation
const nsStatus = {
self: ["this server", ""], in_sync: ["in sync", "ok"], answered: ["answers", "ok"],
behind: [null, "warn"], ahead: [null, "warn"], not_authoritative: ["doesn't serve the zone", "err"],
refused: ["refused", "err"], unreachable: ["no answer", "err"], no_route: ["no route from here", ""],
no_address: ["no address", "err"], error: ["error", "err"],
};
function nsBadge(r) {
let [text, kind] = nsStatus[r.status] || [r.status, "err"];
if (r.status === "behind") text = "behind by " + -r.diff;
if (r.status === "ahead") text = "ahead by " + r.diff;
return [badge(text, kind), r.error && r.status !== "unreachable" ? h("div", { class: "muted small" }, r.error) : null];
}
function nsTable(servers, withSource) {
return table([
{ label: "Server", render: (r) => r.name ? display(r.name) : h("span", { class: "muted" }, "–"), tdClass: "mono" },
{ label: "Address", render: (r) => (r.address || "–") + (r.port ? " port " + r.port : ""), tdClass: "mono small" },
withSource ? { label: "From", render: (r) => r.source, tdClass: "small" } : null,
{ label: "Status", render: nsBadge },
{ label: "Serial", render: (r) => r.serial ?? "–", tdClass: "mono" },
{ label: "Time", render: (r) => r.rtt_ms ? r.rtt_ms + " ms" : "", tdClass: "small muted" },
].filter(Boolean), servers, { empty: "No other name servers to ask." });
}
async function serversTab(el, zone) {
const serials = h("div");
const loadSerials = async () => {
mount(serials, h("div", { class: "muted" }, "Asking the name servers…"));
try {
const r = await get("/zones/" + zone.id + "/serials");
const src = { named: "served by named here", jarl: "Jarl's; named wasn't asked" }[r.serial_source];
mount(serials,
h("p", null, "This server: serial ", h("strong", { class: "mono" }, r.serial ?? "unknown"), src ? h("span", { class: "muted" }, " (" + src + ")") : null),
nsTable(r.servers, true),
r.notes.map((n) => h("p", { class: "muted small" }, n)),
h("p", { class: "muted small" }, "Checked ", time(r.checked_at), "."));
} catch (err) {
mount(serials, errorBox(err));
}
};
const parts = [h("div", { class: "panel" },
h("div", { class: "page-head" },
h("h2", { class: "grow" }, zone.type === "primary" ? "Serials on the secondaries" : "Serials on the primaries"),
h("button", { class: "small", onclick: loadSerials }, "Check again")),
h("p", { class: "muted small" }, zone.type === "primary"
? "Asks the servers in the zone's NS records and its also-notify targets which serial they serve. A secondary that is behind hasn't picked up the latest change yet."
: "Asks the zone's primaries which serial they serve, to see whether this server's copy is up to date."),
serials)];
if (zone.type === "primary") {
const out = h("div");
const resolver = input({ placeholder: "the server's own" });
const run = async (e) => {
e.preventDefault();
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
try {
const q = resolver.value.trim() ? { resolver: resolver.value.trim() } : {};
mount(out, delegationReport(await get("/zones/" + zone.id + "/delegation", q)));
} catch (err) {
mount(out, errorBox(err));
}
};
parts.push(h("div", { class: "panel" },
h("h2", null, "Delegation from the parent zone"),
h("p", { class: "muted small" }, "For zones on the internet: checks that the parent zone (for adbraun.lol, \"lol\") points to this zone's name servers with the right glue addresses, and that each of them serves the zone. A zone used only on your own network isn't delegated, and doesn't need this."),
h("form", { class: "inline-form", onsubmit: run },
h("div", { class: "f" }, h("label", null, "Resolver (optional)"), resolver),
h("div", { class: "f" }, h("label", null, " "), h("button", { type: "submit" }, "Check delegation"))),
out));
}
mount(el, parts);
await loadSerials();
}
function delegationReport(r) {
const lvl = { ok: "ok", info: "", warning: "warn", error: "err" };
const parents = {};
for (const p of r.parent_servers) {
const say = p.status === "delegated" ? "delegate to " + p.ns.map(display).join(", ") : p.status === "not_delegated" ? "no delegation" : p.status + (p.error ? ": " + p.error : "");
(parents[say] = parents[say] || []).push(display(p.name));
}
return [
r.parent ? h("p", null, "Parent zone ", h("strong", { class: "mono" }, display(r.parent)), h("span", { class: "muted" }, " (found through " + r.resolver + ")")) : null,
Object.keys(parents).length ? h("ul", { class: "small" }, Object.entries(parents).map(([say, who]) =>
h("li", null, who.length > 1 ? who[0] + " and " + (who.length - 1) + " more: " : who[0] + ": ", say))) : null,
h("div", null, r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))),
r.glue.length ? table([
{ label: "Glue for", render: (g) => display(g.name), tdClass: "mono" },
{ label: "Parent", render: (g) => g.parent.join(", ") || "none", tdClass: "mono small" },
{ label: "Zone", render: (g) => g.jarl.join(", ") || "none", tdClass: "mono small" },
{ label: "", render: (g) => g.match ? badge("matches", "ok") : badge("differs", "warn") },
], r.glue) : null,
r.servers.length ? [h("h3", null, "Delegated name servers"), nsTable(r.servers, false)] : null,
r.notes.map((n) => h("p", { class: "muted small" }, n)),
];
}
async function deleteZone(zone) {