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:
1 parent
1afed741bf
commit
293e6ddd33
47 files changed
+3717
-95
No files matched your search
@@ -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"`
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user