Add DNSSEC signing, key backups and Porkbun support
Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
293e6ddd33
commit
5630e7a848
72 files changed
+5760
-153
No files matched your search
@@ -6,8 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// Version is the API path prefix.
|
||||
@@ -21,6 +23,9 @@ type Zone struct {
|
||||
Records int `json:"record_count"`
|
||||
ZoneFile string `json:"zone_file,omitempty"` // the file named loads
|
||||
RName string `json:"rname_email,omitempty"`
|
||||
// Registrar names the registrar Jarl can manage the domain at
|
||||
// ("porkbun"), when one is configured for it.
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -267,3 +272,105 @@ type DelegationResponse struct {
|
||||
Notes []string `json:"notes"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
// DNSSECResponse is GET /zones/{zone}/dnssec.
|
||||
type DNSSECResponse struct {
|
||||
Zone string `json:"zone"`
|
||||
View string `json:"view,omitempty"`
|
||||
Type string `json:"type"`
|
||||
*dnssec.Status
|
||||
// ParentZone is set when the parent zone is a primary zone in Jarl
|
||||
// (in the same view): the DS record then goes there, as a record.
|
||||
ParentZone string `json:"parent_zone,omitempty"`
|
||||
// Registrar names the registrar Jarl can publish the DS at
|
||||
// ("porkbun"), when one is configured in jarl.yaml.
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
}
|
||||
|
||||
// CheckDSResponse is POST /zones/{zone}/dnssec/check-ds: what the parent
|
||||
// zone has, and what named was told.
|
||||
type CheckDSResponse struct {
|
||||
Parent *nscheck.ParentDSReport `json:"parent"`
|
||||
Marked []string `json:"marked"`
|
||||
Notes []string `json:"notes"`
|
||||
Status *dnssec.Status `json:"status"`
|
||||
}
|
||||
|
||||
// DNSSECWipe is POST /zones/{zone}/dnssec/wipe.
|
||||
type DNSSECWipe struct {
|
||||
// Force wipes although the parent zone's DS records couldn't be
|
||||
// checked, or named still counts a removed DS as cached.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// DNSSECWipeResponse is the result of a wipe.
|
||||
type DNSSECWipeResponse struct {
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
ChangesetID int64 `json:"changeset_id,omitempty"`
|
||||
Parent *nscheck.ParentDSReport `json:"parent,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarResponse is GET /zones/{zone}/registrar: the domain's
|
||||
// delegation at the registrar, next to what the zone says.
|
||||
type RegistrarResponse struct {
|
||||
Registrar string `json:"registrar"` // "porkbun"
|
||||
Domain string `json:"domain"`
|
||||
// NS are the name servers at the registry, JarlNS those in the
|
||||
// zone's NS records (both without trailing dots).
|
||||
NS []string `json:"ns"`
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
NSMatch bool `json:"ns_match"`
|
||||
// Glue covers every glue record at the registry and every name server
|
||||
// inside the zone that needs one.
|
||||
Glue []GlueStatus `json:"glue"`
|
||||
// DS are the DS records at the registry.
|
||||
DS []RegistrarDS `json:"ds"`
|
||||
Findings []nscheck.Finding `json:"findings"`
|
||||
// Error is set when the registrar couldn't be asked; the rest is
|
||||
// then empty.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GlueStatus compares a glue record at the registry with the zone.
|
||||
type GlueStatus struct {
|
||||
Host string `json:"host"`
|
||||
Registry []string `json:"registry"` // addresses at the registry (none: no glue record)
|
||||
Jarl []string `json:"jarl"` // the zone's A and AAAA records for the host
|
||||
// Needed: a name server in the zone's NS records that is inside the
|
||||
// domain, so resolvers need its glue. InUse: in the registry's NS.
|
||||
Needed bool `json:"needed"`
|
||||
InUse bool `json:"in_use"`
|
||||
// Action is what syncing the glue does: "create", "update" or "".
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarDS is a DS record at the registry.
|
||||
type RegistrarDS struct {
|
||||
porkbun.DS
|
||||
// Matches: it points at a key named publishes for the zone.
|
||||
Matches bool `json:"matches"`
|
||||
}
|
||||
|
||||
// RegistrarChange asks for a change at the registrar.
|
||||
type RegistrarChange struct {
|
||||
// NS (set-ns) are the name servers to set (default: the zone's NS
|
||||
// records).
|
||||
NS []string `json:"ns,omitempty"`
|
||||
// KeyTag (publish DS) picks the key (default: every key that needs
|
||||
// one).
|
||||
KeyTag uint16 `json:"key_tag,omitempty"`
|
||||
// Force skips the checks that the change won't break the domain.
|
||||
Force bool `json:"force,omitempty"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// RegistrarChangeResponse is the result of a change at the registrar.
|
||||
type RegistrarChangeResponse struct {
|
||||
// Done lists what was changed (or would be, in a dry run).
|
||||
Done []string `json:"done"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
Checks []nscheck.Finding `json:"checks"`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
+144
-3
@@ -59,9 +59,11 @@ func parse(name string) (kind string, t time.Time, ok bool) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// dir/Name(kind, now), stores the files under filesDir in it (the DNSSEC
|
||||
// keys; "" for none, see AddFiles), 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, filesDir string) (*File, error) {
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,6 +80,12 @@ func Write(ctx context.Context, src, dir, kind string, now time.Time) (*File, er
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if filesDir != "" {
|
||||
if _, err := AddFiles(ctx, tmp, filesDir); 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)
|
||||
@@ -144,6 +152,130 @@ func openCopy(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", "file:"+escape(path)+"?mode=ro&immutable=1")
|
||||
}
|
||||
|
||||
// FilesTable holds the files a backup stores next to the database. It
|
||||
// exists only in backups: 'jarld restore' writes the files back and drops
|
||||
// the table.
|
||||
const FilesTable = "backup_files"
|
||||
|
||||
// StoredFile is a file stored in a backup.
|
||||
type StoredFile struct {
|
||||
// Path is relative to the directory the files came from (Root).
|
||||
Path string
|
||||
Mode os.FileMode
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// AddFiles stores every regular file under root in the backup database
|
||||
// at path (the DNSSEC keys named keeps under Jarl's key directory,
|
||||
// private keys included). A missing root stores nothing. Files that can't
|
||||
// be read are an error: named's private keys are readable only by named's
|
||||
// user, so the backup must run as root or with CAP_DAC_READ_SEARCH (as
|
||||
// jarl-backup.service does).
|
||||
func AddFiles(ctx context.Context, path, root string) (int, error) {
|
||||
var files []StoredFile
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && p == root {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, StoredFile{Path: rel, Mode: fi.Mode().Perm(), Data: b})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if os.IsPermission(err) {
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w (named's private keys are readable only by its own user: back up as root, or through jarl-backup.service)", err)
|
||||
}
|
||||
return 0, fmt.Errorf("reading the DNSSEC keys: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE `+FilesTable+` (root TEXT NOT NULL, path TEXT NOT NULL, mode INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (root, path))`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, f := range files {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO `+FilesTable+` (root, path, mode, data) VALUES (?, ?, ?, ?)`, root, f.Path, int(f.Mode), f.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(files), tx.Commit()
|
||||
}
|
||||
|
||||
// Files returns the files stored in a backup and the directory they came
|
||||
// from ("" and none for a backup without files).
|
||||
func Files(ctx context.Context, path string) (string, []StoredFile, error) {
|
||||
db, err := openCopy(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return "", nil, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT root, path, mode, data FROM `+FilesTable+` ORDER BY path`)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var root string
|
||||
var out []StoredFile
|
||||
for rows.Next() {
|
||||
var f StoredFile
|
||||
var mode int
|
||||
if err := rows.Scan(&root, &f.Path, &mode, &f.Data); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
f.Mode = os.FileMode(mode).Perm()
|
||||
out = append(out, f)
|
||||
}
|
||||
return root, out, rows.Err()
|
||||
}
|
||||
|
||||
func hasTable(ctx context.Context, db *sql.DB, name string) bool {
|
||||
var n int
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// DropFiles removes the stored files from a database restored from a
|
||||
// backup, and vacuums it so the private keys don't linger in free pages.
|
||||
func DropFiles(ctx context.Context, path string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+escape(path)+"?mode=rw")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if !hasTable(ctx, db, FilesTable) {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TABLE `+FilesTable); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `VACUUM`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Info describes the contents of a configuration database (KindJarl).
|
||||
type Info struct {
|
||||
Schema int `json:"schema"`
|
||||
@@ -151,6 +283,10 @@ type Info struct {
|
||||
Records int `json:"records"`
|
||||
Users int `json:"users"`
|
||||
LastChange *time.Time `json:"last_change,omitempty"`
|
||||
// KeyFiles is the number of DNSSEC key files stored in a backup, and
|
||||
// SignedZones how many zones were signed.
|
||||
KeyFiles int `json:"key_files"`
|
||||
SignedZones int `json:"signed_zones"`
|
||||
}
|
||||
|
||||
// Inspect reads a configuration database's schema version and size. It
|
||||
@@ -168,6 +304,11 @@ func Inspect(ctx context.Context, path string) (*Info, error) {
|
||||
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)
|
||||
if hasTable(ctx, db, FilesTable) {
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM `+FilesTable).Scan(&in.KeyFiles)
|
||||
}
|
||||
// dnssec_policy exists from schema 3 on.
|
||||
db.QueryRowContext(ctx, `SELECT count(*) FROM zones WHERE dnssec_policy != ''`).Scan(&in.SignedZones)
|
||||
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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
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)
|
||||
f, err := Write(ctx, src, dir, KindJarl, now, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -44,7 +45,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
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 {
|
||||
if _, err := Write(ctx, src, dir, KindJarl, now, ""); err == nil {
|
||||
t.Error("overwrote a backup")
|
||||
}
|
||||
// No temporary files are left.
|
||||
@@ -54,7 +55,7 @@ func TestWriteVerifyInspect(t *testing.T) {
|
||||
}
|
||||
// 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 {
|
||||
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 {
|
||||
@@ -149,3 +150,52 @@ func TestPruneTempFiles(t *testing.T) {
|
||||
t.Error("a running backup's temporary file was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyFiles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
src := newDB(t)
|
||||
keys := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(keys, "view-3"), 0o755)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.key"), []byte("public"), 0o644)
|
||||
os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), []byte("private-key-material-xyz"), 0o600)
|
||||
dir := t.TempDir()
|
||||
f, err := Write(ctx, src, dir, KindJarl, time.Now(), keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, files, err := Files(ctx, f.Path)
|
||||
if err != nil || root != keys || len(files) != 2 {
|
||||
t.Fatalf("files: %q %+v %v", root, files, err)
|
||||
}
|
||||
if files[1].Path != "view-3/Kexample.com.+013+12345.private" || string(files[1].Data) != "private-key-material-xyz" || files[1].Mode != 0o600 {
|
||||
t.Errorf("private key: %+v", files[1])
|
||||
}
|
||||
if in, err := Inspect(ctx, f.Path); err != nil || in.KeyFiles != 2 {
|
||||
t.Errorf("inspect: %+v %v", in, err)
|
||||
}
|
||||
// A restored database loses the table again.
|
||||
restored := filepath.Join(t.TempDir(), "jarl.db")
|
||||
b, _ := os.ReadFile(f.Path)
|
||||
os.WriteFile(restored, b, 0o600)
|
||||
if err := DropFiles(ctx, restored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, files, _ := Files(ctx, restored); len(files) != 0 {
|
||||
t.Error("files left after DropFiles")
|
||||
}
|
||||
if b, _ := os.ReadFile(restored); strings.Contains(string(b), "private-key-material-xyz") {
|
||||
t.Error("the private key is still in the file")
|
||||
}
|
||||
// No key directory: nothing stored. An unreadable key: an error.
|
||||
if f, err := Write(ctx, src, dir, KindJarl, time.Now().Add(time.Second), filepath.Join(keys, "missing")); err != nil {
|
||||
t.Error(err)
|
||||
} else if _, files, _ := Files(ctx, f.Path); len(files) != 0 {
|
||||
t.Errorf("files from a missing directory: %v", files)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
os.Chmod(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), 0)
|
||||
if _, err := Write(ctx, src, dir, KindJarl, time.Now().Add(2*time.Second), keys); err == nil || !strings.Contains(err.Error(), "back up as root") {
|
||||
t.Errorf("unreadable key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-10
@@ -223,6 +223,18 @@ func (t *Tools) ReloadZone(ctx context.Context, zone, view string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckDS tells named that the parent zone has published (or withdrawn)
|
||||
// the DS record for key tag of a zone it signs (rndc dnssec -checkds).
|
||||
func (t *Tools) CheckDS(ctx context.Context, zone, view string, tag uint16, alg uint8, published bool) (string, error) {
|
||||
which := "withdrawn"
|
||||
if published {
|
||||
which = "published"
|
||||
}
|
||||
z := zoneArgs("", zone, view)[1:]
|
||||
args := append([]string{"dnssec", "-checkds", "-key", strconv.Itoa(int(tag)), "-alg", strconv.Itoa(int(alg)), which}, z...)
|
||||
return t.RNDCRun(ctx, args...)
|
||||
}
|
||||
|
||||
func zoneArgs(cmd, zone, view string) []string {
|
||||
z := strings.TrimSuffix(zone, ".")
|
||||
if z == "" {
|
||||
@@ -236,16 +248,32 @@ func zoneArgs(cmd, zone, view string) []string {
|
||||
|
||||
// ZoneStatus is the result of rndc zonestatus.
|
||||
type ZoneStatus struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Files string `json:"files"`
|
||||
// Serial is the serial of the zone file named loaded. For a zone
|
||||
// named signs (inline signing) that is the unsigned zone Jarl wrote;
|
||||
// the signed zone that named serves has its own serial, SignedSerial.
|
||||
Serial uint32 `json:"serial"`
|
||||
HasSerial bool `json:"-"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
HasSignedSerial bool `json:"-"`
|
||||
Nodes int `json:"nodes"`
|
||||
LastLoaded string `json:"last_loaded"`
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
Dynamic bool `json:"dynamic"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// ServedSerial is the serial named answers queries with: the signed
|
||||
// zone's for a zone it signs, otherwise the loaded one.
|
||||
func (zs *ZoneStatus) ServedSerial() (uint32, bool) {
|
||||
if zs.HasSignedSerial {
|
||||
return zs.SignedSerial, true
|
||||
}
|
||||
return zs.Serial, zs.HasSerial
|
||||
}
|
||||
|
||||
// ZoneStatus returns named's view of a zone.
|
||||
@@ -277,6 +305,14 @@ func ParseZoneStatus(out string) *ZoneStatus {
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.Serial, zs.HasSerial = uint32(n), true
|
||||
}
|
||||
case "signed serial":
|
||||
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
|
||||
zs.SignedSerial, zs.HasSignedSerial = uint32(n), true
|
||||
}
|
||||
case "inline signing":
|
||||
zs.InlineSigning = v == "yes"
|
||||
case "next key event":
|
||||
zs.NextKeyEvent = v
|
||||
case "nodes":
|
||||
zs.Nodes, _ = strconv.Atoi(v)
|
||||
case "last loaded":
|
||||
|
||||
@@ -17,3 +17,33 @@ zone example.com/IN/internal: loaded serial 1
|
||||
t.Errorf("exit 1: %+v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZoneStatusSigned(t *testing.T) {
|
||||
// rndc zonestatus of a zone named signs with dnssec-policy (BIND 9.20).
|
||||
zs := ParseZoneStatus(`name: example.test
|
||||
type: primary
|
||||
files: /var/lib/bind/jarl/_default/example.test.zone
|
||||
serial: 2026100802
|
||||
signed serial: 2026100804
|
||||
nodes: 4
|
||||
last loaded: Thu, 08 Oct 2026 19:03:31 GMT
|
||||
secure: yes
|
||||
inline signing: yes
|
||||
key maintenance: automatic
|
||||
next key event: Thu, 08 Oct 2026 21:08:31 GMT
|
||||
next resign node: new.example.test/A
|
||||
next resign time: Sat, 17 Oct 2026 07:32:59 GMT
|
||||
dynamic: no
|
||||
reconfigurable via modzone: no
|
||||
`)
|
||||
if zs.Serial != 2026100802 || zs.SignedSerial != 2026100804 || !zs.Secure || !zs.InlineSigning || zs.NextKeyEvent != "Thu, 08 Oct 2026 21:08:31 GMT" {
|
||||
t.Errorf("%+v", zs)
|
||||
}
|
||||
if s, ok := zs.ServedSerial(); !ok || s != 2026100804 {
|
||||
t.Errorf("served serial %d %v", s, ok)
|
||||
}
|
||||
plain := ParseZoneStatus("name: example.com\nserial: 7\nsecure: no\n")
|
||||
if s, ok := plain.ServedSerial(); !ok || s != 7 || plain.HasSignedSerial {
|
||||
t.Errorf("unsigned: %+v", plain)
|
||||
}
|
||||
}
|
||||
@@ -102,13 +102,25 @@ var managed = map[string]map[string]string{
|
||||
"match-recursive-only": "the view's match-recursive-only field",
|
||||
"key": "TSIG keys (define keys at the top level)",
|
||||
"zone": "zones",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
ScopeOptions: {
|
||||
"dnssec-policy": dnssecManaged,
|
||||
},
|
||||
}
|
||||
|
||||
// dnssecManaged: Jarl turns signing on and off per zone, so that it can
|
||||
// guard the steps (a zone mustn't go unsigned while the parent zone still
|
||||
// has a DS record for it). dnssec-policy statements that define custom
|
||||
// policies (at the top level) are still options.
|
||||
const dnssecManaged = "each zone's DNSSEC setting (jarl zone dnssec enable ZONE; see docs/dnssec.md)"
|
||||
|
||||
var zoneManaged = map[string]string{
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"type": "the zone's type field",
|
||||
"file": "the zone's file (generated by Jarl)",
|
||||
"in-view": "the zone's in-view field",
|
||||
"dnssec-policy": dnssecManaged,
|
||||
"key-directory": "Jarl (signed zones keep their keys in Jarl's key directory, which its backups include)",
|
||||
}
|
||||
|
||||
// synonyms are older spellings that named 9.20 still accepts.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -34,6 +35,62 @@ type Config struct {
|
||||
Backup Backup `yaml:"backup"`
|
||||
Stats Stats `yaml:"stats"`
|
||||
ACME ACME `yaml:"acme"`
|
||||
Porkbun Porkbun `yaml:"porkbun"`
|
||||
}
|
||||
|
||||
// Porkbun lets Jarl manage domains' name servers, glue records and DNSSEC
|
||||
// DS records at the registrar Porkbun (docs/dnssec.md#porkbun). The key
|
||||
// lives only here, in a file root and jarld can read, and never in the
|
||||
// database (so not in backups either).
|
||||
type Porkbun struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
SecretAPIKey string `yaml:"secret_api_key"`
|
||||
// Domains limits which domains Jarl may manage at Porkbun (default:
|
||||
// every Jarl zone the key can reach).
|
||||
Domains []string `yaml:"domains"`
|
||||
// APIURL is the API's base URL (for tests).
|
||||
APIURL string `yaml:"api_url"`
|
||||
}
|
||||
|
||||
// Enabled reports whether a Porkbun key is configured.
|
||||
func (p *Porkbun) Enabled() bool { return p.APIKey != "" }
|
||||
|
||||
// Allows reports whether Jarl may manage domain (no trailing dot) at
|
||||
// Porkbun.
|
||||
func (p *Porkbun) Allows(domain string) bool {
|
||||
if !p.Enabled() {
|
||||
return false
|
||||
}
|
||||
if len(p.Domains) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, d := range p.Domains {
|
||||
if strings.EqualFold(strings.TrimSuffix(d, "."), domain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *Porkbun) check() error {
|
||||
if (p.APIKey == "") != (p.SecretAPIKey == "") {
|
||||
return fmt.Errorf("set both api_key and secret_api_key (or neither)")
|
||||
}
|
||||
if p.APIURL != "" {
|
||||
u, err := url.Parse(p.APIURL)
|
||||
if err != nil || (u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname()))) {
|
||||
return fmt.Errorf("api_url must be an https:// URL")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopbackHost(h string) bool {
|
||||
if h == "localhost" {
|
||||
return true
|
||||
}
|
||||
a, err := netip.ParseAddr(h)
|
||||
return err == nil && a.IsLoopback()
|
||||
}
|
||||
|
||||
// Backup configures 'jarld backup' run without a file name, which is what
|
||||
@@ -94,11 +151,14 @@ type Session struct {
|
||||
|
||||
// BIND says where BIND's files and tools are.
|
||||
type BIND struct {
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
MainConfig string `yaml:"main_config"`
|
||||
ConfigDir string `yaml:"config_dir"`
|
||||
ZoneDir string `yaml:"zone_dir"`
|
||||
CacheDir string `yaml:"cache_dir"`
|
||||
FileGroup string `yaml:"file_group"`
|
||||
// NamedUser is the user named runs as. 'jarld restore' gives it the
|
||||
// DNSSEC keys it puts back.
|
||||
NamedUser string `yaml:"named_user"`
|
||||
NamedCheckConf string `yaml:"named_checkconf"`
|
||||
NamedCheckZone string `yaml:"named_checkzone"`
|
||||
RNDC string `yaml:"rndc"`
|
||||
@@ -200,6 +260,7 @@ func Default() *Config {
|
||||
ZoneDir: "/var/lib/bind/jarl",
|
||||
CacheDir: "/var/cache/bind/jarl",
|
||||
FileGroup: "bind",
|
||||
NamedUser: "bind",
|
||||
NamedCheckConf: "/usr/bin/named-checkconf",
|
||||
NamedCheckZone: "/usr/bin/named-checkzone",
|
||||
RNDC: "/usr/sbin/rndc",
|
||||
@@ -273,6 +334,9 @@ func (c *Config) check() error {
|
||||
if err := c.Backup.check(); err != nil {
|
||||
return fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
if err := c.Porkbun.check(); err != nil {
|
||||
return fmt.Errorf("porkbun: %w", err)
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
@@ -415,6 +479,8 @@ bind:
|
||||
cache_dir: /var/cache/bind/jarl
|
||||
# Group of generated files; named must be able to read them.
|
||||
file_group: bind
|
||||
# The user named runs as ('jarld restore' gives it the DNSSEC keys back).
|
||||
named_user: bind
|
||||
named_checkconf: /usr/bin/named-checkconf
|
||||
named_checkzone: /usr/bin/named-checkzone
|
||||
rndc: /usr/sbin/rndc
|
||||
@@ -434,7 +500,9 @@ audit:
|
||||
# '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'.
|
||||
# like that are ever deleted. Each backup also holds the DNSSEC keys of
|
||||
# signed zones (private keys included), so keep the directory private.
|
||||
# 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
|
||||
@@ -478,4 +546,17 @@ acme:
|
||||
# this server. Leave false if a reverse proxy runs on this server but
|
||||
# isn't in trusted_proxies: its requests would look local.
|
||||
allow_loopback_http: false
|
||||
|
||||
# The registrar Porkbun (docs/dnssec.md#porkbun): with an API key, Jarl can
|
||||
# show and set a domain's name servers, glue records and DNSSEC DS records
|
||||
# there, after checking that the change won't break the domain. Off without
|
||||
# a key. Create the key at porkbun.com (Account -> API Access), switch on
|
||||
# "API Access" for the domain, and limit the key to that domain if you can.
|
||||
# The key can move your domain elsewhere: it lives only in this file (keep
|
||||
# it readable by root and jarld only: chown root:jarl, chmod 640), never in
|
||||
# Jarl's database or backups.
|
||||
porkbun:
|
||||
api_key: "" # pk1_...
|
||||
secret_api_key: "" # sk1_...
|
||||
domains: [] # only these domains (default: any Jarl zone the key reaches)
|
||||
`
|
||||
@@ -0,0 +1,320 @@
|
||||
// Package dnssec reads the DNSSEC keys named keeps for the zones it signs
|
||||
// (dnssec-policy with inline signing) and works out where a zone is in the
|
||||
// steps of turning signing on or off.
|
||||
//
|
||||
// named writes three files per key into the zone's key-directory:
|
||||
// K<zone>.+<alg>+<tag>.key (the public DNSKEY, world-readable),
|
||||
// .private (the private key, readable only by named's user) and .state
|
||||
// (the key's timing and state, see the dnssec-keymgr states in the BIND
|
||||
// ARM). Jarl only reads the .key and .state files.
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Key states, as named writes them in .state files.
|
||||
const (
|
||||
Hidden = "hidden" // not in the zone (or the parent)
|
||||
Rumoured = "rumoured" // published, but caches may not have it yet
|
||||
Omnipresent = "omnipresent" // published long enough that everyone has it
|
||||
Unretentive = "unretentive" // removed, but caches may still have it
|
||||
)
|
||||
|
||||
// Status describes how a zone is signed.
|
||||
type Status struct {
|
||||
Policy string `json:"policy"`
|
||||
// KeyDir is the zone's key-directory.
|
||||
KeyDir string `json:"key_dir"`
|
||||
Keys []*Key `json:"keys"`
|
||||
Phase Phase `json:"phase"`
|
||||
Named *NamedSigning `json:"named,omitempty"`
|
||||
// NamedError says why named couldn't be asked.
|
||||
NamedError string `json:"named_error,omitempty"`
|
||||
// KeyError says why the key directory couldn't be read.
|
||||
KeyError string `json:"key_error,omitempty"`
|
||||
// Policies are the policies the zone could use: default and the
|
||||
// custom dnssec-policy statements.
|
||||
Policies []string `json:"policies"`
|
||||
}
|
||||
|
||||
// NamedSigning is what named reports about a zone it signs.
|
||||
type NamedSigning struct {
|
||||
Secure bool `json:"secure"`
|
||||
InlineSigning bool `json:"inline_signing"`
|
||||
Serial uint32 `json:"serial"`
|
||||
SignedSerial uint32 `json:"signed_serial,omitempty"`
|
||||
NextKeyEvent string `json:"next_key_event,omitempty"`
|
||||
}
|
||||
|
||||
// DS is a DS record for a key: what the parent zone publishes to vouch
|
||||
// for it.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
// Record is the DS record in zone file format.
|
||||
Record string `json:"record"`
|
||||
}
|
||||
|
||||
// Key is one DNSSEC key of a zone.
|
||||
type Key struct {
|
||||
Tag uint16 `json:"tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
AlgorithmName string `json:"algorithm_name"`
|
||||
// Role is CSK (signs everything: BIND's default policy), KSK (signs
|
||||
// the DNSKEY set; the parent's DS points at it) or ZSK.
|
||||
Role string `json:"role"`
|
||||
// DNSKEY is the public key record, and DS its DS record (KSK and CSK
|
||||
// only).
|
||||
DNSKEY string `json:"dnskey,omitempty"`
|
||||
DS *DS `json:"ds,omitempty"`
|
||||
// States from the .state file.
|
||||
Goal string `json:"goal,omitempty"`
|
||||
DNSKEYState string `json:"dnskey_state,omitempty"`
|
||||
DSState string `json:"ds_state,omitempty"`
|
||||
ZoneRRSIG string `json:"zone_rrsig_state,omitempty"`
|
||||
KeyRRSIG string `json:"key_rrsig_state,omitempty"`
|
||||
// Times from the .state file.
|
||||
Created *time.Time `json:"created,omitempty"`
|
||||
Published *time.Time `json:"published,omitempty"`
|
||||
Active *time.Time `json:"active,omitempty"`
|
||||
Retired *time.Time `json:"retired,omitempty"`
|
||||
Removed *time.Time `json:"removed,omitempty"`
|
||||
// PublishCDS is when named publishes CDS/CDNSKEY records: from then on
|
||||
// the DS can safely be added at the parent.
|
||||
PublishCDS *time.Time `json:"publish_cds,omitempty"`
|
||||
// DSPublished and DSRemoved are when named was told (or saw) that the
|
||||
// parent added or removed the DS.
|
||||
DSPublished *time.Time `json:"ds_published,omitempty"`
|
||||
DSRemoved *time.Time `json:"ds_removed,omitempty"`
|
||||
// Files are the key's file names in the key directory.
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
// SigningKey reports whether the parent's DS should point at the key.
|
||||
func (k *Key) SigningKey() bool { return k.Role == "CSK" || k.Role == "KSK" }
|
||||
|
||||
// Gone reports whether named has finished removing the key: every state
|
||||
// is hidden.
|
||||
func (k *Key) Gone() bool {
|
||||
for _, s := range []string{k.Goal, k.DNSKEYState, k.DSState, k.ZoneRRSIG, k.KeyRRSIG} {
|
||||
if s != "" && s != Hidden {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FilePrefix is how named's key files for zone (a name with the trailing
|
||||
// dot) begin.
|
||||
func FilePrefix(zone string) string { return "K" + strings.ToLower(zone) + "+" }
|
||||
|
||||
// Files lists the names of zone's key files in dir (every .key, .private
|
||||
// and .state file, and any other file named with the zone's key prefix).
|
||||
// A missing directory has none.
|
||||
func Files(dir, zone string) ([]string, error) {
|
||||
ents, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := FilePrefix(zone)
|
||||
var out []string
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() && strings.HasPrefix(strings.ToLower(e.Name()), strings.ToLower(prefix)) {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Keys reads zone's keys from dir, ordered with the signing keys first.
|
||||
func Keys(dir, zone string) ([]*Key, error) {
|
||||
files, err := Files(dir, zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byBase := map[string]*Key{}
|
||||
var order []string
|
||||
for _, f := range files {
|
||||
ext := filepath.Ext(f)
|
||||
base := strings.TrimSuffix(f, ext)
|
||||
k := byBase[base]
|
||||
if k == nil {
|
||||
k = &Key{}
|
||||
byBase[base] = k
|
||||
order = append(order, base)
|
||||
}
|
||||
k.Files = append(k.Files, f)
|
||||
switch ext {
|
||||
case ".key":
|
||||
if err := k.readPublic(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ".state":
|
||||
if err := k.readState(filepath.Join(dir, f)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []*Key
|
||||
for _, b := range order {
|
||||
k := byBase[b]
|
||||
if k.DNSKEY == "" && k.Goal == "" {
|
||||
continue // only a .private file: nothing to show
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].SigningKey() != out[j].SigningKey() {
|
||||
return out[i].SigningKey()
|
||||
}
|
||||
return out[i].Tag < out[j].Tag
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (k *Key) readPublic(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zp := dns.NewZoneParser(strings.NewReader(string(b)), "", path)
|
||||
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
|
||||
key, isKey := rr.(*dns.DNSKEY)
|
||||
if !isKey {
|
||||
continue
|
||||
}
|
||||
k.Tag = key.KeyTag()
|
||||
k.Algorithm = key.Algorithm
|
||||
k.AlgorithmName = dns.AlgorithmToString[key.Algorithm]
|
||||
k.DNSKEY = key.String()
|
||||
if k.Role == "" {
|
||||
switch {
|
||||
case key.Flags&dns.SEP != 0:
|
||||
k.Role = "KSK"
|
||||
default:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
}
|
||||
if key.Flags&dns.SEP != 0 {
|
||||
if ds := key.ToDS(dns.SHA256); ds != nil {
|
||||
k.DS = &DS{KeyTag: ds.KeyTag, Algorithm: ds.Algorithm, DigestType: ds.DigestType, Digest: strings.ToUpper(ds.Digest), Record: ds.String()}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := zp.Err(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return fmt.Errorf("%s: no DNSKEY record", path)
|
||||
}
|
||||
|
||||
func (k *Key) readState(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
var ksk, zsk bool
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
name, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
t := func() *time.Time {
|
||||
// 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
f := strings.Fields(v)
|
||||
if len(f) == 0 {
|
||||
return nil
|
||||
}
|
||||
ts, err := time.Parse("20060102150405", f[0])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ts
|
||||
}
|
||||
switch name {
|
||||
case "Algorithm":
|
||||
if n, err := strconv.Atoi(v); err == nil && k.Algorithm == 0 {
|
||||
k.Algorithm = uint8(n)
|
||||
k.AlgorithmName = dns.AlgorithmToString[uint8(n)]
|
||||
}
|
||||
case "KSK":
|
||||
ksk = v == "yes"
|
||||
case "ZSK":
|
||||
zsk = v == "yes"
|
||||
case "Generated":
|
||||
k.Created = t()
|
||||
case "Published":
|
||||
k.Published = t()
|
||||
case "Active":
|
||||
k.Active = t()
|
||||
case "Retired":
|
||||
k.Retired = t()
|
||||
case "Removed":
|
||||
k.Removed = t()
|
||||
case "PublishCDS":
|
||||
k.PublishCDS = t()
|
||||
case "DSPublish":
|
||||
k.DSPublished = t()
|
||||
case "DSRemoved":
|
||||
k.DSRemoved = t()
|
||||
case "GoalState":
|
||||
k.Goal = v
|
||||
case "DNSKEYState":
|
||||
k.DNSKEYState = v
|
||||
case "DSState":
|
||||
k.DSState = v
|
||||
case "ZRRSIGState":
|
||||
k.ZoneRRSIG = v
|
||||
case "KRRSIGState":
|
||||
k.KeyRRSIG = v
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case ksk && zsk:
|
||||
k.Role = "CSK"
|
||||
case ksk:
|
||||
k.Role = "KSK"
|
||||
case zsk:
|
||||
k.Role = "ZSK"
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// ZoneFileSuffixes are the files named keeps next to a zone file: the
|
||||
// journal of the zone Jarl wrote (.jnl, and .jbk while it rewrites it) and,
|
||||
// for a signed zone, the signed copy and its journal.
|
||||
var ZoneFileSuffixes = []string{".jnl", ".jbk", ".signed", ".signed.jnl", ".signed.jbk"}
|
||||
|
||||
// DerivedFiles returns the files named made next to zoneFile that exist.
|
||||
func DerivedFiles(zoneFile string) []string {
|
||||
var out []string
|
||||
for _, s := range ZoneFileSuffixes {
|
||||
if _, err := os.Lstat(zoneFile + s); err == nil {
|
||||
out = append(out, zoneFile+s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testdata holds key files written by named 9.20 with dnssec-policy:
|
||||
// example.test just signed (default policy), fast.test turned insecure
|
||||
// after its DS was removed, fast2.test turned insecure before any DS.
|
||||
|
||||
func TestKeys(t *testing.T) {
|
||||
keys, err := Keys("testdata", "example.test.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("%d keys", len(keys))
|
||||
}
|
||||
k := keys[0]
|
||||
if k.Tag != 22642 || k.Role != "CSK" || k.Algorithm != 13 || k.AlgorithmName != "ECDSAP256SHA256" || len(k.Files) != 3 {
|
||||
t.Errorf("key: %+v", k)
|
||||
}
|
||||
if k.DS == nil || k.DS.KeyTag != 22642 || k.DS.DigestType != 2 || len(k.DS.Digest) != 64 || !strings.HasPrefix(k.DS.Record, "example.test.\t3600\tIN\tDS\t22642 13 2 ") {
|
||||
t.Errorf("DS: %+v", k.DS)
|
||||
}
|
||||
if k.DNSKEYState != Rumoured || k.Goal != Omnipresent || k.PublishCDS == nil || k.PublishCDS.Format("2006-01-02 15:04") != "2026-10-09 19:08" {
|
||||
t.Errorf("state: %+v", k)
|
||||
}
|
||||
// "fast.test." mustn't pick up fast2.test's keys, nor "test." anyone's.
|
||||
if keys, _ := Keys("testdata", "fast.test."); len(keys) != 1 || keys[0].Tag != 642 {
|
||||
t.Errorf("fast.test: %+v", keys)
|
||||
}
|
||||
if files, _ := Files("testdata", "test."); len(files) != 0 {
|
||||
t.Errorf("test.: %v", files)
|
||||
}
|
||||
if files, _ := Files("no-such-dir", "test."); files != nil {
|
||||
t.Errorf("missing dir: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssess(t *testing.T) {
|
||||
load := func(zone string) []*Key {
|
||||
keys, err := Keys("testdata", zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
check := func(name string, p Phase, code string) {
|
||||
t.Helper()
|
||||
if p.Code != code {
|
||||
t.Errorf("%s: %s (%s), want %s", name, p.Code, p.Summary, code)
|
||||
}
|
||||
}
|
||||
check("unsigned", Assess("", nil), PhaseOff)
|
||||
check("left over", Assess("", load("example.test.")), PhaseLeftover)
|
||||
check("no keys yet", Assess("default", nil), PhaseStarting)
|
||||
p := Assess("default", load("example.test."))
|
||||
check("new", p, PhaseSigning)
|
||||
if p.CanPublishDS || p.DSReadyAt == nil {
|
||||
t.Errorf("new: %+v", p)
|
||||
}
|
||||
check("DS removed", Assess("insecure", load("fast.test.")), PhaseDSExpiring)
|
||||
if Assess("insecure", load("fast.test.")).CanFinish {
|
||||
t.Error("DNSSEC mustn't be switched off while resolvers may still have the DS cached")
|
||||
}
|
||||
p = Assess("insecure", load("fast2.test."))
|
||||
check("no DS ever", p, PhaseRemovingKeys)
|
||||
if !p.CanFinish {
|
||||
t.Error("no DS: switching off is safe")
|
||||
}
|
||||
check("insecure, no keys", Assess("insecure", nil), PhaseUnsigned)
|
||||
|
||||
// The rest of the life cycle, from edited state files.
|
||||
edit := func(zone string, repl ...string) []*Key {
|
||||
dir := t.TempDir()
|
||||
files, _ := Files("testdata", zone)
|
||||
for _, f := range files {
|
||||
b, _ := os.ReadFile(filepath.Join("testdata", f))
|
||||
s := strings.NewReplacer(repl...).Replace(string(b))
|
||||
os.WriteFile(filepath.Join(dir, f), []byte(s), 0o644)
|
||||
}
|
||||
keys, err := Keys(dir, zone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
ready := edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: rumoured")
|
||||
p = Assess("default", ready)
|
||||
check("ready", p, PhaseReady)
|
||||
if !p.CanPublishDS || len(p.NeedsDS) != 1 || p.NeedsDS[0] != 22642 {
|
||||
t.Errorf("ready: %+v", p)
|
||||
}
|
||||
check("DS added", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSChange:", "DSPublish: 20261010000000\nDSChange:")), PhaseDSPending)
|
||||
check("secure", Assess("default", edit("example.test.", "DNSKEYState: rumoured", "DNSKEYState: omnipresent", "KRRSIGState: rumoured", "KRRSIGState: omnipresent", "DSState: hidden", "DSState: omnipresent")), PhaseSecure)
|
||||
p = Assess("insecure", edit("fast.test.", "DSRemoved:", "XDSRemoved:"))
|
||||
check("turning off with the DS still there", p, PhaseRemovingDS)
|
||||
if p.CanFinish {
|
||||
t.Error("the DS is still at the parent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zf := filepath.Join(dir, "example.com.zone")
|
||||
for _, f := range []string{zf, zf + ".jnl", zf + ".signed", zf + ".signed.jnl", zf + ".other"} {
|
||||
os.WriteFile(f, nil, 0o644)
|
||||
}
|
||||
got := DerivedFiles(zf)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package dnssec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phases of a zone, from unsigned to signed and back. Turning signing on:
|
||||
// off -> starting -> signing -> ready (add the DS at the parent) ->
|
||||
// ds_pending -> secure. Turning it off: secure -> removing_ds (remove the
|
||||
// DS at the parent) -> ds_expiring -> removing_keys -> unsigned -> off.
|
||||
const (
|
||||
PhaseOff = "off" // not signed, no keys
|
||||
PhaseLeftover = "leftover_keys" // not signed, but old keys are still in the key directory
|
||||
PhaseStarting = "starting" // signing is on in Jarl; named hasn't made keys yet
|
||||
PhaseSigning = "signing" // keys made, zone signed; not yet safe to add the DS
|
||||
PhaseReady = "ready" // safe to add the DS at the parent
|
||||
PhaseDSPending = "ds_pending" // the DS is at the parent; named waits for its TTL
|
||||
PhaseSecure = "secure" // signed, and the parent's DS is in place
|
||||
PhaseRemovingDS = "removing_ds" // turning off: the DS must go from the parent first
|
||||
PhaseDSExpiring = "ds_expiring" // the DS is gone; named waits until caches forget it
|
||||
PhaseRemovingKeys = "removing_keys" // named removes the keys and signatures
|
||||
PhaseUnsigned = "unsigned" // turning off has finished; switch DNSSEC off
|
||||
)
|
||||
|
||||
// Phase says where a zone is and what comes next.
|
||||
type Phase struct {
|
||||
Code string `json:"code"`
|
||||
Summary string `json:"summary"`
|
||||
Next string `json:"next,omitempty"`
|
||||
// DSReadyAt is when the DS can safely be added at the parent (phase
|
||||
// signing).
|
||||
DSReadyAt *time.Time `json:"ds_ready_at,omitempty"`
|
||||
// CanPublishDS: the zone is ready for (or already has) a DS at the
|
||||
// parent.
|
||||
CanPublishDS bool `json:"can_publish_ds"`
|
||||
// CanFinish: the zone is being turned off and the parent has no DS
|
||||
// any more (as far as named knows), so DNSSEC can be switched off.
|
||||
CanFinish bool `json:"can_finish"`
|
||||
// NeedsDS lists the signing keys whose DS should be at the parent,
|
||||
// and StaleDS those whose DS should be removed from it.
|
||||
NeedsDS []uint16 `json:"needs_ds,omitempty"`
|
||||
StaleDS []uint16 `json:"stale_ds,omitempty"`
|
||||
}
|
||||
|
||||
// Assess works out the phase of a zone signed with policy ("" when Jarl
|
||||
// doesn't sign it, "insecure" while signing is being removed) that has
|
||||
// keys.
|
||||
func Assess(policy string, keys []*Key) Phase {
|
||||
var live []*Key // signing keys named keeps (or is introducing)
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Omnipresent {
|
||||
live = append(live, k)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case policy == "":
|
||||
if len(keys) == 0 {
|
||||
return Phase{Code: PhaseOff, Summary: "DNSSEC is off."}
|
||||
}
|
||||
return Phase{Code: PhaseLeftover, Summary: "DNSSEC is off, but keys from when the zone was signed are still in its key directory.",
|
||||
Next: "Wipe them (jarl zone dnssec wipe) once you're sure the parent zone has no DS record for them, or turn signing on again to reuse them."}
|
||||
case policy == "insecure":
|
||||
return assessOff(keys)
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return Phase{Code: PhaseStarting, Summary: "Signing is on; named hasn't created the zone's keys yet.",
|
||||
Next: "Wait a moment: named makes the keys when it loads the configuration."}
|
||||
}
|
||||
p := Phase{}
|
||||
var notReady []*Key
|
||||
for _, k := range live {
|
||||
if k.DNSKEYState != Omnipresent || k.KeyRRSIG != Omnipresent {
|
||||
notReady = append(notReady, k)
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.SigningKey() && k.Goal == Hidden && k.DSState != Hidden && k.DSRemoved == nil {
|
||||
p.StaleDS = append(p.StaleDS, k.Tag)
|
||||
}
|
||||
}
|
||||
if len(notReady) > 0 {
|
||||
p.Code = PhaseSigning
|
||||
p.Summary = "named is signing the zone. Resolvers still treat it as unsigned, because the parent zone has no DS record for it."
|
||||
for _, k := range notReady {
|
||||
if k.PublishCDS != nil && (p.DSReadyAt == nil || k.PublishCDS.After(*p.DSReadyAt)) {
|
||||
t := *k.PublishCDS
|
||||
p.DSReadyAt = &t
|
||||
}
|
||||
}
|
||||
if p.DSReadyAt != nil {
|
||||
p.Next = fmt.Sprintf("Wait until %s before adding the DS at the parent: until then some resolvers may still have unsigned answers cached, and would reject them once the DS appears.", p.DSReadyAt.UTC().Format("Mon 2 Jan 15:04 MST"))
|
||||
} else {
|
||||
p.Next = "Wait until named has published the keys long enough for caches to pick them up before adding the DS."
|
||||
}
|
||||
return p
|
||||
}
|
||||
p.CanPublishDS = true
|
||||
var pending, secure int
|
||||
for _, k := range live {
|
||||
switch {
|
||||
case k.DSState == Omnipresent:
|
||||
secure++
|
||||
case k.DSPublished != nil:
|
||||
pending++
|
||||
default:
|
||||
p.NeedsDS = append(p.NeedsDS, k.Tag)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(p.NeedsDS) > 0:
|
||||
p.Code = PhaseReady
|
||||
p.Summary = "The zone is signed and ready: add the DS record at the parent zone (your registrar) to turn validation on."
|
||||
p.Next = "Add the DS record (" + tags(p.NeedsDS) + ") at your registrar, then run the DS check so named knows it's there."
|
||||
case pending > 0:
|
||||
p.Code = PhaseDSPending
|
||||
p.Summary = "The parent zone has the DS record. named waits for the parent's TTL before it treats it as in place."
|
||||
default:
|
||||
p.Code = PhaseSecure
|
||||
p.Summary = "The zone is signed and the parent zone's DS record points at its key: validating resolvers check its answers."
|
||||
}
|
||||
if len(p.StaleDS) > 0 && p.Next == "" {
|
||||
p.Next = "Remove the DS record of the retired key " + tags(p.StaleDS) + " at your registrar, then run the DS check."
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assessOff(keys []*Key) Phase {
|
||||
var dsUp, dsGoing, keysLeft bool
|
||||
for _, k := range keys {
|
||||
if !k.SigningKey() {
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case k.DSState == Hidden || k.DSState == "":
|
||||
case k.DSRemoved != nil:
|
||||
dsGoing = true
|
||||
default:
|
||||
dsUp = true
|
||||
}
|
||||
if !k.Gone() {
|
||||
keysLeft = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case dsUp:
|
||||
return Phase{Code: PhaseRemovingDS, Summary: "Turning DNSSEC off: named keeps the zone signed until the parent zone's DS record is gone.",
|
||||
Next: "Remove the DS record at your registrar, then run the DS check so named knows. Don't switch DNSSEC off before that: the zone would fail to validate."}
|
||||
case dsGoing:
|
||||
return Phase{Code: PhaseDSExpiring, Summary: "Turning DNSSEC off: the parent's DS record is gone; named keeps signing until resolvers' cached copies of it expire (the parent's DS TTL, often a day).",
|
||||
Next: "Wait: switching DNSSEC off now would make the zone fail to validate for resolvers that still have the DS cached."}
|
||||
case keysLeft:
|
||||
return Phase{Code: PhaseRemovingKeys, Summary: "Turning DNSSEC off: the parent has no DS any more, and named is removing the keys and signatures.",
|
||||
Next: "Wait until named has finished, then switch DNSSEC off.", CanFinish: true}
|
||||
}
|
||||
return Phase{Code: PhaseUnsigned, Summary: "Turning DNSSEC off has finished: the zone is no longer signed.",
|
||||
Next: "Switch DNSSEC off for the zone to finish (jarl zone dnssec disable), and wipe the old keys if you like.", CanFinish: true}
|
||||
}
|
||||
|
||||
func tags(ts []uint16) string {
|
||||
var s []string
|
||||
for _, t := range ts {
|
||||
s = append(s, fmt.Sprintf("key %d", t))
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
; This is a key-signing key, keyid 22642, for example.test.
|
||||
; Created: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Publish: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; Activate: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
; SyncPublish: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
example.test. 3600 IN DNSKEY 257 3 13 fiSDgJJGHyAp36ZNGOeb45gn+O1pF3hCbTUPHeJ3ztIuisGoywmo7Be3 +vUPyC6e9BmTCIRIeElB4f2o9HoPXg==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,19 @@
|
||||
; This is the state of key 22642, for example.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Published: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
Active: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
PublishCDS: 20261009190831 (Fri Oct 9 19:08:31 2026)
|
||||
DNSKEYChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
ZRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
KRRSIGChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DSChange: 20261008190331 (Thu Oct 8 19:03:31 2026)
|
||||
DNSKEYState: rumoured
|
||||
ZRRSIGState: rumoured
|
||||
KRRSIGState: rumoured
|
||||
DSState: hidden
|
||||
GoalState: omnipresent
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 642, for fast.test.
|
||||
; Created: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Publish: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Activate: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
; Inactive: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
; Delete: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
; SyncPublish: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
fast.test. 3600 IN DNSKEY 257 3 13 D6O3u6ZWXwKPu/Y4acS2eRm1zKQ8LaN8zhvkr6KNWP+fEPgko6yLib94 J5KcUXpFcG6Y/jdxKM8FgeASRDq/ow==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,23 @@
|
||||
; This is the state of key 642, for fast.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Published: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Active: 20261008203220 (Thu Oct 8 20:32:20 2026)
|
||||
Retired: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
Removed: 20261024213819 (Sat Oct 24 21:38:19 2026)
|
||||
DSPublish: 20261008203249 (Thu Oct 8 20:32:49 2026)
|
||||
DSRemoved: 20261008203329 (Thu Oct 8 20:33:29 2026)
|
||||
PublishCDS: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DNSKEYChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
ZRRSIGChange: 20261008203232 (Thu Oct 8 20:32:32 2026)
|
||||
KRRSIGChange: 20261008203234 (Thu Oct 8 20:32:34 2026)
|
||||
DSChange: 20261008203319 (Thu Oct 8 20:33:19 2026)
|
||||
DNSKEYState: omnipresent
|
||||
ZRRSIGState: omnipresent
|
||||
KRRSIGState: omnipresent
|
||||
DSState: unretentive
|
||||
GoalState: hidden
|
||||
@@ -0,0 +1,8 @@
|
||||
; This is a key-signing key, keyid 28643, for fast2.test.
|
||||
; Created: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Publish: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Activate: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
; Inactive: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
; Delete: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
; SyncPublish: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
fast2.test. 3600 IN DNSKEY 257 3 13 6WkqPp3kxXxMJtC9clsF4zTnZGPXaFRikSdMLG1eFExa4d0Uo+PtMbqo KHR/Qe601tO7yZoY8itie0aZzPaMiA==
|
||||
@@ -0,0 +1 @@
|
||||
Private-key-format: v1.3 (test data, not a real key)
|
||||
@@ -0,0 +1,21 @@
|
||||
; This is the state of key 28643, for fast2.test.
|
||||
Algorithm: 13
|
||||
Length: 256
|
||||
Lifetime: 0
|
||||
KSK: yes
|
||||
ZSK: yes
|
||||
Generated: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Published: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Active: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
Retired: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
Removed: 20261024213844 (Sat Oct 24 21:38:44 2026)
|
||||
PublishCDS: 20261008203354 (Thu Oct 8 20:33:54 2026)
|
||||
DNSKEYChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
ZRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
KRRSIGChange: 20261008203344 (Thu Oct 8 20:33:44 2026)
|
||||
DSChange: 20261008203340 (Thu Oct 8 20:33:40 2026)
|
||||
DNSKEYState: unretentive
|
||||
ZRRSIGState: unretentive
|
||||
KRRSIGState: unretentive
|
||||
DSState: hidden
|
||||
GoalState: hidden
|
||||
@@ -60,7 +60,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, Comment: &n.Comment}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package engine
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md). named signs zones itself (dnssec-policy
|
||||
// with inline signing) and keeps their keys in a key directory Jarl
|
||||
// chooses (render.KeyDir). Jarl's part is to keep the steps in order: a
|
||||
// zone must never become unsigned while the parent zone may still have a
|
||||
// DS record for it, or validating resolvers reject the whole domain.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// DNSSEC returns the signing status of zone z.
|
||||
func (e *Engine) DNSSEC(ctx context.Context, st *model.State, z *model.Zone) *dnssec.Status {
|
||||
s := &dnssec.Status{Policy: z.DNSSECPolicy, KeyDir: render.KeyDir(st, z, e.cfg.Paths), Keys: []*dnssec.Key{}, Policies: []string{model.DNSSECDefault}}
|
||||
for name := range validate.Policies(st) {
|
||||
s.Policies = append(s.Policies, name)
|
||||
}
|
||||
sort.Strings(s.Policies[1:])
|
||||
keys, err := dnssec.Keys(s.KeyDir, z.Name)
|
||||
if err != nil {
|
||||
s.KeyError = err.Error()
|
||||
} else if keys != nil {
|
||||
s.Keys = keys
|
||||
}
|
||||
s.Phase = dnssec.Assess(z.DNSSECPolicy, s.Keys)
|
||||
if z.Type == model.ZonePrimary && !e.cfg.NoReload && e.tools != nil {
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(st, z))
|
||||
if err != nil {
|
||||
s.NamedError = err.Error()
|
||||
} else {
|
||||
s.Named = &dnssec.NamedSigning{Secure: zs.Secure, InlineSigning: zs.InlineSigning, Serial: zs.Serial, SignedSerial: zs.SignedSerial, NextKeyEvent: zs.NextKeyEvent}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// dnssecChanges checks the DNSSEC side of going from base to work: the
|
||||
// steps that would make a signed zone fail to validate, or give it new
|
||||
// keys its parent's DS doesn't match.
|
||||
func (e *Engine) dnssecChanges(base, work *model.State, opts ApplyOptions) []validate.Problem {
|
||||
var out []validate.Problem
|
||||
problem := func(sev validate.Severity, st *model.State, z *model.Zone, code, format string, args ...any) {
|
||||
out = append(out, validate.Problem{Severity: sev, Code: code, Message: fmt.Sprintf(format, args...), ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
for _, old := range base.SortedZones() {
|
||||
if !old.Signed() {
|
||||
continue
|
||||
}
|
||||
z := work.Zones[old.ID]
|
||||
if z == nil {
|
||||
problem(validate.Error, base, old, "dnssec_delete_signed", "the zone is signed: turn DNSSEC off before deleting it (jarl zone dnssec disable, and remove the DS record at your registrar), or the domain fails to validate while the parent still points at its keys")
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue // reported by validate (dnssec_zone_type)
|
||||
}
|
||||
if render.KeyDir(base, old, e.cfg.Paths) != render.KeyDir(work, z, e.cfg.Paths) {
|
||||
problem(validate.Error, work, z, "dnssec_keys_moved", "the zone is signed, and moving it to another view would make named sign it with new keys that the parent's DS record doesn't match; turn DNSSEC off first, or create the zone anew in the other view")
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case z.DNSSECPolicy == "" && opts.wipeZone == z.ID:
|
||||
// WipeDNSSEC: the caller checked that the parent has no DS.
|
||||
case z.DNSSECPolicy == "" && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Error, work, z, "dnssec_off_unsafe", "switching DNSSEC off at once makes named serve the zone unsigned immediately, and if the parent zone has a DS record for it, validating resolvers then reject the whole domain. Turn it off in steps: 'jarl zone dnssec disable' (the insecure policy) keeps the zone signed while you remove the DS at your registrar, and Jarl says when it's safe to finish. If the zone never had a DS at its parent, wipe it instead ('jarl zone dnssec wipe')")
|
||||
case z.DNSSECPolicy == "":
|
||||
keys, err := dnssec.Keys(render.KeyDir(base, old, e.cfg.Paths), old.Name)
|
||||
if err != nil {
|
||||
problem(validate.Error, work, z, "dnssec_off_unknown", "can't tell whether turning DNSSEC off has finished, because the zone's keys can't be read: %v", err)
|
||||
continue
|
||||
}
|
||||
if ph := dnssec.Assess(model.DNSSECInsecure, keys); !ph.CanFinish {
|
||||
problem(validate.Error, work, z, "dnssec_off_too_early", "turning DNSSEC off hasn't finished yet: %s %s", ph.Summary, ph.Next)
|
||||
}
|
||||
case z.DNSSECPolicy != old.DNSSECPolicy && z.DNSSECPolicy != model.DNSSECInsecure && old.DNSSECPolicy != model.DNSSECInsecure:
|
||||
problem(validate.Warning, work, z, "dnssec_policy_change", "changing the zone's DNSSEC policy from %s to %s can make named replace its keys; when its key-signing key changes, the DS record at the parent must change too (Jarl shows when)", old.DNSSECPolicy, z.DNSSECPolicy)
|
||||
}
|
||||
}
|
||||
for _, z := range work.SortedZones() {
|
||||
if !z.Signed() || z.DNSSECPolicy == model.DNSSECInsecure {
|
||||
continue
|
||||
}
|
||||
if old := base.Zones[z.ID]; old != nil && old.Signed() {
|
||||
continue
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(work, z, e.cfg.Paths), z.Name); len(files) > 0 {
|
||||
problem(validate.Info, work, z, "dnssec_old_keys", "the zone's key directory still has keys from when it was signed before; named only reuses keys it hasn't retired, and makes new ones otherwise (wipe the old ones first for a clean start)")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// removeDerived deletes what named kept for zones that are gone after a
|
||||
// change: the journals and signed copies next to their files and, for a
|
||||
// deleted zone, keys left over from when it was signed (a signed zone
|
||||
// can't be deleted). Failures are only logged.
|
||||
func (e *Engine) removeDerived(pl *plan) {
|
||||
for _, path := range pl.removed {
|
||||
for _, f := range dnssec.DerivedFiles(path) {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a file named kept for a deleted zone failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, old := range pl.base.Zones {
|
||||
if old.Type != model.ZonePrimary || pl.work.Zones[id] != nil || pl.work.FindZone(old.Name, old.ViewID) != nil {
|
||||
continue
|
||||
}
|
||||
dir := render.KeyDir(pl.base, old, e.cfg.Paths)
|
||||
files, _ := dnssec.Files(dir, old.Name)
|
||||
for _, f := range files {
|
||||
if err := os.Remove(filepath.Join(dir, f)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
e.log.Warn("removing a deleted zone's old DNSSEC key failed", "file", f, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WipeResult is the outcome of WipeDNSSEC.
|
||||
type WipeResult struct {
|
||||
Result *Result `json:"result,omitempty"` // the change that switched signing off, if one was needed
|
||||
Removed []string `json:"removed"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// WipeDNSSEC switches signing off for a zone at once, skipping the
|
||||
// insecure policy, and deletes its keys and named's signed copy of it, so
|
||||
// that signing it again starts from scratch. The caller must make sure the
|
||||
// parent zone has no DS record for the zone: otherwise the domain stops
|
||||
// validating.
|
||||
func (e *Engine) WipeDNSSEC(ctx context.Context, zoneID int64, p *auth.Principal, via string, opts ApplyOptions) (*WipeResult, error) {
|
||||
st, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z := st.Zones[zoneID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d not found", zoneID)
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones are signed by Jarl")
|
||||
}
|
||||
if !p.Can(zoneAction(st, z, true)) {
|
||||
return nil, errf(KindForbidden, "permission denied: this needs write access to zone %s", zoneDesc(st, z))
|
||||
}
|
||||
out := &WipeResult{Removed: []string{}, DryRun: opts.DryRun}
|
||||
dir := render.KeyDir(st, z, e.cfg.Paths)
|
||||
keyFiles, err := dnssec.Files(dir, z.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the key directory: %w", err)
|
||||
}
|
||||
zoneFile := render.ZoneFilePath(st, z, e.cfg.Paths)
|
||||
var signed []string
|
||||
for _, f := range dnssec.DerivedFiles(zoneFile) {
|
||||
if strings.Contains(filepath.Base(f), ".signed") {
|
||||
signed = append(signed, f)
|
||||
}
|
||||
}
|
||||
if z.DNSSECPolicy == "" && len(keyFiles) == 0 && len(signed) == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't signed and has no keys or signed files left: there is nothing to wipe", zoneDesc(st, z))
|
||||
}
|
||||
if z.DNSSECPolicy != "" {
|
||||
opts.wipeZone = z.ID
|
||||
res, err := e.Submit(ctx, []Request{NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})}, p, via, "wipe DNSSEC from zone "+zoneDesc(st, z), opts)
|
||||
out.Result = res
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, f := range keyFiles {
|
||||
out.Removed = append(out.Removed, filepath.Join(dir, f))
|
||||
}
|
||||
out.Removed = append(out.Removed, signed...)
|
||||
if opts.DryRun {
|
||||
return out, nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var failed []string
|
||||
for _, f := range out.Removed {
|
||||
if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
failed = append(failed, err.Error())
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf("%d files removed", len(out.Removed))
|
||||
if len(failed) > 0 {
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "failure", strings.Join(failed, "; "), nil)
|
||||
return out, fmt.Errorf("signing is off, but removing files failed: %s", strings.Join(failed, "; "))
|
||||
}
|
||||
e.audit(ctx, p, "dnssec.wipe", "zone", z.ID, zoneDesc(st, z), 0, "success", msg, nil)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MarkDS tells named that the parent zone now has (published) or no
|
||||
// longer has (withdrawn) the DS record of each key, as a DS check found.
|
||||
// named moves to the next step of signing or unsigning the zone once it
|
||||
// knows, and (unless it can query the parent itself) waits until told.
|
||||
func (e *Engine) MarkDS(ctx context.Context, st *model.State, z *model.Zone, p *auth.Principal, published, withdrawn []*dnssec.Key) ([]string, error) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, errf(KindConflict, "jarld doesn't control named here (bind.no_reload)")
|
||||
}
|
||||
var out []string
|
||||
run := func(k *dnssec.Key, pub bool) error {
|
||||
msg, err := e.tools.CheckDS(ctx, z.Name, e.namedView(st, z), k.Tag, k.Algorithm, pub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("key %d: %s", k.Tag, strings.TrimSpace(msg)))
|
||||
return nil
|
||||
}
|
||||
for _, k := range published {
|
||||
if err := run(k, true); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
for _, k := range withdrawn {
|
||||
if err := run(k, false); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
e.audit(ctx, p, "dnssec.checkds", "zone", z.ID, zoneDesc(st, z), 0, "success", strings.Join(out, "; "), nil)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// waitPhase polls the zone's DNSSEC status until ok accepts it.
|
||||
func (n *namedEnv) waitPhase(z *model.Zone, what string, ok func(*dnssec.Status) bool) *dnssec.Status {
|
||||
n.t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
st := n.state()
|
||||
s := n.e.DNSSEC(n.ctx, st, st.Zones[z.ID])
|
||||
if ok(s) {
|
||||
return s
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
n.t.Fatalf("waiting for %s: %+v (phase %+v)", what, s, s.Phase)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSSECWithRealNamed(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
z := n.state().ZonesNamed("example.test")[0]
|
||||
zoneFile := filepath.Join(n.dir, "zones/_default/example.test.zone")
|
||||
|
||||
// Switching signing on for an existing zone: named makes a key and
|
||||
// signs the zone (inline signing, keeping Jarl's file as it is).
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
conf, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
if !strings.Contains(string(conf), `dnssec-policy "default";`) || !strings.Contains(string(conf), `key-directory "`+filepath.Join(n.dir, "zones/dnssec-keys/_default")+`";`) {
|
||||
t.Errorf("named.conf:\n%s", conf)
|
||||
}
|
||||
s := n.waitPhase(z, "signing", func(s *dnssec.Status) bool { return len(s.Keys) == 1 && s.Named != nil && s.Named.Secure })
|
||||
k := s.Keys[0]
|
||||
if s.Phase.Code != dnssec.PhaseSigning || k.Role != "CSK" || k.Algorithm != 13 || k.DS == nil || s.Phase.DSReadyAt == nil {
|
||||
t.Errorf("status: %+v, key %+v", s.Phase, k)
|
||||
}
|
||||
if got := n.dig("example.test", "DNSKEY"); !strings.HasPrefix(got, "257 3 13 ") {
|
||||
t.Errorf("DNSKEY = %q", got)
|
||||
}
|
||||
// Changes still load (Jarl's serial is the unsigned zone's), and the
|
||||
// serial check sees the signed zone's serial.
|
||||
r := findRec(n.state(), "www.example.test.", "A")
|
||||
n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.12" {
|
||||
t.Errorf("www = %q", got)
|
||||
}
|
||||
cur := n.state()
|
||||
served, ok, err := n.e.LoadedSerial(n.ctx, cur, cur.Zones[z.ID])
|
||||
if !ok || err != nil || !serialGreater(served, cur.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("served serial %d (%v %v), Jarl's %d", served, ok, err, cur.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
|
||||
// Unsafe steps are refused.
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")})); !strings.Contains(e.Message, "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off directly: %v", e)
|
||||
}
|
||||
if e := n.submitErr(n.admin, NewRequest(OpZoneDelete, z.ID, nil)); !strings.Contains(e.Message, "turn DNSSEC off before deleting") {
|
||||
t.Errorf("deleting: %v", e)
|
||||
}
|
||||
r = findRec(n.state(), "www.example.test.", "A")
|
||||
if e := n.submitErr(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{TTL: model.Some(uint32(172800))})); !strings.Contains(e.Message, "max-zone-ttl") {
|
||||
t.Errorf("long TTL: %v", e)
|
||||
}
|
||||
|
||||
// A restore of an older database: Jarl's serial is now below named's,
|
||||
// which named refuses for a signed zone. Resync raises it.
|
||||
before := n.state()
|
||||
older := before.Clone()
|
||||
older.Zones[z.ID].SOA.Serial -= 5
|
||||
findRec(older, "www.example.test.", "A").Data = "192.0.2.10"
|
||||
if err := n.st.InTx(n.ctx, func(tx *store.Tx) error { return tx.SaveState(before, older) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := n.e.Resync(n.ctx, n.admin, ApplyOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("resync: %v", err)
|
||||
}
|
||||
if len(res.Serials) != 1 || !serialGreater(res.Serials[0].New, before.Zones[z.ID].SOA.Serial) {
|
||||
t.Errorf("serials: %+v (named had %d)", res.Serials, before.Zones[z.ID].SOA.Serial)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after restore = %q", got)
|
||||
}
|
||||
if n.state().Zones[z.ID].SOA.Serial != res.Serials[0].New {
|
||||
t.Error("raised serial not saved")
|
||||
}
|
||||
|
||||
// Turning off: insecure first. The DS was never at the parent, so
|
||||
// named removes the keys at once and finishing is allowed.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("insecure")}))
|
||||
n.waitPhase(z, "removal", func(s *dnssec.Status) bool { return s.Phase.CanFinish })
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("")}))
|
||||
if got := n.dig("example.test", "DNSKEY"); got != "" {
|
||||
t.Errorf("DNSKEY after switching off = %q", got)
|
||||
}
|
||||
s = n.waitPhase(z, "leftover keys", func(s *dnssec.Status) bool { return true })
|
||||
if s.Phase.Code != dnssec.PhaseLeftover {
|
||||
t.Errorf("after switching off: %+v", s.Phase)
|
||||
}
|
||||
|
||||
// Signing again makes a new key (the old one stays retired); a wipe
|
||||
// switches off at once and deletes keys and signed files.
|
||||
n.submit(n.admin, NewRequest(OpZoneUpdate, z.ID, ZoneInput{DNSSECPolicy: model.Ptr("default")}))
|
||||
s = n.waitPhase(z, "signing again", func(s *dnssec.Status) bool { return len(s.Keys) == 2 && s.Named != nil && s.Named.Secure })
|
||||
if s.Phase.Code != dnssec.PhaseSigning || s.Keys[0].Tag == k.Tag && s.Keys[0].Goal == dnssec.Omnipresent {
|
||||
t.Errorf("signing again: %+v %+v", s.Phase, s.Keys[0])
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); err != nil {
|
||||
t.Errorf("signed copy: %v", err)
|
||||
}
|
||||
w, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("wipe: %v", err)
|
||||
}
|
||||
if len(w.Removed) < 4 || w.Result == nil || !w.Result.Applied {
|
||||
t.Errorf("wipe: %+v", w)
|
||||
}
|
||||
if files, _ := dnssec.Files(render.KeyDir(n.state(), z, n.e.cfg.Paths), z.Name); len(files) != 0 {
|
||||
t.Errorf("keys left: %v", files)
|
||||
}
|
||||
if _, err := os.Stat(zoneFile + ".signed"); !os.IsNotExist(err) {
|
||||
t.Errorf("signed copy left: %v", err)
|
||||
}
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www after wipe = %q", got)
|
||||
}
|
||||
if _, err := n.e.WipeDNSSEC(n.ctx, z.ID, n.admin, "test", ApplyOptions{}); err == nil {
|
||||
t.Error("a second wipe found something")
|
||||
}
|
||||
// Deleting the zone also removes named's journal.
|
||||
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if left, _ := filepath.Glob(zoneFile + "*"); len(left) != 0 {
|
||||
t.Errorf("left after deleting the zone: %v", left)
|
||||
}
|
||||
}
|
||||
+40
-11
@@ -122,6 +122,8 @@ type ApplyOptions struct {
|
||||
Force bool
|
||||
// NoReload writes the files without reloading named.
|
||||
NoReload bool
|
||||
// wipeZone lets WipeDNSSEC switch signing off for this zone at once.
|
||||
wipeZone int64
|
||||
}
|
||||
|
||||
func (e *Engine) replay(base *model.State, ops []model.Op, p *auth.Principal) (*model.State, []string, error) {
|
||||
@@ -397,11 +399,11 @@ func (pl *plan) files() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string) (*plan, error) {
|
||||
func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user string, opts ApplyOptions) (*plan, error) {
|
||||
pl := &plan{base: base, work: work, verify: map[int64]uint32{}}
|
||||
before := e.validate(base)
|
||||
after := e.validate(work)
|
||||
pl.introduced = validate.Introduced(before, after)
|
||||
pl.introduced = append(validate.Introduced(before, after), e.dnssecChanges(base, work, opts)...)
|
||||
introducedKeys := map[string]bool{}
|
||||
for _, p := range pl.introduced {
|
||||
introducedKeys[p.Key()] = true
|
||||
@@ -415,6 +417,12 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
|
||||
return pl, nil
|
||||
}
|
||||
pl.serials = bumpSerials(base, work, e.Now(), user)
|
||||
guarded, problems := e.guardSerials(ctx, base, work, pl.serials, user)
|
||||
pl.serials = append(pl.serials, guarded...)
|
||||
if validate.HasErrors(problems) {
|
||||
pl.introduced = append(pl.introduced, problems...)
|
||||
return pl, nil
|
||||
}
|
||||
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
|
||||
deployed, err := e.store.DeployedFiles(ctx)
|
||||
if err != nil {
|
||||
@@ -498,6 +506,11 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
|
||||
defer os.RemoveAll(dir)
|
||||
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||
sout := render.Render(pl.work, sp, e.cfg.Extras)
|
||||
for _, d := range sout.Dirs {
|
||||
if err := os.MkdirAll(d, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, f := range sout.Files {
|
||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
|
||||
return err
|
||||
@@ -635,7 +648,7 @@ func (e *Engine) Preview(ctx context.Context, csID int64, p *auth.Principal) (*R
|
||||
res.Error = ee
|
||||
return res, nil
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -714,7 +727,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
cs.Description += fmt.Sprintf(" (+%d more)", len(cs.Ops)-1)
|
||||
}
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -782,6 +795,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
|
||||
}
|
||||
res.ChangesetID = cs.ID
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
e.log.Info("changeset applied", "changeset", cs.ID, "user", p.Name(), "ops", len(cs.Ops), "files", len(res.Files), "description", cs.Description)
|
||||
return res, nil
|
||||
}
|
||||
@@ -956,15 +970,16 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
return nil, err
|
||||
}
|
||||
work := base.Clone()
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name())
|
||||
pl, err := e.makePlan(ctx, base, work, p.Name(), opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := pl.result()
|
||||
res.DryRun = opts.DryRun
|
||||
if validate.HasErrors(pl.existing) {
|
||||
if validate.HasErrors(pl.existing) || validate.HasErrors(pl.introduced) {
|
||||
// Resync deploys the stored state as is; it must be valid.
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(pl.existing), Problems: validate.Errors(pl.existing)}
|
||||
ps := append(append([]validate.Problem{}, pl.existing...), pl.introduced...)
|
||||
return res, &Error{Kind: KindValidation, Message: "the stored configuration has errors: " + describeErrors(ps), Problems: validate.Errors(ps)}
|
||||
}
|
||||
if pl.check != nil && !pl.check.OK {
|
||||
return res, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration: " + firstCheckError(pl.checkMsgs), Check: pl.checkMsgs}
|
||||
@@ -998,15 +1013,26 @@ func (e *Engine) Resync(ctx context.Context, p *auth.Principal, opts ApplyOption
|
||||
}
|
||||
deployed := deployedList(pl.out, 0, e.Now())
|
||||
err = e.store.InTx(ctx, func(tx *store.Tx) error {
|
||||
// Serials raised above named's (guardSerials) are kept.
|
||||
if len(pl.serials) > 0 {
|
||||
if err := tx.SaveState(base, work); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.SetDeployedFiles(deployed); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), nil))
|
||||
var details json.RawMessage
|
||||
if len(pl.serials) > 0 {
|
||||
details, _ = json.Marshal(map[string]any{"serials": pl.serials})
|
||||
}
|
||||
return tx.AddAudit(e.auditEvent(p, "system.resync", "", 0, "", 0, "success", fmt.Sprintf("%d files written", len(res.Files)), details))
|
||||
})
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Applied = true
|
||||
e.removeDerived(pl)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -1058,7 +1084,7 @@ func (e *Engine) CheckCurrent(ctx context.Context) (*Result, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "")
|
||||
pl, err := e.makePlan(ctx, base, base.Clone(), "", ApplyOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1192,7 +1218,9 @@ func (e *Engine) WorkingState(ctx context.Context, csID int64, p *auth.Principal
|
||||
}
|
||||
|
||||
// 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
|
||||
// zonestatus: for a zone named signs, the signed zone's serial (which
|
||||
// secondaries and the rest of the world see), not that of the zone file
|
||||
// Jarl wrote. 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 {
|
||||
@@ -1202,5 +1230,6 @@ func (e *Engine) LoadedSerial(ctx context.Context, st *model.State, z *model.Zon
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
return zs.Serial, zs.HasSerial, nil
|
||||
serial, ok = zs.ServedSerial()
|
||||
return serial, ok, nil
|
||||
}
|
||||
@@ -87,7 +87,11 @@ type ZoneInput struct {
|
||||
SOA *SOAInput `json:"soa,omitempty"`
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// DNSSECPolicy turns signing on ("default" or a custom policy's
|
||||
// name), starts turning it off ("insecure") or, once that has
|
||||
// finished, off ("").
|
||||
DNSSECPolicy *string `json:"dnssec_policy,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
|
||||
@@ -230,6 +230,16 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
||||
if in.SerialPolicy != nil {
|
||||
z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy))
|
||||
}
|
||||
if in.DNSSECPolicy != nil {
|
||||
p := strings.Trim(strings.TrimSpace(*in.DNSSECPolicy), `"`)
|
||||
switch strings.ToLower(p) {
|
||||
case "none", "off":
|
||||
p = ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
z.DNSSECPolicy = p
|
||||
}
|
||||
if in.Comment != nil {
|
||||
z.Comment = *in.Comment
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// serialGreater reports whether a > b in RFC 1982 serial number
|
||||
@@ -73,3 +76,59 @@ func bumpSerials(base, work *model.State, now time.Time, user string) []SerialCh
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardSerials makes sure every zone whose file is about to change gets
|
||||
// a serial above the one named has loaded. Normally Jarl's serial is
|
||||
// already higher, but not after restoring an older database or when the
|
||||
// files on disk were replaced: named refuses to load a signed zone's file
|
||||
// unless its serial went up (and keeps serving the old data), and
|
||||
// secondaries ignore a zone whose serial went down. Zones are bumped past
|
||||
// named's serial unless the user set the serial explicitly; an explicit
|
||||
// serial that a signed zone can't take is an error. named is only asked
|
||||
// when it controls the zone (not with no_reload).
|
||||
func (e *Engine) guardSerials(ctx context.Context, base, work *model.State, bumped []SerialChange, user string) ([]SerialChange, []validate.Problem) {
|
||||
if e.cfg.NoReload || e.tools == nil {
|
||||
return nil, nil
|
||||
}
|
||||
auto := map[int64]bool{}
|
||||
for _, c := range bumped {
|
||||
auto[c.ZoneID] = true
|
||||
}
|
||||
var out []SerialChange
|
||||
var problems []validate.Problem
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
old := base.Zones[z.ID]
|
||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
||||
continue // new to named: nothing loaded to compare with
|
||||
}
|
||||
path := render.ZoneFilePath(work, z, e.cfg.Paths)
|
||||
if cur, ok, _ := readFile(path); ok && string(cur) == render.ZoneFile(work, z) {
|
||||
continue // named keeps what it has
|
||||
}
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, e.namedView(work, z))
|
||||
if err != nil || !zs.HasSerial || serialGreater(z.SOA.Serial, zs.Serial) {
|
||||
continue
|
||||
}
|
||||
explicit := z.SOA.Serial != old.SOA.Serial && !auto[z.ID]
|
||||
if explicit {
|
||||
if z.Signed() {
|
||||
problems = append(problems, validate.Problem{Severity: validate.Error, Code: "serial_not_higher", ObjectType: "zone", ObjectID: z.ID, Zone: z.Name, View: work.ViewName(z),
|
||||
Message: fmt.Sprintf("named only loads a signed zone's file when its serial goes up, and it has serial %d loaded; set a higher serial (or leave it to Jarl)", zs.Serial)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
policy := z.SerialPolicy
|
||||
if policy == "" {
|
||||
policy = work.Settings.SerialPolicy
|
||||
}
|
||||
next := NextSerial(zs.Serial, policy, e.Now())
|
||||
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: z.SOA.Serial, New: next})
|
||||
e.log.Info("raising a zone's serial above the one named has loaded", "zone", z.Name, "view", work.ViewName(z), "jarl", z.SOA.Serial, "named", zs.Serial, "new", next)
|
||||
z.SOA.Serial = next
|
||||
z.Touch(user, e.Now())
|
||||
}
|
||||
return out, problems
|
||||
}
|
||||
@@ -61,6 +61,9 @@ type importer struct {
|
||||
dir string // the directory option, for relative zone file names
|
||||
seen map[string]bool
|
||||
ctrl map[string]bool // key names used by controls
|
||||
// policies are dnssec-policy statements of the options block (key
|
||||
// "options") and of views ("view:ID"), which Jarl sets per zone.
|
||||
policies map[string]string
|
||||
}
|
||||
|
||||
func (im *importer) warn(format string, args ...any) {
|
||||
@@ -90,7 +93,7 @@ func (im *importer) meta() model.Meta {
|
||||
|
||||
// Import reads the configuration starting at mainPath.
|
||||
func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) {
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}}
|
||||
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}, policies: map[string]string{}}
|
||||
stmts, err := im.readFile(mainPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -116,6 +119,7 @@ func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
im.dnssecZones()
|
||||
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
|
||||
sort.Slice(im.rep.Zones, func(i, j int) bool {
|
||||
a, b := im.rep.Zones[i], im.rep.Zones[j]
|
||||
@@ -246,6 +250,20 @@ func (im *importer) keepInclude(path string) {
|
||||
}
|
||||
|
||||
func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) {
|
||||
switch {
|
||||
case name == "dnssec-policy" && scope == model.ScopeOptions:
|
||||
im.policies["options"] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeView:
|
||||
im.policies[fmt.Sprintf("view:%d", scopeID)] = value
|
||||
return
|
||||
case name == "dnssec-policy" && scope == model.ScopeZone:
|
||||
im.policies[fmt.Sprintf("zone:%d", scopeID)] = value
|
||||
return
|
||||
case name == "key-directory" && scope == model.ScopeZone:
|
||||
// Jarl chooses signed zones' key directories (see dnssecKeys).
|
||||
return
|
||||
}
|
||||
id, err := im.id()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -259,6 +277,45 @@ func (im *importer) addOption(scope string, scopeID int64, name, value, comment
|
||||
im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()}
|
||||
}
|
||||
|
||||
// policyName turns a dnssec-policy value into a zone's DNSSECPolicy.
|
||||
func policyName(v string) string {
|
||||
v = strings.Trim(strings.TrimSpace(v), `"`)
|
||||
switch l := strings.ToLower(v); l {
|
||||
case "none":
|
||||
return ""
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
return l
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// dnssecZones gives primary zones the dnssec-policy they inherit from
|
||||
// their view or the options block (Jarl sets it per zone), and warns
|
||||
// about signed zones' keys: Jarl keeps them in its own key directory, and
|
||||
// unless the existing keys are moved there, named makes new ones that the
|
||||
// parent's DS record doesn't match.
|
||||
func (im *importer) dnssecZones() {
|
||||
for _, z := range im.st.SortedZones() {
|
||||
v, ok := im.policies[fmt.Sprintf("zone:%d", z.ID)]
|
||||
if !ok {
|
||||
v, ok = im.policies[fmt.Sprintf("view:%d", z.ViewID)]
|
||||
}
|
||||
if !ok {
|
||||
v = im.policies["options"]
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
if p := policyName(v); p != "" {
|
||||
im.warn("zone %s: named signs this %s zone (dnssec-policy %s); Jarl only signs primary zones, so the setting was dropped", dnsname.Display(z.Name), z.Type, p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
z.DNSSECPolicy = policyName(v)
|
||||
if z.DNSSECPolicy != "" {
|
||||
im.warn("zone %s is signed (dnssec-policy %s). Before 'jarld adopt', copy its key files (K%s+*) into the key directory Jarl uses for it ('jarl zone dnssec status %s' shows it), owned by named's user; otherwise named makes new keys, and if the parent zone has a DS record for the old ones, the zone stops validating (docs/dnssec.md#adopting-a-signed-zone)", dnsname.Display(z.Name), z.DNSSECPolicy, z.Name, dnsname.Display(z.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restText(s *namedconf.Stmt) string {
|
||||
return namedconf.Compact(s.Items[1:])
|
||||
}
|
||||
|
||||
@@ -146,3 +146,43 @@ func TestParseTTL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSignedZones(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
zone := "$TTL 300\n@ IN SOA ns1 h 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\n"
|
||||
for _, n := range []string{"a", "b", "c"} {
|
||||
os.WriteFile(filepath.Join(dir, n+".db"), []byte(strings.ReplaceAll(zone, "ns1", "ns1."+n+".test.")), 0o644)
|
||||
}
|
||||
conf := `options { directory "` + dir + `"; dnssec-policy default; key-directory "/var/cache/bind/keys"; };
|
||||
view "external" {
|
||||
zone "a.test" { type primary; file "a.db"; key-directory "/elsewhere"; };
|
||||
zone "b.test" { type primary; file "b.db"; dnssec-policy none; };
|
||||
};
|
||||
view "internal" {
|
||||
dnssec-policy "none";
|
||||
zone "c.test" { type primary; file "c.db"; };
|
||||
};`
|
||||
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(conf), 0o644)
|
||||
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy := map[string]string{}
|
||||
for _, z := range st.Zones {
|
||||
policy[z.Name] = z.DNSSECPolicy
|
||||
}
|
||||
if policy["a.test."] != "default" || policy["b.test."] != "" || policy["c.test."] != "" {
|
||||
t.Errorf("policies: %v", policy)
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
if o.Name == "dnssec-policy" || o.Scope == model.ScopeZone && o.Name == "key-directory" {
|
||||
t.Errorf("option kept: %+v", o)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(rep.Warnings, "\n"), "zone a.test is signed (dnssec-policy default). Before 'jarld adopt', copy its key files (Ka.test.+*)") {
|
||||
t.Errorf("warnings: %v", rep.Warnings)
|
||||
}
|
||||
if ps := validate.Errors(validate.State(st, validate.Options{})); len(ps) != 0 {
|
||||
t.Errorf("imported state has errors: %v", ps)
|
||||
}
|
||||
}
|
||||
@@ -118,13 +118,28 @@ type Zone struct {
|
||||
SOA SOA `json:"soa"`
|
||||
DefaultTTL uint32 `json:"default_ttl"`
|
||||
SerialPolicy string `json:"serial_policy,omitempty"`
|
||||
// DNSSECPolicy is the dnssec-policy named signs the zone with (primary
|
||||
// zones only): "" for an unsigned zone, "default" (BIND's built-in
|
||||
// policy), the name of a dnssec-policy statement, or "insecure" while
|
||||
// signing is being removed safely (docs/dnssec.md).
|
||||
DNSSECPolicy string `json:"dnssec_policy,omitempty"`
|
||||
Comment string `json:"comment"`
|
||||
Meta
|
||||
}
|
||||
|
||||
// DNSSEC policies with a special meaning.
|
||||
const (
|
||||
DNSSECDefault = "default" // BIND's built-in policy
|
||||
DNSSECInsecure = "insecure" // BIND's built-in policy for removing signing
|
||||
)
|
||||
|
||||
// HasRecords reports whether Jarl manages the zone's records.
|
||||
func (z *Zone) HasRecords() bool { return z.Type == ZonePrimary }
|
||||
|
||||
// Signed reports whether named signs the zone (or is still removing its
|
||||
// signatures, under the insecure policy).
|
||||
func (z *Zone) Signed() bool { return z.Type == ZonePrimary && z.DNSSECPolicy != "" }
|
||||
|
||||
// Record is a resource record in a primary zone.
|
||||
type Record struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
+256
-46
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// Finding is one conclusion of a delegation check. Level is "ok",
|
||||
@@ -28,8 +30,73 @@ type ParentAnswer struct {
|
||||
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"`
|
||||
Glue map[string][]string `json:"glue,omitempty"`
|
||||
// DS is the zone's DS records at this server ("" in DSError when it
|
||||
// couldn't be asked).
|
||||
DS []dnssec.DS `json:"ds,omitempty"`
|
||||
DSError string `json:"ds_error,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Signing is how Jarl signs a zone, for comparing with the parent's DS
|
||||
// records.
|
||||
type Signing struct {
|
||||
// Policy is the zone's dnssec-policy ("" when unsigned, "insecure"
|
||||
// while signing is being removed).
|
||||
Policy string
|
||||
// DS are the DS records of the zone's key-signing keys that named
|
||||
// still publishes.
|
||||
DS []dnssec.DS
|
||||
}
|
||||
|
||||
// ParentDSReport is what the parent zone's servers say about the zone's
|
||||
// DS records.
|
||||
type ParentDSReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Resolver string `json:"resolver,omitempty"`
|
||||
// Delegated: at least one parent server delegates the zone. Reached
|
||||
// is how many answered.
|
||||
Delegated bool `json:"delegated"`
|
||||
Reached int `json:"reached"`
|
||||
Servers []ParentAnswer `json:"parent_servers"`
|
||||
// DS is every DS record any parent server has.
|
||||
DS []dnssec.DS `json:"ds"`
|
||||
// Consistent: every server that answered has the same DS records.
|
||||
Consistent bool `json:"consistent"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// AllHaveDS reports whether every parent server that answered has a DS
|
||||
// record for key tag (with digest, unless "").
|
||||
func (r *ParentDSReport) AllHaveDS(tag uint16, digest string) bool {
|
||||
n := 0
|
||||
for _, a := range r.Servers {
|
||||
if a.Status != "delegated" || a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
found := false
|
||||
for _, d := range a.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// HasDS reports whether a parent server has a DS record for key tag.
|
||||
func (r *ParentDSReport) HasDS(tag uint16, digest string) bool {
|
||||
for _, d := range r.DS {
|
||||
if d.KeyTag == tag && (digest == "" || strings.EqualFold(d.Digest, digest)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GlueCheck compares the parent's glue for an in-zone name server with
|
||||
@@ -52,6 +119,8 @@ type DelegationReport struct {
|
||||
JarlNS []string `json:"jarl_ns"`
|
||||
Parents []ParentAnswer `json:"parent_servers"`
|
||||
Glue []GlueCheck `json:"glue"`
|
||||
// ParentDS are the zone's DS records at the parent (DNSSEC).
|
||||
ParentDS []dnssec.DS `json:"parent_ds"`
|
||||
// Servers are the delegated name servers' SOA answers, as resolvers
|
||||
// on the internet would reach them.
|
||||
Servers []Result `json:"servers"`
|
||||
@@ -60,12 +129,14 @@ type DelegationReport struct {
|
||||
|
||||
// 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's NS records, glue and DS records, compares those with jarlNS and
|
||||
// jarlAddrs (Jarl's NS records and its addresses for in-zone name
|
||||
// servers) and with sig (how Jarl signs the zone; nil skips the DNSSEC
|
||||
// part), 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, sig *Signing) *DelegationReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
rep := &DelegationReport{Zone: zone, JarlNS: canonAll(jarlNS), ParentNS: []string{}, Parents: []ParentAnswer{}, Glue: []GlueCheck{}, ParentDS: []dnssec.DS{}, Servers: []Result{}, Findings: []Finding{}}
|
||||
sort.Strings(rep.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
for i, a := range args {
|
||||
@@ -78,42 +149,17 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
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)
|
||||
pr := c.ParentDS(ctx, zone)
|
||||
rep.Resolver, rep.Parent, rep.Parents = pr.Resolver, pr.Parent, pr.Servers
|
||||
if pr.Error != "" {
|
||||
level := "error"
|
||||
if zone == "." {
|
||||
level = "info"
|
||||
}
|
||||
rep.Findings = append(rep.Findings, Finding{level, pr.Error})
|
||||
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
|
||||
parent, answers := pr.Parent, pr.Servers
|
||||
|
||||
parentNS := map[string]bool{}
|
||||
glue := map[string]map[string]bool{}
|
||||
@@ -141,10 +187,6 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
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)
|
||||
@@ -154,6 +196,7 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
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.ParentDS = pr.DS
|
||||
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)
|
||||
@@ -207,6 +250,12 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
}
|
||||
}
|
||||
|
||||
if sig != nil {
|
||||
for _, f := range dsFindings(zone, pr, sig) {
|
||||
rep.Findings = append(rep.Findings, f)
|
||||
}
|
||||
}
|
||||
|
||||
// Every delegated server, reached the way resolvers reach it: by its
|
||||
// glue, or by looking its name up.
|
||||
var targets []Target
|
||||
@@ -262,7 +311,151 @@ func (c *Checker) Delegation(ctx context.Context, zone string, jarlNS []string,
|
||||
return rep
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS.
|
||||
// ParentDS finds the parent of zone through the resolver and asks each of
|
||||
// its servers for the zone's delegation and DS records.
|
||||
func (c *Checker) ParentDS(ctx context.Context, zone string) *ParentDSReport {
|
||||
zone = dns.CanonicalName(zone)
|
||||
rep := &ParentDSReport{Zone: zone, Servers: []ParentAnswer{}, DS: []dnssec.DS{}}
|
||||
res, err := c.resolver()
|
||||
if err != nil {
|
||||
rep.Error = "No resolver to find the parent zone with: " + err.Error()
|
||||
return rep
|
||||
}
|
||||
rep.Resolver = res
|
||||
if zone == "." {
|
||||
rep.Error = "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 {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the parent zone of %s through %s: %v", show(zone), res, err)
|
||||
return rep
|
||||
}
|
||||
rep.Parent = parent
|
||||
pns, err := c.nsNames(ctx, res, parent)
|
||||
if err != nil || len(pns) == 0 {
|
||||
rep.Error = fmt.Sprintf("Couldn't find the name servers of the parent zone %s through %s: %v", show(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.Servers = answers
|
||||
all := map[string]dnssec.DS{}
|
||||
sets := map[string]bool{}
|
||||
for _, a := range answers {
|
||||
if a.Status != "delegated" && a.Status != "not_delegated" {
|
||||
continue
|
||||
}
|
||||
rep.Reached++
|
||||
if a.Status == "delegated" {
|
||||
rep.Delegated = true
|
||||
}
|
||||
if a.DSError != "" {
|
||||
continue
|
||||
}
|
||||
var set []string
|
||||
for _, d := range a.DS {
|
||||
k := dsKey(d)
|
||||
all[k] = d
|
||||
set = append(set, k)
|
||||
}
|
||||
sort.Strings(set)
|
||||
sets[strings.Join(set, " ")] = true
|
||||
}
|
||||
if rep.Reached == 0 {
|
||||
names := make([]string, len(pns))
|
||||
for i, n := range pns {
|
||||
names[i] = show(n)
|
||||
}
|
||||
rep.Error = fmt.Sprintf("None of the parent zone's servers (%s) answered; this server may not be able to reach them.", strings.Join(names, ", "))
|
||||
return rep
|
||||
}
|
||||
for _, k := range keys(boolSet(all)) {
|
||||
rep.DS = append(rep.DS, all[k])
|
||||
}
|
||||
rep.Consistent = len(sets) <= 1
|
||||
return rep
|
||||
}
|
||||
|
||||
func dsKey(d dnssec.DS) string {
|
||||
return fmt.Sprintf("%05d %d %d %s", d.KeyTag, d.Algorithm, d.DigestType, strings.ToUpper(d.Digest))
|
||||
}
|
||||
|
||||
func boolSet(m map[string]dnssec.DS) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for k := range m {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dsFindings compares the parent's DS records with how Jarl signs the
|
||||
// zone.
|
||||
func dsFindings(zone string, pr *ParentDSReport, sig *Signing) []Finding {
|
||||
var out []Finding
|
||||
add := func(level, format string, args ...any) {
|
||||
out = append(out, Finding{level, fmt.Sprintf(format, args...)})
|
||||
}
|
||||
z := show(zone)
|
||||
var tags []string
|
||||
for _, d := range pr.DS {
|
||||
tags = append(tags, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
if !pr.Consistent {
|
||||
add("warning", "The parent's servers have different DS records for %s (a change at the registrar may still be on its way to them all).", z)
|
||||
}
|
||||
matches := func(d dnssec.DS) bool {
|
||||
for _, k := range sig.DS {
|
||||
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var good, stale []string
|
||||
for _, d := range pr.DS {
|
||||
if matches(d) {
|
||||
good = append(good, fmt.Sprint(d.KeyTag))
|
||||
} else {
|
||||
stale = append(stale, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(pr.DS) == 0 && sig.Policy == "":
|
||||
// Unsigned and no DS: nothing to say about DNSSEC.
|
||||
case len(pr.DS) == 0 && sig.Policy == "insecure":
|
||||
add("ok", "The parent has no DS record for %s any more, so turning DNSSEC off can go ahead.", z)
|
||||
case len(pr.DS) == 0:
|
||||
add("info", "%s is signed, but the parent has no DS record for it, so resolvers treat it as unsigned. Add the DS record at your registrar when the zone's DNSSEC page says it's ready.", z)
|
||||
case sig.Policy == "":
|
||||
add("error", "The parent has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject all of its answers. Remove the DS record at your registrar now.", z, strings.Join(tags, ", "))
|
||||
case len(good) == 0:
|
||||
add("error", "The parent's DS record for %s (key %s) matches none of the zone's keys: validating resolvers reject all of its answers. Replace it at your registrar with the zone's DS record.", z, strings.Join(tags, ", "))
|
||||
default:
|
||||
if sig.Policy == "insecure" {
|
||||
add("warning", "The parent still has the DS record for %s (key %s). DNSSEC is being turned off: remove it at your registrar, then run the DS check.", z, strings.Join(good, ", "))
|
||||
} else {
|
||||
add("ok", "The parent's DS record matches the zone's key %s: validating resolvers check its answers.", strings.Join(good, ", "))
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
add("warning", "The parent also has a DS record for key %s, which the zone no longer has. Remove it at your registrar.", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// askParent asks one of the parent's servers (by name) for zone's NS and
|
||||
// DS records.
|
||||
func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnswer {
|
||||
pa := ParentAnswer{Name: server}
|
||||
addrs, err := c.lookup(ctx, server)
|
||||
@@ -331,6 +524,23 @@ func (c *Checker) askParent(ctx context.Context, zone, server string) ParentAnsw
|
||||
for n := range pa.Glue {
|
||||
pa.Glue[n] = canonAddrs(pa.Glue[n])
|
||||
}
|
||||
// The parent itself answers DS queries for the zone.
|
||||
m = new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeDS)
|
||||
m.RecursionDesired = false
|
||||
resp, _, err = c.exchange(ctx, m, pa.Address, nil)
|
||||
switch {
|
||||
case err != nil:
|
||||
pa.DSError = err.Error()
|
||||
case resp.Rcode != dns.RcodeSuccess:
|
||||
pa.DSError = "answered " + dns.RcodeToString[resp.Rcode]
|
||||
default:
|
||||
for _, rr := range resp.Answer {
|
||||
if d, ok := rr.(*dns.DS); ok && dns.CanonicalName(d.Hdr.Name) == zone {
|
||||
pa.DS = append(pa.DS, dnssec.DS{KeyTag: d.KeyTag, Algorithm: d.Algorithm, DigestType: d.DigestType, Digest: strings.ToUpper(d.Digest), Record: d.String()})
|
||||
}
|
||||
}
|
||||
}
|
||||
return pa
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
)
|
||||
|
||||
// fake is a DNS server on one loopback address that answers with fn.
|
||||
@@ -187,7 +189,7 @@ func TestDelegationMismatch(t *testing.T) {
|
||||
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)
|
||||
map[string][]string{"ns1.adbraun.lol.": {"127.0.0.99"}, "ns2.adbraun.lol.": {"127.0.0.14"}}, &ref, nil)
|
||||
if rep.Parent != "lol." || !rep.Delegated {
|
||||
t.Fatalf("report: %+v", rep)
|
||||
}
|
||||
@@ -218,7 +220,7 @@ func TestDelegationGood(t *testing.T) {
|
||||
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)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, nil)
|
||||
l := levels(rep)
|
||||
if len(l["error"])+len(l["warning"])+len(l["info"]) != 0 || len(l["ok"]) != 2 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -234,7 +236,7 @@ func TestNotDelegated(t *testing.T) {
|
||||
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)
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, nil, nil, nil)
|
||||
l := levels(rep)
|
||||
if rep.Delegated || len(l["info"]) != 1 || len(l["warning"])+len(l["error"]) != 0 {
|
||||
t.Errorf("findings: %v", l)
|
||||
@@ -243,3 +245,65 @@ func TestNotDelegated(t *testing.T) {
|
||||
t.Errorf("note: %s", l["info"][0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegationDS(t *testing.T) {
|
||||
const dsData = "22642 13 2 0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A0B7A2F5E1C8E2F6A"
|
||||
parent := func(withDS bool) fake {
|
||||
return func(q dns.Question) *dns.Msg {
|
||||
m := new(dns.Msg)
|
||||
switch {
|
||||
case 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")}
|
||||
case q.Qtype == dns.TypeDS && q.Name == "adbraun.lol.":
|
||||
m.Authoritative = true
|
||||
if withDS {
|
||||
m.Answer = []dns.RR{rr("adbraun.lol. 3600 IN DS " + dsData)}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
}
|
||||
ours := dnssec.DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: strings.Fields(dsData)[3]}
|
||||
other := ours
|
||||
other.KeyTag, other.Digest = 1111, strings.Repeat("AB", 32)
|
||||
ref := uint32(7)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
withDS bool
|
||||
sig Signing
|
||||
level string
|
||||
want string
|
||||
}{
|
||||
{"secure", true, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "ok", "matches the zone's key 22642"},
|
||||
{"wrong key", true, Signing{Policy: "default", DS: []dnssec.DS{other}}, "error", "matches none of the zone's keys"},
|
||||
{"unsigned", true, Signing{}, "error", "but the zone isn't signed"},
|
||||
{"turning off", true, Signing{Policy: "insecure", DS: []dnssec.DS{ours}}, "warning", "remove it at your registrar"},
|
||||
{"no DS yet", false, Signing{Policy: "default", DS: []dnssec.DS{ours}}, "info", "the parent has no DS record for it"},
|
||||
{"DS gone", false, Signing{Policy: "insecure"}, "ok", "turning DNSSEC off can go ahead"},
|
||||
} {
|
||||
port, res := internet(t, parent(c.withDS))
|
||||
sig := c.sig
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &sig)
|
||||
found := false
|
||||
for _, f := range rep.Findings {
|
||||
if f.Level == c.level && strings.Contains(f.Message, c.want) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s: no %s finding %q in %+v", c.name, c.level, c.want, rep.Findings)
|
||||
}
|
||||
if c.withDS && (len(rep.ParentDS) != 1 || rep.ParentDS[0].KeyTag != 22642) {
|
||||
t.Errorf("%s: parent DS %+v", c.name, rep.ParentDS)
|
||||
}
|
||||
}
|
||||
// Unsigned and no DS: nothing about DNSSEC.
|
||||
port, res := internet(t, parent(false))
|
||||
rep := checker(port, res).Delegation(context.Background(), "adbraun.lol.", []string{"ns1.adbraun.lol."}, map[string][]string{"ns1.adbraun.lol.": {"127.0.0.12"}}, &ref, &Signing{})
|
||||
for _, f := range rep.Findings {
|
||||
if strings.Contains(f.Message, "DS") {
|
||||
t.Errorf("unsigned zone: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Fake is an in-memory stand-in for the Porkbun API, for tests: it knows
|
||||
// one account's domains and checks the key like Porkbun does.
|
||||
type Fake struct {
|
||||
APIKey, Secret string
|
||||
mu sync.Mutex
|
||||
Domains map[string]*FakeDomain
|
||||
// Calls lists the paths called, in order.
|
||||
Calls []string
|
||||
}
|
||||
|
||||
// FakeDomain is a domain in a Fake.
|
||||
type FakeDomain struct {
|
||||
NS []string
|
||||
Glue map[string][]string // host -> addresses
|
||||
DS map[uint16]DS
|
||||
APIAccess bool
|
||||
}
|
||||
|
||||
// NewFake returns a Fake with the given domains (API access on, Porkbun's
|
||||
// own name servers, no glue or DS).
|
||||
func NewFake(key, secret string, domains ...string) *Fake {
|
||||
f := &Fake{APIKey: key, Secret: secret, Domains: map[string]*FakeDomain{}}
|
||||
for _, d := range domains {
|
||||
f.Domains[d] = &FakeDomain{NS: []string{"curitiba.ns.porkbun.com", "fortaleza.ns.porkbun.com"}, Glue: map[string][]string{}, DS: map[uint16]DS{}, APIAccess: true}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *Fake) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
reply := func(status int, v map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
fail := func(status int, code, msg string) {
|
||||
reply(status, map[string]any{"status": "ERROR", "code": code, "message": msg})
|
||||
}
|
||||
var body map[string]any
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/json/v3")
|
||||
f.Calls = append(f.Calls, path)
|
||||
if body["apikey"] != f.APIKey || body["secretapikey"] != f.Secret {
|
||||
fail(http.StatusForbidden, "INVALID_API_KEYS_001", "Invalid API key.")
|
||||
return
|
||||
}
|
||||
if path == "/ping" {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "yourIp": "192.0.2.1", "credentialsValid": true, "account": "tester", "scopes": []string{"full"}})
|
||||
return
|
||||
}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
return
|
||||
}
|
||||
op, domain := parts[0]+"/"+parts[1], parts[2]
|
||||
d := f.Domains[domain]
|
||||
if d == nil {
|
||||
fail(400, "DOMAIN_NOT_FOUND", "Domain not found in this account.")
|
||||
return
|
||||
}
|
||||
if !d.APIAccess {
|
||||
fail(403, "API_ACCESS_DISABLED", "The domain is not opted in to API access.")
|
||||
return
|
||||
}
|
||||
host := ""
|
||||
if len(parts) > 3 {
|
||||
host = parts[3] + "." + domain
|
||||
}
|
||||
ips := func() []string {
|
||||
var out []string
|
||||
if l, ok := body["ips"].([]any); ok {
|
||||
for _, v := range l {
|
||||
out = append(out, v.(string))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
switch op {
|
||||
case "domain/getNs":
|
||||
reply(200, map[string]any{"status": "SUCCESS", "ns": d.NS})
|
||||
case "domain/updateNs":
|
||||
var ns []string
|
||||
for _, v := range body["ns"].([]any) {
|
||||
n := v.(string)
|
||||
if strings.HasSuffix(n, "."+domain) && d.Glue[n] == nil {
|
||||
fail(400, "INVALID_NAMESERVER", "Host "+n+" doesn't exist at the registry.")
|
||||
return
|
||||
}
|
||||
ns = append(ns, n)
|
||||
}
|
||||
d.NS = ns
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/getGlue":
|
||||
hosts := []any{}
|
||||
for h, a := range d.Glue {
|
||||
v4, v6 := []string{}, []string{}
|
||||
for _, ip := range a {
|
||||
if strings.Contains(ip, ":") {
|
||||
v6 = append(v6, ip)
|
||||
} else {
|
||||
v4 = append(v4, ip)
|
||||
}
|
||||
}
|
||||
hosts = append(hosts, []any{h, map[string]any{"v4": v4, "v6": v6}})
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "hosts": hosts})
|
||||
case "domain/createGlue":
|
||||
if d.Glue[host] != nil {
|
||||
fail(400, "GLUE_EXISTS", "Host already exists.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/updateGlue":
|
||||
if d.Glue[host] == nil {
|
||||
fail(400, "GLUE_NOT_FOUND", "Host not found.")
|
||||
return
|
||||
}
|
||||
d.Glue[host] = ips()
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "domain/deleteGlue":
|
||||
for _, n := range d.NS {
|
||||
if n == host {
|
||||
fail(400, "GLUE_IN_USE", "Host is in use as a name server.")
|
||||
return
|
||||
}
|
||||
}
|
||||
delete(d.Glue, host)
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/getDnssecRecords":
|
||||
if len(d.DS) == 0 {
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": []any{}})
|
||||
return
|
||||
}
|
||||
recs := map[string]any{}
|
||||
for tag, ds := range d.DS {
|
||||
recs[strconv.Itoa(int(tag))] = map[string]any{"keyTag": strconv.Itoa(int(tag)), "alg": strconv.Itoa(int(ds.Algorithm)), "digestType": strconv.Itoa(int(ds.DigestType)), "digest": strings.ToLower(ds.Digest)}
|
||||
}
|
||||
reply(200, map[string]any{"status": "SUCCESS", "records": recs})
|
||||
case "dns/createDnssecRecord":
|
||||
num := func(k string) int { n, _ := strconv.Atoi(body[k].(string)); return n }
|
||||
tag := uint16(num("keyTag"))
|
||||
d.DS[tag] = DS{KeyTag: tag, Algorithm: uint8(num("alg")), DigestType: uint8(num("digestType")), Digest: strings.ToUpper(body["digest"].(string))}
|
||||
reply(200, map[string]any{"status": "SUCCESS"})
|
||||
case "dns/deleteDnssecRecord":
|
||||
tag, _ := strconv.Atoi(parts[3])
|
||||
delete(d.DS, uint16(tag))
|
||||
reply(200, map[string]any{"status": "SUCCESS", "message": "Deleted."})
|
||||
default:
|
||||
fail(404, "NOT_FOUND", "Unknown endpoint.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// Package porkbun is a small client for the parts of Porkbun's API v3
|
||||
// (https://porkbun.com/api/json/v3/documentation) that Jarl uses to manage
|
||||
// a domain's delegation: its name servers, glue records (host objects at
|
||||
// the registry) and DNSSEC DS records.
|
||||
//
|
||||
// Every call is a POST with the API key and secret in the JSON body. API
|
||||
// access must be switched on for each domain in Porkbun's web interface,
|
||||
// and a key can be limited to some domains, permissions and source
|
||||
// addresses there.
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultURL is the API's base URL.
|
||||
const DefaultURL = "https://api.porkbun.com/api/json/v3"
|
||||
|
||||
// Client calls the Porkbun API.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Secret string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// Error is an error the API returned.
|
||||
type Error struct {
|
||||
Status int // HTTP status
|
||||
Code string // e.g. API_ACCESS_DISABLED
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
msg := "Porkbun: " + e.Message
|
||||
if e.Code != "" {
|
||||
msg += " (" + e.Code + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Glue is a glue record (a host object at the registry).
|
||||
type Glue struct {
|
||||
Host string `json:"host"` // full host name, without a trailing dot
|
||||
V4 []string `json:"v4"`
|
||||
V6 []string `json:"v6"`
|
||||
}
|
||||
|
||||
// Addresses returns all of the glue's addresses.
|
||||
func (g Glue) Addresses() []string { return append(append([]string{}, g.V4...), g.V6...) }
|
||||
|
||||
// DS is a DS record at the registry.
|
||||
type DS struct {
|
||||
KeyTag uint16 `json:"key_tag"`
|
||||
Algorithm uint8 `json:"algorithm"`
|
||||
DigestType uint8 `json:"digest_type"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
Warnings []string `json:"warnings"`
|
||||
NS []string `json:"ns"`
|
||||
Hosts json.RawMessage `json:"hosts"`
|
||||
Records json.RawMessage `json:"records"`
|
||||
YourIP string `json:"yourIp"`
|
||||
Valid bool `json:"credentialsValid"`
|
||||
Account string `json:"account"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, path string, body map[string]any) (*response, error) {
|
||||
if body == nil {
|
||||
body = map[string]any{}
|
||||
}
|
||||
body["apikey"], body["secretapikey"] = c.APIKey, c.Secret
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := c.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultURL
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSuffix(base, "/")+path, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "jarl")
|
||||
hc := c.HTTP
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: %w", err)
|
||||
}
|
||||
var r response
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected answer (HTTP %d)", resp.StatusCode)}
|
||||
}
|
||||
if r.Status != "SUCCESS" {
|
||||
msg := r.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("request failed (HTTP %d)", resp.StatusCode)
|
||||
}
|
||||
return &r, &Error{Status: resp.StatusCode, Code: r.Code, Message: msg}
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func domainPath(op, domain string, rest ...string) string {
|
||||
p := op + "/" + url.PathEscape(strings.ToLower(strings.TrimSuffix(domain, ".")))
|
||||
for _, r := range rest {
|
||||
p += "/" + url.PathEscape(r)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Ping checks the credentials and returns the account name and the key's
|
||||
// permission scopes.
|
||||
func (c *Client) Ping(ctx context.Context) (account string, scopes []string, err error) {
|
||||
r, err := c.call(ctx, "/ping", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if !r.Valid {
|
||||
return "", nil, &Error{Code: "INVALID_API_KEYS_001", Message: "the API key wasn't accepted"}
|
||||
}
|
||||
return r.Account, r.Scopes, nil
|
||||
}
|
||||
|
||||
// NS returns the domain's name servers at the registry (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (c *Client) NS(ctx context.Context, domain string) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getNs", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []string{}
|
||||
for _, n := range r.NS {
|
||||
out = append(out, normName(n))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetNS replaces the domain's name servers at the registry.
|
||||
func (c *Client) SetNS(ctx context.Context, domain string, ns []string) error {
|
||||
list := make([]string, len(ns))
|
||||
for i, n := range ns {
|
||||
list[i] = normName(n)
|
||||
}
|
||||
_, err := c.call(ctx, domainPath("/domain/updateNs", domain), map[string]any{"ns": list})
|
||||
return err
|
||||
}
|
||||
|
||||
// Glue returns the domain's glue records.
|
||||
func (c *Client) Glue(ctx context.Context, domain string) ([]Glue, error) {
|
||||
r, err := c.call(ctx, domainPath("/domain/getGlue", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Glue{}
|
||||
if len(r.Hosts) == 0 || string(r.Hosts) == "null" {
|
||||
return out, nil
|
||||
}
|
||||
var tuples [][]json.RawMessage
|
||||
if err := json.Unmarshal(r.Hosts, &tuples); err != nil {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected glue records: %w", err)
|
||||
}
|
||||
for _, t := range tuples {
|
||||
if len(t) != 2 {
|
||||
continue
|
||||
}
|
||||
var g Glue
|
||||
var ips struct{ V4, V6 []string }
|
||||
if json.Unmarshal(t[0], &g.Host) != nil || json.Unmarshal(t[1], &ips) != nil {
|
||||
continue
|
||||
}
|
||||
g.Host, g.V4, g.V6 = normName(g.Host), nonNil(ips.V4), nonNil(ips.V6)
|
||||
out = append(out, g)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetGlue creates the glue record for host (a name inside domain) or, when
|
||||
// exists, replaces its addresses.
|
||||
func (c *Client) SetGlue(ctx context.Context, domain, host string, ips []string, exists bool) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
op := "/domain/createGlue"
|
||||
if exists {
|
||||
op = "/domain/updateGlue"
|
||||
}
|
||||
_, err = c.call(ctx, domainPath(op, domain, sub), map[string]any{"ips": ips})
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteGlue deletes the glue record for host. The registry refuses while
|
||||
// the domain (or another one) uses it as a name server.
|
||||
func (c *Client) DeleteGlue(ctx context.Context, domain, host string) error {
|
||||
sub, err := subdomain(domain, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.call(ctx, domainPath("/domain/deleteGlue", domain, sub), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// DS returns the domain's DS records at the registry.
|
||||
func (c *Client) DS(ctx context.Context, domain string) ([]DS, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/getDnssecRecords", domain), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []DS{}
|
||||
type rec struct {
|
||||
KeyTag json.Number `json:"keyTag"`
|
||||
Alg json.Number `json:"alg"`
|
||||
DigestType json.Number `json:"digestType"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
var recs []rec
|
||||
// An object keyed by key tag; an empty one may come as [].
|
||||
var byTag map[string]rec
|
||||
if err := json.Unmarshal(r.Records, &byTag); err == nil {
|
||||
for _, v := range byTag {
|
||||
recs = append(recs, v)
|
||||
}
|
||||
} else if err := json.Unmarshal(r.Records, &recs); err != nil && len(r.Records) > 0 && string(r.Records) != "null" {
|
||||
return nil, fmt.Errorf("Porkbun: unexpected DNSSEC records: %w", err)
|
||||
}
|
||||
for _, v := range recs {
|
||||
tag, _ := strconv.ParseUint(v.KeyTag.String(), 10, 16)
|
||||
alg, _ := strconv.ParseUint(v.Alg.String(), 10, 8)
|
||||
dt, _ := strconv.ParseUint(v.DigestType.String(), 10, 8)
|
||||
out = append(out, DS{KeyTag: uint16(tag), Algorithm: uint8(alg), DigestType: uint8(dt), Digest: strings.ToUpper(v.Digest)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].KeyTag < out[j].KeyTag })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddDS adds a DS record at the registry. Warnings (a value the registry is
|
||||
// retiring) come back with a nil error.
|
||||
func (c *Client) AddDS(ctx context.Context, domain string, ds DS) ([]string, error) {
|
||||
r, err := c.call(ctx, domainPath("/dns/createDnssecRecord", domain), map[string]any{
|
||||
"keyTag": strconv.Itoa(int(ds.KeyTag)), "alg": strconv.Itoa(int(ds.Algorithm)),
|
||||
"digestType": strconv.Itoa(int(ds.DigestType)), "digest": ds.Digest,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Warnings, nil
|
||||
}
|
||||
|
||||
// DeleteDS deletes the DS record with key tag at the registry. Porkbun
|
||||
// warns that most registries delete every record with the same key data.
|
||||
func (c *Client) DeleteDS(ctx context.Context, domain string, keyTag uint16) error {
|
||||
_, err := c.call(ctx, domainPath("/dns/deleteDnssecRecord", domain, strconv.Itoa(int(keyTag))), nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// subdomain returns host's label(s) below domain ("ns1" for
|
||||
// ns1.example.com in example.com).
|
||||
func subdomain(domain, host string) (string, error) {
|
||||
d, h := normName(domain), normName(host)
|
||||
sub, ok := strings.CutSuffix(h, "."+d)
|
||||
if !ok || sub == "" {
|
||||
return "", fmt.Errorf("%s isn't inside %s, so it can't have a glue record there", h, d)
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(n), ".")) }
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package porkbun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClient(t *testing.T) {
|
||||
f := NewFake("pk1_x", "sk1_y", "adbraun.lol")
|
||||
ts := httptest.NewServer(f)
|
||||
defer ts.Close()
|
||||
c := &Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_x", Secret: "sk1_y"}
|
||||
ctx := context.Background()
|
||||
|
||||
if acct, _, err := c.Ping(ctx); err != nil || acct != "tester" {
|
||||
t.Fatalf("ping: %q %v", acct, err)
|
||||
}
|
||||
ns, err := c.NS(ctx, "adbraun.lol.")
|
||||
if err != nil || strings.Join(ns, " ") != "curitiba.ns.porkbun.com fortaleza.ns.porkbun.com" {
|
||||
t.Errorf("NS: %v %v", ns, err)
|
||||
}
|
||||
// Two glue records, one with IPv4 and IPv6.
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns1.adbraun.lol.", []string{"203.0.113.1", "2001:db8::1"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.2"}, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns2.adbraun.lol", []string{"203.0.113.3"}, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.SetGlue(ctx, "adbraun.lol", "ns.elsewhere.net", nil, false); err == nil {
|
||||
t.Error("glue outside the domain accepted")
|
||||
}
|
||||
glue, err := c.Glue(ctx, "adbraun.lol")
|
||||
if err != nil || len(glue) != 2 || glue[0].Host != "ns1.adbraun.lol" || strings.Join(glue[0].Addresses(), " ") != "203.0.113.1 2001:db8::1" || glue[1].V4[0] != "203.0.113.3" {
|
||||
t.Errorf("glue: %+v %v", glue, err)
|
||||
}
|
||||
if err := c.SetNS(ctx, "adbraun.lol", []string{"ns1.adbraun.lol.", "ns2.adbraun.lol"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.DeleteGlue(ctx, "adbraun.lol", "ns2.adbraun.lol"); err == nil {
|
||||
t.Error("deleted glue in use")
|
||||
}
|
||||
// DS records: empty comes as [], others keyed by tag.
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 0 {
|
||||
t.Errorf("no DS: %v %v", ds, err)
|
||||
}
|
||||
if _, err := c.AddDS(ctx, "adbraun.lol", DS{KeyTag: 22642, Algorithm: 13, DigestType: 2, Digest: "ABCDEF"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ds, err := c.DS(ctx, "adbraun.lol"); err != nil || len(ds) != 1 || ds[0].KeyTag != 22642 || ds[0].Algorithm != 13 || ds[0].Digest != "ABCDEF" {
|
||||
t.Errorf("DS: %+v %v", ds, err)
|
||||
}
|
||||
if err := c.DeleteDS(ctx, "adbraun.lol", 22642); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Errors carry Porkbun's code.
|
||||
f.Domains["adbraun.lol"].APIAccess = false
|
||||
_, err = c.NS(ctx, "adbraun.lol")
|
||||
var pe *Error
|
||||
if !errors.As(err, &pe) || pe.Code != "API_ACCESS_DISABLED" {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
bad := &Client{BaseURL: c.BaseURL, APIKey: "pk1_x", Secret: "wrong"}
|
||||
if _, _, err := bad.Ping(ctx); err == nil || !strings.Contains(err.Error(), "INVALID_API_KEYS") {
|
||||
t.Errorf("bad key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ type Span struct {
|
||||
// Output is the result of rendering.
|
||||
type Output struct {
|
||||
Files []*File
|
||||
// Dirs are directories named needs to exist (for secondary zone files).
|
||||
// Dirs are directories named must be able to write to (secondary zone
|
||||
// files, DNSSEC key directories).
|
||||
Dirs []string
|
||||
// Spans maps file paths to source spans.
|
||||
Spans map[string][]Span
|
||||
@@ -103,6 +104,22 @@ func ViewDir(viewName string) string {
|
||||
return dnsname.FileSafe(dnsname.Fqdn(strings.ToLower(viewName)))
|
||||
}
|
||||
|
||||
// KeysDir is the directory under which named keeps the DNSSEC keys of the
|
||||
// zones Jarl signs, one subdirectory per view.
|
||||
func (p Paths) KeysDir() string { return filepath.Join(p.ZoneDir, "dnssec-keys") }
|
||||
|
||||
// KeyDir returns the key-directory of zone z: one per view, because named
|
||||
// refuses one directory for zones of the same name in two views. It is
|
||||
// named after the view's ID, which unlike its name never changes: a new
|
||||
// directory would make named create new keys, which the parent's DS
|
||||
// record wouldn't match.
|
||||
func KeyDir(s *model.State, z *model.Zone, p Paths) string {
|
||||
if z.ViewID == 0 {
|
||||
return filepath.Join(p.KeysDir(), "_default")
|
||||
}
|
||||
return filepath.Join(p.KeysDir(), fmt.Sprintf("view-%d", z.ViewID))
|
||||
}
|
||||
|
||||
// ZoneFilePath returns the file named loads for zone z, or "" for zone
|
||||
// types without files.
|
||||
func ZoneFilePath(s *model.State, z *model.Zone, p Paths) string {
|
||||
@@ -387,6 +404,14 @@ func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Outp
|
||||
}
|
||||
}
|
||||
}
|
||||
if z.Signed() {
|
||||
// named signs the zone itself (inline signing): it keeps the
|
||||
// signed copy next to Jarl's file (.signed) and its keys in dir.
|
||||
dir := KeyDir(s, z, p)
|
||||
w.write(fmt.Sprintf("%s\tdnssec-policy %s;\n", tabs, namedconf.QuoteString(z.DNSSECPolicy)))
|
||||
w.write(fmt.Sprintf("%s\tkey-directory %s;\n", tabs, namedconf.QuoteString(dir)))
|
||||
out.Dirs = appendUnique(out.Dirs, dir)
|
||||
}
|
||||
for _, o := range s.ScopedOptions(model.ScopeZone, z.ID) {
|
||||
w.begin("option", o.ID, label+" option "+o.Name)
|
||||
w.write(optionText(o, indent+1))
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
)
|
||||
|
||||
func TestDNSSECEndpoints(t *testing.T) {
|
||||
e := newAPIEnv(t)
|
||||
// Nothing answers DNS here, so the parent can't be checked.
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Resolver: "127.0.0.1:1"}
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
|
||||
e.do("GET", "/zones/example.com/dnssec?view=", e.token, nil, 200)
|
||||
|
||||
e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
got := e.do("GET", "/zones/example.com/dnssec", e.token, nil, 200)
|
||||
if got["policy"] != "default" || got["phase"].(map[string]any)["code"] != dnssec.PhaseStarting || !strings.HasSuffix(got["key_dir"].(string), "zones/dnssec-keys/_default") {
|
||||
t.Errorf("status: %v", got)
|
||||
}
|
||||
// Switching off at once is refused; insecure is the way.
|
||||
out := e.do("PATCH", "/zones/example.com", e.token, map[string]any{"dnssec_policy": "off"}, 422)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "switching DNSSEC off at once") {
|
||||
t.Errorf("switching off: %v", out)
|
||||
}
|
||||
cd := e.do("POST", "/zones/example.com/dnssec/check-ds", e.token, nil, 200)
|
||||
if cd["parent"].(map[string]any)["error"] == "" || len(cd["marked"].([]any)) != 0 {
|
||||
t.Errorf("check-ds without a resolver: %v", cd)
|
||||
}
|
||||
// Wipe: refused while the parent can't be checked, unless forced.
|
||||
out = e.do("POST", "/zones/example.com/dnssec/wipe", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "wipe with force") {
|
||||
t.Errorf("wipe: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true, "dry_run": true}, 200)
|
||||
if e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"] != "default" {
|
||||
t.Error("a dry run changed the zone")
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 200)
|
||||
if p, ok := e.do("GET", "/zones/example.com", e.token, nil, 200)["dnssec_policy"]; ok && p != "" {
|
||||
t.Errorf("after wipe: %v", p)
|
||||
}
|
||||
e.do("POST", "/zones/example.com/dnssec/wipe", e.token, map[string]any{"force": true}, 409) // nothing left
|
||||
|
||||
// Secondary zones aren't signed by Jarl.
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "copy.example", "type": "secondary", "options": []map[string]any{{"name": "primaries", "value": "{ 192.0.2.1; }"}}}, 201)
|
||||
e.do("GET", "/zones/copy.example/dnssec", e.token, nil, 400)
|
||||
e.do("PATCH", "/zones/copy.example", e.token, map[string]any{"dnssec_policy": "default"}, 422)
|
||||
}
|
||||
|
||||
func TestDSChanges(t *testing.T) {
|
||||
ds := dnssec.DS{KeyTag: 1, Algorithm: 13, DigestType: 2, Digest: "AA"}
|
||||
key := func(goal, dsState string) *dnssec.Key {
|
||||
d := ds
|
||||
return &dnssec.Key{Tag: 1, Role: "CSK", DS: &d, Goal: goal, DSState: dsState}
|
||||
}
|
||||
with := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated", DS: []dnssec.DS{ds}}}, DS: []dnssec.DS{ds}, Consistent: true}
|
||||
half := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated", DS: []dnssec.DS{ds}}, {Status: "delegated"}}, DS: []dnssec.DS{ds}}
|
||||
without := &nscheck.ParentDSReport{Servers: []nscheck.ParentAnswer{{Status: "delegated"}}, Consistent: true}
|
||||
signed := &model.Zone{DNSSECPolicy: "default"}
|
||||
off := &model.Zone{DNSSECPolicy: "insecure"}
|
||||
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, with); len(p) != 1 || len(w) != 0 {
|
||||
t.Errorf("DS added: %v %v", p, w)
|
||||
}
|
||||
if p, _ := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, half); len(p) != 0 {
|
||||
t.Error("published while not every parent server has it")
|
||||
}
|
||||
if p, w := dsChanges(signed, []*dnssec.Key{key("omnipresent", "rumoured")}, without); len(p)+len(w) != 0 {
|
||||
t.Error("a signed zone without a DS yet is just waiting")
|
||||
}
|
||||
if p, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, without); len(p) != 0 || len(w) != 1 {
|
||||
t.Errorf("DS removed: %v %v", p, w)
|
||||
}
|
||||
if _, w := dsChanges(off, []*dnssec.Key{key("hidden", "unretentive")}, half); len(w) != 0 {
|
||||
t.Error("withdrawn while a parent server still has it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package server
|
||||
|
||||
// DNSSEC signing (docs/dnssec.md): GET /zones/{zone}/dnssec,
|
||||
// POST /zones/{zone}/dnssec/check-ds and POST /zones/{zone}/dnssec/wipe.
|
||||
// Signing itself is switched with the zone's dnssec_policy field
|
||||
// (PATCH /zones/{zone}), which goes through a changeset like any change.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
)
|
||||
|
||||
func (s *Server) zoneDNSSEC(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("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
||||
}
|
||||
resp := api.DNSSECResponse{Zone: z.Name, View: st.ViewName(z), Type: z.Type, Status: s.eng.DNSSEC(r.Context(), st, z), Registrar: s.registrarFor(z)}
|
||||
if pz := parentInJarl(st, z); pz != nil {
|
||||
resp.ParentZone = pz.Name
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parentInJarl returns the primary zone in z's view that z is delegated
|
||||
// from, if Jarl has it.
|
||||
func parentInJarl(st *model.State, z *model.Zone) *model.Zone {
|
||||
var best *model.Zone
|
||||
for _, o := range st.ZonesInView(z.ViewID) {
|
||||
if o.ID == z.ID || o.Type != model.ZonePrimary || o.Name == z.Name || !dnsname.IsSubdomain(z.Name, o.Name) {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(o.Name) > len(best.Name) {
|
||||
best = o
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// dsChecker returns the checker for parent DS lookups, with ?resolver=IP.
|
||||
func (s *Server) dsChecker(r *http.Request) (*nscheck.Checker, error) {
|
||||
c := s.checker()
|
||||
if v := r.URL.Query().Get("resolver"); v != "" {
|
||||
a, err := netip.ParseAddr(v)
|
||||
if err != nil {
|
||||
return nil, 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")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Server) zoneCheckDS(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 err := require(p, auth.Action{Resource: auth.ResZones, Write: true, Zone: z.Name, View: st.ViewName(z)}); err != nil {
|
||||
return err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return badRequest("only primary zones are signed by Jarl (this is a %s zone)", z.Type)
|
||||
}
|
||||
c, err := s.dsChecker(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
resp := api.CheckDSResponse{Parent: c.ParentDS(ctx, z.Name), Marked: []string{}, Notes: []string{}}
|
||||
status := s.eng.DNSSEC(ctx, st, z)
|
||||
if resp.Parent.Error == "" {
|
||||
pub, wd := dsChanges(z, status.Keys, resp.Parent)
|
||||
if len(pub)+len(wd) > 0 {
|
||||
marked, err := s.eng.MarkDS(ctx, st, z, p, pub, wd)
|
||||
resp.Marked = append(resp.Marked, marked...)
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "telling named failed: "+err.Error())
|
||||
}
|
||||
}
|
||||
if !resp.Parent.Consistent {
|
||||
resp.Notes = append(resp.Notes, "The parent's servers don't all have the same DS records yet; named is only told once they agree. Check again in a while.")
|
||||
}
|
||||
if len(resp.Marked) == 0 {
|
||||
resp.Notes = append(resp.Notes, "Nothing new: named already knows what the parent has.")
|
||||
}
|
||||
status = s.eng.DNSSEC(ctx, st, z)
|
||||
} else {
|
||||
resp.Notes = append(resp.Notes, "The parent zone couldn't be checked, so named wasn't told anything.")
|
||||
}
|
||||
resp.Status = status
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dsChanges works out what named should be told after a DS check: keys
|
||||
// whose DS every parent server now has (published), and keys whose DS no
|
||||
// parent server has any more, when they're on their way out (withdrawn).
|
||||
// A signed zone without a DS yet is simply waiting for one: nothing is
|
||||
// withdrawn for it.
|
||||
func dsChanges(z *model.Zone, keys []*dnssec.Key, pr *nscheck.ParentDSReport) (published, withdrawn []*dnssec.Key) {
|
||||
for _, k := range keys {
|
||||
if !k.SigningKey() || k.DS == nil {
|
||||
continue
|
||||
}
|
||||
leaving := z.DNSSECPolicy == model.DNSSECInsecure || k.Goal == dnssec.Hidden
|
||||
switch {
|
||||
case !leaving && k.DSState != dnssec.Omnipresent && k.DSPublished == nil && pr.AllHaveDS(k.Tag, k.DS.Digest):
|
||||
published = append(published, k)
|
||||
case leaving && k.DSState != dnssec.Hidden && k.DSRemoved == nil && !pr.HasDS(k.Tag, k.DS.Digest):
|
||||
withdrawn = append(withdrawn, k)
|
||||
}
|
||||
}
|
||||
return published, withdrawn
|
||||
}
|
||||
|
||||
func (s *Server) zoneDNSSECWipe(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.DNSSECWipe
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if queryBool(r, "dry_run") {
|
||||
in.DryRun = true
|
||||
}
|
||||
c, err := s.dsChecker(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
// The one thing that must not happen: the zone goes unsigned while
|
||||
// resolvers can still get a DS record for it.
|
||||
pr := c.ParentDS(ctx, z.Name)
|
||||
if len(pr.DS) > 0 {
|
||||
var tags []string
|
||||
for _, d := range pr.DS {
|
||||
tags = append(tags, fmt.Sprint(d.KeyTag))
|
||||
}
|
||||
return conflict("the parent zone %s still has a DS record for %s (key %s): wiping now would make validating resolvers reject the whole domain. Remove the DS at your registrar first and wait for its TTL to pass (or turn DNSSEC off step by step: jarl zone dnssec disable)", dnsname.Display(pr.Parent), dnsname.Display(z.Name), strings.Join(tags, ", "))
|
||||
}
|
||||
if pr.Error != "" && !in.Force {
|
||||
return conflict("couldn't check that the parent zone has no DS record for %s: %s. If the zone isn't on the internet (or you're sure its parent has no DS for it), wipe with force", dnsname.Display(z.Name), pr.Error)
|
||||
}
|
||||
if !in.Force {
|
||||
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
||||
if k.SigningKey() && k.DSPublished != nil && k.DSState != dnssec.Hidden {
|
||||
return conflict("the parent's DS record for key %d is gone, but resolvers may still have it cached (named counts until its TTL has passed); wait until 'jarl zone dnssec status' no longer shows it, or wipe with force", k.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := s.eng.WipeDNSSEC(r.Context(), z.ID, p, via(r, p), engine.ApplyOptions{DryRun: in.DryRun})
|
||||
if err != nil {
|
||||
if res != nil && res.Result != nil {
|
||||
s.writeErr(w, r, err, res.Result)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
out := api.DNSSECWipeResponse{Removed: res.Removed, DryRun: res.DryRun}
|
||||
if pr.Error == "" {
|
||||
out.Parent = pr
|
||||
}
|
||||
if res.Result != nil {
|
||||
out.ChangesetID = res.Result.ChangesetID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
"jarl/internal/nscheck"
|
||||
@@ -61,6 +62,11 @@ func (s *Server) zoneSerials(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
if err != nil {
|
||||
resp.Notes = append(resp.Notes, "named on this server didn't report the zone's serial: "+err.Error())
|
||||
}
|
||||
if z.Signed() && resp.SerialSource == "jarl" {
|
||||
// named gives the signed zone its own serial, ahead of Jarl's.
|
||||
resp.Notes = append(resp.Notes, "The zone is signed and named wasn't asked: other servers serve the signed zone's serial, which named sets and which is usually ahead of Jarl's.")
|
||||
resp.Serial = nil
|
||||
}
|
||||
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.")
|
||||
@@ -116,7 +122,7 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
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()}
|
||||
resp := api.DelegationResponse{DelegationReport: c.Delegation(ctx, z.Name, ns, addrs, ref, s.signing(ctx, st, z)), 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))
|
||||
}
|
||||
@@ -124,6 +130,18 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
return nil
|
||||
}
|
||||
|
||||
// signing describes how Jarl signs z, for comparing with the parent's DS
|
||||
// records: the DS of every key-signing key named still publishes.
|
||||
func (s *Server) signing(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Signing {
|
||||
sig := &nscheck.Signing{Policy: z.DNSSECPolicy}
|
||||
for _, k := range s.eng.DNSSEC(ctx, st, z).Keys {
|
||||
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
||||
sig.DS = append(sig.DS, *k.DS)
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
package server
|
||||
|
||||
// The registrar (docs/dnssec.md#porkbun): a domain's name servers, glue
|
||||
// records and DNSSEC DS records at Porkbun, next to what the zone in Jarl
|
||||
// says.
|
||||
//
|
||||
// GET /zones/{zone}/registrar
|
||||
// POST /zones/{zone}/registrar/glue create or update glue records
|
||||
// POST /zones/{zone}/registrar/ds publish the zone's DS record
|
||||
// DELETE /zones/{zone}/registrar/ds/{keytag} remove a DS record
|
||||
// PUT /zones/{zone}/registrar/ns set the domain's name servers
|
||||
//
|
||||
// All of them need an administrator: the API key can move the domain
|
||||
// anywhere. Every change first checks that it won't break the domain
|
||||
// (errors refuse it unless forced), and a dry run returns those checks
|
||||
// without changing anything.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/dnssec"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// registrarFor names the registrar Jarl can manage z's delegation at:
|
||||
// "porkbun" when a key is configured and allows the zone's name.
|
||||
func (s *Server) registrarFor(z *model.Zone) string {
|
||||
if s.cfg.Porkbun == nil || z.Type != model.ZonePrimary {
|
||||
return ""
|
||||
}
|
||||
if s.cfg.PorkbunAllows != nil && !s.cfg.PorkbunAllows(dnsname.Display(z.Name)) {
|
||||
return ""
|
||||
}
|
||||
return "porkbun"
|
||||
}
|
||||
|
||||
func (s *Server) registrarZone(r *http.Request, p *auth.Principal) (*model.State, *model.Zone, string, error) {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if s.registrarFor(z) == "" {
|
||||
if s.cfg.Porkbun == nil {
|
||||
return nil, nil, "", badRequest("no registrar is configured: add a Porkbun API key to jarl.yaml (porkbun:, see docs/dnssec.md#porkbun)")
|
||||
}
|
||||
return nil, nil, "", badRequest("Jarl may not manage %s at Porkbun (only primary zones listed in porkbun.domains in jarl.yaml)", dnsname.Display(z.Name))
|
||||
}
|
||||
return st, z, dnsname.Display(z.Name), nil
|
||||
}
|
||||
|
||||
func lowerNoDot(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||
|
||||
// zoneNS returns the zone's apex NS records, as lowercase names without
|
||||
// trailing dots.
|
||||
func zoneNS(st *model.State, z *model.Zone) []string {
|
||||
var out []string
|
||||
for _, rec := range st.ZoneRecords(z.ID) {
|
||||
if rec.Type == "NS" && rec.Name == z.Name {
|
||||
out = append(out, lowerNoDot(rec.Data))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
x := append([]string{}, a...)
|
||||
y := append([]string{}, b...)
|
||||
sort.Strings(x)
|
||||
sort.Strings(y)
|
||||
return strings.Join(x, " ") == strings.Join(y, " ")
|
||||
}
|
||||
|
||||
// registrarState asks the registrar about the domain and compares that
|
||||
// with the zone.
|
||||
func (s *Server) registrarState(ctx context.Context, st *model.State, z *model.Zone, domain string, sig *dnssec.Status) *api.RegistrarResponse {
|
||||
resp := &api.RegistrarResponse{Registrar: "porkbun", Domain: domain, NS: []string{}, JarlNS: zoneNS(st, z), Glue: []api.GlueStatus{}, DS: []api.RegistrarDS{}, Findings: []nscheck.Finding{}}
|
||||
if resp.JarlNS == nil {
|
||||
resp.JarlNS = []string{}
|
||||
}
|
||||
pb := s.cfg.Porkbun
|
||||
ns, err := pb.NS(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
glue, err := pb.Glue(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
ds, err := pb.DS(ctx, domain)
|
||||
if err != nil {
|
||||
resp.Error = err.Error()
|
||||
return resp
|
||||
}
|
||||
resp.NS = ns
|
||||
resp.NSMatch = sameSet(ns, resp.JarlNS)
|
||||
add := func(level, format string, args ...any) {
|
||||
resp.Findings = append(resp.Findings, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
switch {
|
||||
case resp.NSMatch:
|
||||
add("ok", "Porkbun delegates %s to the zone's name servers: %s.", domain, strings.Join(ns, ", "))
|
||||
case len(resp.JarlNS) == 0:
|
||||
add("warning", "The zone has no NS records.")
|
||||
default:
|
||||
add("warning", "Porkbun delegates %s to %s, but the zone's NS records are %s.", domain, strings.Join(ns, ", "), strings.Join(resp.JarlNS, ", "))
|
||||
}
|
||||
|
||||
// Glue: every host at the registry, and every name server inside the
|
||||
// domain (which resolvers can only find through its glue).
|
||||
byHost := map[string]*api.GlueStatus{}
|
||||
var order []string
|
||||
get := func(host string) *api.GlueStatus {
|
||||
if g := byHost[host]; g != nil {
|
||||
return g
|
||||
}
|
||||
g := &api.GlueStatus{Host: host, Registry: []string{}, Jarl: []string{}}
|
||||
byHost[host] = g
|
||||
order = append(order, host)
|
||||
return g
|
||||
}
|
||||
for _, g := range glue {
|
||||
get(g.Host).Registry = canonAddrs(g.Addresses())
|
||||
}
|
||||
for _, n := range resp.JarlNS {
|
||||
if n == domain || !dnsname.IsSubdomain(dnsname.Fqdn(n), z.Name) {
|
||||
continue
|
||||
}
|
||||
g := get(n)
|
||||
g.Needed = true
|
||||
g.Jarl = canonAddrs(jarlAddresses(st, z.ViewID, dnsname.Fqdn(n)))
|
||||
switch {
|
||||
case len(g.Registry) == 0:
|
||||
g.Action = "create"
|
||||
case !sameSet(g.Registry, g.Jarl) && len(g.Jarl) > 0:
|
||||
g.Action = "update"
|
||||
}
|
||||
}
|
||||
sort.Strings(order)
|
||||
for _, h := range order {
|
||||
g := byHost[h]
|
||||
for _, n := range ns {
|
||||
if n == h {
|
||||
g.InUse = true
|
||||
}
|
||||
}
|
||||
resp.Glue = append(resp.Glue, *g)
|
||||
switch {
|
||||
case g.Needed && len(g.Jarl) == 0:
|
||||
add("error", "%s is one of the zone's name servers, inside the domain, but the zone has no A or AAAA record for it; add one, then sync the glue.", h)
|
||||
case g.Action == "create":
|
||||
add("warning", "%s has no glue record at Porkbun, so resolvers can't find it once the domain is delegated to it. Sync the glue (%s).", h, strings.Join(g.Jarl, ", "))
|
||||
case g.Action == "update":
|
||||
add("warning", "The glue for %s at Porkbun is %s, but the zone says %s. Sync the glue.", h, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
|
||||
case g.Needed:
|
||||
add("ok", "The glue for %s matches the zone (%s).", h, strings.Join(g.Jarl, ", "))
|
||||
case !g.InUse:
|
||||
add("info", "Porkbun has a glue record for %s (%s), which the zone's NS records don't use.", h, strings.Join(g.Registry, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// DS records.
|
||||
live := publishedDS(sig)
|
||||
var good, stale []string
|
||||
for _, d := range ds {
|
||||
rd := api.RegistrarDS{DS: d}
|
||||
for _, k := range live {
|
||||
if k.KeyTag == d.KeyTag && k.Algorithm == d.Algorithm && k.DigestType == d.DigestType && strings.EqualFold(k.Digest, d.Digest) {
|
||||
rd.Matches = true
|
||||
}
|
||||
}
|
||||
resp.DS = append(resp.DS, rd)
|
||||
if rd.Matches {
|
||||
good = append(good, strconv.Itoa(int(d.KeyTag)))
|
||||
} else {
|
||||
stale = append(stale, strconv.Itoa(int(d.KeyTag)))
|
||||
}
|
||||
}
|
||||
signed := sig != nil && sig.Policy != "" && sig.Policy != model.DNSSECInsecure
|
||||
switch {
|
||||
case len(ds) == 0 && signed && sig.Phase.CanPublishDS:
|
||||
add("info", "The zone is signed and ready: publish its DS record at Porkbun to turn validation on.")
|
||||
case len(ds) == 0 && signed:
|
||||
add("info", "The zone is signed; its DS record can be published once named says it's ready (DNSSEC tab).")
|
||||
case len(ds) > 0 && (sig == nil || sig.Policy == ""):
|
||||
add("error", "Porkbun has a DS record for %s (key %s), but the zone isn't signed: validating resolvers reject the domain. Remove the DS record.", domain, strings.Join(stale, ", "))
|
||||
case len(ds) > 0 && len(good) == 0:
|
||||
add("error", "Porkbun's DS record (key %s) matches none of the zone's keys: validating resolvers reject the domain. Replace it with the zone's DS record.", strings.Join(stale, ", "))
|
||||
case len(ds) > 0 && sig.Policy == model.DNSSECInsecure:
|
||||
add("warning", "DNSSEC is being turned off: remove the DS record (key %s) at Porkbun, then run the DS check.", strings.Join(good, ", "))
|
||||
case len(good) > 0:
|
||||
add("ok", "Porkbun's DS record matches the zone's key %s.", strings.Join(good, ", "))
|
||||
if len(stale) > 0 {
|
||||
add("warning", "Porkbun also has a DS record for key %s, which the zone doesn't have any more; remove it.", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// publishedDS returns the DS records of the key-signing keys named
|
||||
// publishes for the zone.
|
||||
func publishedDS(sig *dnssec.Status) []dnssec.DS {
|
||||
if sig == nil {
|
||||
return nil
|
||||
}
|
||||
var out []dnssec.DS
|
||||
for _, k := range sig.Keys {
|
||||
if k.DS != nil && k.DNSKEYState != dnssec.Hidden {
|
||||
out = append(out, *k.DS)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canonAddrs(addrs []string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range addrs {
|
||||
if v, err := netip.ParseAddr(a); err == nil && !seen[v.String()] {
|
||||
seen[v.String()] = true
|
||||
out = append(out, v.String())
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) zoneRegistrar(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, z, domain, err := s.registrarZone(r, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
writeJSON(w, http.StatusOK, s.registrarState(ctx, st, z, domain, s.eng.DNSSEC(ctx, st, z)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// changeCtx holds what a registrar change needs.
|
||||
type changeCtx struct {
|
||||
st *model.State
|
||||
z *model.Zone
|
||||
domain string
|
||||
in api.RegistrarChange
|
||||
sig *dnssec.Status
|
||||
cur *api.RegistrarResponse
|
||||
out api.RegistrarChangeResponse
|
||||
}
|
||||
|
||||
func (c *changeCtx) check(level, format string, args ...any) {
|
||||
c.out.Checks = append(c.out.Checks, nscheck.Finding{Level: level, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// blocked returns the error that refuses the change when a check failed
|
||||
// (unless forced).
|
||||
func (c *changeCtx) blocked() error {
|
||||
if c.in.Force || c.in.DryRun {
|
||||
return nil
|
||||
}
|
||||
var errs []string
|
||||
for _, f := range c.out.Checks {
|
||||
if f.Level == "error" {
|
||||
errs = append(errs, f.Message)
|
||||
}
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return conflict("not changed, because it could break the domain: %s (force overrides this check)", strings.Join(errs, " "))
|
||||
}
|
||||
|
||||
func (s *Server) registrarChange(w http.ResponseWriter, r *http.Request, p *auth.Principal, body bool, run func(ctx context.Context, c *changeCtx) error) error {
|
||||
st, z, domain, err := s.registrarZone(r, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := &changeCtx{st: st, z: z, domain: domain, out: api.RegistrarChangeResponse{Done: []string{}, Checks: []nscheck.Finding{}, Warnings: []string{}}}
|
||||
if body {
|
||||
if err := decodeOptional(r, &c.in); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if queryBool(r, "dry_run") {
|
||||
c.in.DryRun = true
|
||||
}
|
||||
if queryBool(r, "force") {
|
||||
c.in.Force = true
|
||||
}
|
||||
c.out.DryRun = c.in.DryRun
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
c.sig = s.eng.DNSSEC(ctx, st, z)
|
||||
c.cur = s.registrarState(ctx, st, z, domain, c.sig)
|
||||
if c.cur.Error != "" {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", c.cur.Error}
|
||||
}
|
||||
if err := run(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c.out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) audit(ctx context.Context, p *auth.Principal, c *changeCtx, action, msg string) {
|
||||
s.eng.Audit(ctx, p, action, "zone", c.z.ID, dnsname.Display(c.z.Name), "success", msg)
|
||||
}
|
||||
|
||||
// serving checks that the name servers answer authoritatively for the
|
||||
// zone with the serial this server serves (for a signed zone, the signed
|
||||
// serial: so they serve the signed zone).
|
||||
func (s *Server) serving(ctx context.Context, c *changeCtx, targets []nscheck.Target, why string) {
|
||||
ref, _, _ := s.localSerial(ctx, c.st, c.z)
|
||||
for _, res := range s.checker().Serials(ctx, c.z.Name, ref, targets) {
|
||||
where := dnsname.Display(res.Name)
|
||||
if res.Address != "" {
|
||||
where += " (" + res.Address + ")"
|
||||
}
|
||||
switch res.Status {
|
||||
case "self", "in_sync":
|
||||
case "answered":
|
||||
c.check("warning", "%s answers for the zone, but its serial couldn't be compared with this server's.", where)
|
||||
case "behind", "ahead":
|
||||
c.check("error", "%s serves serial %d, not this server's %d; %s.", where, *res.Serial, *ref, why)
|
||||
default:
|
||||
msg := res.Status
|
||||
if res.Error != "" {
|
||||
msg += ": " + res.Error
|
||||
}
|
||||
c.check("error", "%s doesn't serve the zone (%s); %s.", where, msg, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// targetsFor returns check targets for name servers: in-domain ones at
|
||||
// the given addresses, others looked up.
|
||||
func targetsFor(names []string, addrs map[string][]string) []nscheck.Target {
|
||||
var out []nscheck.Target
|
||||
for _, n := range names {
|
||||
out = append(out, nscheck.Target{Name: dnsname.Fqdn(n), Addresses: addrs[n], Source: "name server"})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) registrarSyncGlue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
var todo []api.GlueStatus
|
||||
for _, g := range c.cur.Glue {
|
||||
if !g.Needed {
|
||||
continue
|
||||
}
|
||||
if len(g.Jarl) == 0 {
|
||||
c.check("error", "%s has no A or AAAA record in the zone, so there is no address to give its glue.", g.Host)
|
||||
continue
|
||||
}
|
||||
if g.Action == "" {
|
||||
continue
|
||||
}
|
||||
todo = append(todo, g)
|
||||
if g.Action == "update" && g.InUse {
|
||||
// Resolvers go to the new addresses at once.
|
||||
s.serving(ctx, c, []nscheck.Target{{Name: dnsname.Fqdn(g.Host), Addresses: g.Jarl, Source: "glue"}}, "the domain is delegated to "+g.Host+", so resolvers would go there")
|
||||
}
|
||||
}
|
||||
if len(todo) == 0 && len(c.out.Checks) == 0 {
|
||||
c.check("ok", "The glue records at Porkbun already match the zone.")
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range todo {
|
||||
what := fmt.Sprintf("%s glue for %s: %s", map[string]string{"create": "create", "update": "update"}[g.Action], g.Host, strings.Join(g.Jarl, ", "))
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.SetGlue(ctx, c.domain, g.Host, g.Jarl, g.Action == "update"); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", fmt.Sprintf("%v (done before that: %s)", err, strings.Join(c.out.Done, "; "))}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.glue", what)
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarPublishDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
sig := c.sig
|
||||
if sig.Policy == "" || sig.Policy == model.DNSSECInsecure {
|
||||
return conflict("the zone isn't signed (or DNSSEC is being turned off), so it has no DS record to publish")
|
||||
}
|
||||
var keys []*dnssec.Key
|
||||
for _, k := range sig.Keys {
|
||||
if k.DS != nil && k.Goal == dnssec.Omnipresent && (c.in.KeyTag == 0 || k.Tag == c.in.KeyTag) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return conflict("no key to publish a DS record for (named hasn't made the zone's key yet, or there is no key %d)", c.in.KeyTag)
|
||||
}
|
||||
if !sig.Phase.CanPublishDS {
|
||||
c.check("error", "Not yet: %s", sig.Phase.Next)
|
||||
}
|
||||
// The DS turns validation on: whatever the domain is delegated
|
||||
// to must serve this signed zone.
|
||||
if !c.cur.NSMatch {
|
||||
c.check("error", "Porkbun delegates the domain to %s, not to the zone's name servers (%s): validating resolvers would reject the answers of servers that don't serve this signed zone. Set the name servers first.", strings.Join(c.cur.NS, ", "), strings.Join(c.cur.JarlNS, ", "))
|
||||
} else {
|
||||
glue := map[string][]string{}
|
||||
for _, g := range c.cur.Glue {
|
||||
glue[g.Host] = g.Registry
|
||||
}
|
||||
s.serving(ctx, c, targetsFor(c.cur.NS, glue), "validating resolvers would reject its answers once the DS is published")
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
already := false
|
||||
for _, d := range c.cur.DS {
|
||||
if d.Matches && d.KeyTag == k.Tag {
|
||||
already = true
|
||||
}
|
||||
}
|
||||
if already {
|
||||
c.out.Warnings = append(c.out.Warnings, fmt.Sprintf("Porkbun already has the DS record for key %d.", k.Tag))
|
||||
continue
|
||||
}
|
||||
what := fmt.Sprintf("publish the DS record for key %d (algorithm %d, digest type %d)", k.Tag, k.DS.Algorithm, k.DS.DigestType)
|
||||
if !c.in.DryRun {
|
||||
warns, err := s.cfg.Porkbun.AddDS(ctx, c.domain, porkbun.DS{KeyTag: k.DS.KeyTag, Algorithm: k.DS.Algorithm, DigestType: k.DS.DigestType, Digest: k.DS.Digest})
|
||||
if err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
c.out.Warnings = append(c.out.Warnings, warns...)
|
||||
s.audit(ctx, p, c, "registrar.ds.add", what)
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
}
|
||||
if len(c.out.Done) > 0 && !c.in.DryRun {
|
||||
c.out.Warnings = append(c.out.Warnings, "The registry publishes it in the parent zone within minutes to hours; then run the DS check (jarl zone dnssec check-ds) so named knows.")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarRemoveDS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
tag, err := strconv.ParseUint(r.PathValue("keytag"), 10, 16)
|
||||
if err != nil {
|
||||
return badRequest("invalid key tag %q", r.PathValue("keytag"))
|
||||
}
|
||||
return s.registrarChange(w, r, p, false, func(ctx context.Context, c *changeCtx) error {
|
||||
var found *api.RegistrarDS
|
||||
for i := range c.cur.DS {
|
||||
if c.cur.DS[i].KeyTag == uint16(tag) {
|
||||
found = &c.cur.DS[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return notFound("Porkbun has no DS record for key %d", tag)
|
||||
}
|
||||
if found.Matches && c.sig.Policy != "" && c.sig.Policy != model.DNSSECInsecure {
|
||||
c.check("warning", "This DS record points at the zone's key: without it, resolvers stop validating the domain (the zone stays signed). To turn DNSSEC off, start with 'Turn signing off' so named removes its signatures safely afterwards.")
|
||||
}
|
||||
c.check("info", "Porkbun notes that most registries delete every DS record with the same key data.")
|
||||
what := fmt.Sprintf("remove the DS record for key %d", tag)
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.DeleteDS(ctx, c.domain, uint16(tag)); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.ds.delete", what)
|
||||
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep the DS cached for its TTL (often a day). If DNSSEC is being turned off, run the DS check once the parent zone no longer has it.")
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) registrarSetNS(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.registrarChange(w, r, p, true, func(ctx context.Context, c *changeCtx) error {
|
||||
target := c.cur.JarlNS
|
||||
if len(c.in.NS) > 0 {
|
||||
target = nil
|
||||
for _, n := range c.in.NS {
|
||||
n = lowerNoDot(n)
|
||||
if !dnsname.IsHostname(dnsname.Fqdn(n), false) {
|
||||
return badRequest("%q isn't a host name", n)
|
||||
}
|
||||
target = append(target, n)
|
||||
}
|
||||
}
|
||||
if len(target) == 0 {
|
||||
return badRequest("no name servers to set: the zone has no NS records")
|
||||
}
|
||||
if sameSet(target, c.cur.NS) {
|
||||
c.check("ok", "Porkbun already delegates the domain to %s.", strings.Join(target, ", "))
|
||||
return nil
|
||||
}
|
||||
if len(target) < 2 {
|
||||
c.check("warning", "Only one name server: when it can't be reached, the domain can't be resolved at all. Registries and RFC 1034 expect at least two (a secondary on another network).")
|
||||
}
|
||||
if !sameSet(target, c.cur.JarlNS) {
|
||||
c.check("warning", "These aren't the zone's NS records (%s); resolvers prefer the zone's own list once they reach it.", strings.Join(c.cur.JarlNS, ", "))
|
||||
}
|
||||
// In-domain servers need glue at the registry.
|
||||
addrs := map[string][]string{}
|
||||
for _, n := range target {
|
||||
if !dnsname.IsSubdomain(dnsname.Fqdn(n), c.z.Name) {
|
||||
continue
|
||||
}
|
||||
var g *api.GlueStatus
|
||||
for i := range c.cur.Glue {
|
||||
if c.cur.Glue[i].Host == n {
|
||||
g = &c.cur.Glue[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case g == nil || len(g.Registry) == 0:
|
||||
c.check("error", "%s is inside the domain but has no glue record at Porkbun, so resolvers couldn't find it. Sync the glue first.", n)
|
||||
case len(g.Jarl) > 0 && !sameSet(g.Registry, g.Jarl):
|
||||
c.check("error", "The glue for %s at Porkbun (%s) differs from the zone (%s). Sync the glue first.", n, strings.Join(g.Registry, ", "), strings.Join(g.Jarl, ", "))
|
||||
default:
|
||||
addrs[n] = g.Registry
|
||||
}
|
||||
if addrs[n] == nil {
|
||||
// Check the addresses the glue will have.
|
||||
addrs[n] = canonAddrs(jarlAddresses(c.st, c.z.ViewID, dnsname.Fqdn(n)))
|
||||
}
|
||||
}
|
||||
// Every new server must serve the zone...
|
||||
s.serving(ctx, c, targetsFor(target, addrs), "resolvers would be sent there")
|
||||
// ...and match the DS records at the registry, or the domain goes
|
||||
// dark for validating resolvers.
|
||||
if len(c.cur.DS) > 0 {
|
||||
matches := false
|
||||
for _, d := range c.cur.DS {
|
||||
matches = matches || d.Matches
|
||||
}
|
||||
if !matches {
|
||||
c.check("error", "Porkbun has DS records for the domain (key %s), and the zone's keys don't match them: validating resolvers would reject every answer from the new name servers. Remove the DS records first (or publish the zone's own).", dsTags(c.cur.DS))
|
||||
}
|
||||
}
|
||||
if err := c.blocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
what := "set the name servers to " + strings.Join(target, ", ")
|
||||
if !c.in.DryRun {
|
||||
if err := s.cfg.Porkbun.SetNS(ctx, c.domain, target); err != nil {
|
||||
return &httpError{http.StatusBadGateway, "registrar_error", err.Error()}
|
||||
}
|
||||
s.audit(ctx, p, c, "registrar.ns", what+" (was "+strings.Join(c.cur.NS, ", ")+")")
|
||||
c.out.Warnings = append(c.out.Warnings, "Resolvers may keep using the old name servers for the parent's NS TTL (often a day or two); keep them serving the zone until then.")
|
||||
}
|
||||
c.out.Done = append(c.out.Done, what)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func dsTags(ds []api.RegistrarDS) string {
|
||||
var s []string
|
||||
for _, d := range ds {
|
||||
s = append(s, strconv.Itoa(int(d.KeyTag)))
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]in
|
||||
if z.Type == model.ZonePrimary {
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
)
|
||||
|
||||
// soaServers answers SOA queries for zone with serial on each address
|
||||
// (all on one port, which it returns).
|
||||
func soaServers(t *testing.T, zone string, serial uint32, addrs ...string) string {
|
||||
t.Helper()
|
||||
port := "0"
|
||||
for _, a := range addrs {
|
||||
pc, err := net.ListenPacket("udp", net.JoinHostPort(a, port))
|
||||
if err != nil {
|
||||
t.Skipf("can't listen on %s: %v", a, err)
|
||||
}
|
||||
_, port, _ = net.SplitHostPort(pc.LocalAddr().String())
|
||||
srv := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(req)
|
||||
m.Authoritative = true
|
||||
if req.Question[0].Qtype == dns.TypeSOA {
|
||||
rr, _ := dns.NewRR(zone + " 300 IN SOA ns1." + zone + " h." + zone + " " + strconv.FormatUint(uint64(serial), 10) + " 3600 900 1209600 300")
|
||||
m.Answer = []dns.RR{rr}
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
})}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
func TestRegistrar(t *testing.T) {
|
||||
fake := porkbun.NewFake("pk1_a", "sk1_b", "adbraun.lol")
|
||||
pts := httptest.NewServer(fake)
|
||||
t.Cleanup(pts.Close)
|
||||
e := newAPIEnvConfig(t, Config{Version: "test", Porkbun: &porkbun.Client{BaseURL: pts.URL + "/api/json/v3", APIKey: "pk1_a", Secret: "sk1_b"},
|
||||
PorkbunAllows: func(d string) bool { return d == "adbraun.lol" }})
|
||||
e.do("POST", "/zones", e.token, map[string]any{
|
||||
"name": "adbraun.lol", "nameservers": []string{"ns1.adbraun.lol.", "ns2.adbraun.lol."},
|
||||
"records": []map[string]any{
|
||||
{"name": "ns1", "type": "A", "data": "127.0.0.41"}, {"name": "ns1", "type": "AAAA", "data": "::1"},
|
||||
{"name": "ns2", "type": "A", "data": "127.0.0.42"},
|
||||
},
|
||||
}, 201)
|
||||
e.do("POST", "/zones", e.token, map[string]any{"name": "other.lol"}, 201)
|
||||
e.do("GET", "/zones/other.lol/registrar", e.token, nil, 400)
|
||||
|
||||
st := e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if st["ns_match"] != false || len(st["glue"].([]any)) != 2 {
|
||||
t.Fatalf("status: %v", st)
|
||||
}
|
||||
for _, g := range st["glue"].([]any) {
|
||||
if g.(map[string]any)["action"] != "create" {
|
||||
t.Errorf("glue: %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// Glue: a dry run changes nothing; then both hosts, IPv4 and IPv6.
|
||||
out := e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(out["done"].([]any)) != 2 || len(fake.Domains["adbraun.lol"].Glue) != 0 {
|
||||
t.Errorf("dry run: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 200)
|
||||
g := fake.Domains["adbraun.lol"].Glue
|
||||
sort.Strings(g["ns1.adbraun.lol"])
|
||||
if strings.Join(g["ns1.adbraun.lol"], " ") != "127.0.0.41 ::1" || strings.Join(g["ns2.adbraun.lol"], " ") != "127.0.0.42" {
|
||||
t.Errorf("glue at Porkbun: %v", g)
|
||||
}
|
||||
|
||||
// NS: refused while the servers don't answer...
|
||||
e.srv.nsChecker = &nscheck.Checker{Timeout: 200 * time.Millisecond, Port: "1", IsLocal: func(netip.Addr) bool { return false }}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "doesn't serve the zone") {
|
||||
t.Errorf("set-ns without servers: %v", out)
|
||||
}
|
||||
chk := e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"dry_run": true}, 200)
|
||||
if len(chk["checks"].([]any)) == 0 {
|
||||
t.Errorf("dry run checks: %v", chk)
|
||||
}
|
||||
// ...and allowed once they serve the zone.
|
||||
serial := uint32(e.do("GET", "/zones/adbraun.lol", e.token, nil, 200)["soa"].(map[string]any)["serial"].(float64))
|
||||
port := soaServers(t, "adbraun.lol.", serial, "127.0.0.41", "127.0.0.42", "::1")
|
||||
e.srv.nsChecker.Port = port
|
||||
e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, nil, 200)
|
||||
if ns := fake.Domains["adbraun.lol"].NS; strings.Join(ns, " ") != "ns1.adbraun.lol ns2.adbraun.lol" {
|
||||
t.Errorf("NS at Porkbun: %v", ns)
|
||||
}
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); st["ns_match"] != true {
|
||||
t.Errorf("after set-ns: %v", st)
|
||||
}
|
||||
|
||||
// A DS record for an unsigned zone: reported, removable; and setting
|
||||
// NS while a foreign DS is there is refused.
|
||||
fake.Domains["adbraun.lol"].DS[4242] = porkbun.DS{KeyTag: 4242, Algorithm: 13, DigestType: 2, Digest: "AB"}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
found := false
|
||||
for _, f := range st["findings"].([]any) {
|
||||
if f.(map[string]any)["level"] == "error" && strings.Contains(f.(map[string]any)["message"].(string), "isn't signed") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("DS on an unsigned zone not reported: %v", st["findings"])
|
||||
}
|
||||
out = e.do("PUT", "/zones/adbraun.lol/registrar/ns", e.token, map[string]any{"ns": []string{"ns1.adbraun.lol"}}, 409)
|
||||
if !strings.Contains(out["error"].(map[string]any)["message"].(string), "DS records") {
|
||||
t.Errorf("set-ns with a foreign DS: %v", out)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // not signed
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 200)
|
||||
if len(fake.Domains["adbraun.lol"].DS) != 0 {
|
||||
t.Error("DS not removed")
|
||||
}
|
||||
e.do("DELETE", "/zones/adbraun.lol/registrar/ds/4242", e.token, nil, 404)
|
||||
|
||||
// A signed zone whose key named has published long enough: its DS
|
||||
// goes to Porkbun.
|
||||
e.do("PATCH", "/zones/adbraun.lol", e.token, map[string]any{"dnssec_policy": "default"}, 200)
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 409) // no key yet
|
||||
tag := writeKey(t, filepath.Join(e.srv.eng.Config().Paths.KeysDir(), "_default"), "adbraun.lol.")
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if d, ok := fake.Domains["adbraun.lol"].DS[tag]; !ok || d.Algorithm != 13 || d.DigestType != 2 || len(d.Digest) != 64 {
|
||||
t.Errorf("DS at Porkbun: %+v", fake.Domains["adbraun.lol"].DS)
|
||||
}
|
||||
st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200)
|
||||
if !st["ds"].([]any)[0].(map[string]any)["matches"].(bool) {
|
||||
t.Errorf("DS doesn't match: %v", st["ds"])
|
||||
}
|
||||
out = e.do("POST", "/zones/adbraun.lol/registrar/ds", e.token, nil, 200)
|
||||
if len(out["done"].([]any)) != 0 {
|
||||
t.Errorf("published twice: %v", out)
|
||||
}
|
||||
|
||||
// Registrar errors are passed on.
|
||||
fake.Domains["adbraun.lol"].APIAccess = false
|
||||
if st = e.do("GET", "/zones/adbraun.lol/registrar", e.token, nil, 200); !strings.Contains(st["error"].(string), "API_ACCESS_DISABLED") {
|
||||
t.Errorf("error: %v", st)
|
||||
}
|
||||
e.do("POST", "/zones/adbraun.lol/registrar/glue", e.token, nil, 502)
|
||||
}
|
||||
|
||||
// writeKey writes the .key and .state files named would have for a key
|
||||
// of zone that is ready for its DS record, and returns its tag.
|
||||
func writeKey(t *testing.T, dir, zone string) uint16 {
|
||||
t.Helper()
|
||||
k := &dns.DNSKEY{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeDNSKEY, Class: dns.ClassINET, Ttl: 3600}, Flags: 257, Protocol: 3, Algorithm: dns.ECDSAP256SHA256}
|
||||
if _, err := k.Generate(256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(dir, fmt.Sprintf("K%s+013+%05d", zone, k.KeyTag()))
|
||||
os.MkdirAll(dir, 0o755)
|
||||
os.WriteFile(base+".key", []byte(k.String()+"\n"), 0o644)
|
||||
os.WriteFile(base+".state", []byte("Algorithm: 13\nKSK: yes\nZSK: yes\nGoalState: omnipresent\nDNSKEYState: omnipresent\nKRRSIGState: omnipresent\nZRRSIGState: omnipresent\nDSState: rumoured\n"), 0o644)
|
||||
return k.KeyTag()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -156,6 +157,21 @@ func readBody(r *http.Request) (json.RawMessage, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// decodeOptional reads a JSON body into v like decodeJSON, but an empty
|
||||
// body leaves v as it is.
|
||||
func decodeOptional(r *http.Request, v any) error {
|
||||
b, err := readBody(r)
|
||||
if err != nil || b == nil {
|
||||
return err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
|
||||
@@ -67,6 +67,14 @@ func (s *Server) routes() {
|
||||
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}/dnssec", s.authed(s.zoneDNSSEC))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/check-ds", s.authed(s.zoneCheckDS))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/dnssec/wipe", s.authed(s.zoneDNSSECWipe))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/registrar", s.authed(s.zoneRegistrar))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/glue", s.authed(s.registrarSyncGlue))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/registrar/ds", s.authed(s.registrarPublishDS))
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/stats"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
@@ -47,6 +48,10 @@ type Config struct {
|
||||
// reports on (backup.dir and backup.warn_after).
|
||||
BackupDir string
|
||||
BackupWarnAfter time.Duration
|
||||
// Porkbun manages domains' delegation at the registrar Porkbun (nil
|
||||
// without an API key); PorkbunAllows says which domains it may touch.
|
||||
Porkbun *porkbun.Client
|
||||
PorkbunAllows func(domain string) bool
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
|
||||
@@ -92,14 +92,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
z := &model.Zone{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -209,8 +209,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||
return err
|
||||
|
||||
@@ -238,6 +238,10 @@ CREATE TABLE deployed_files (
|
||||
ALTER TABLE tokens ADD COLUMN rotated_at TEXT;
|
||||
ALTER TABLE tokens ADD COLUMN prev_secret_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE tokens ADD COLUMN prev_expires_at TEXT;
|
||||
`,
|
||||
// 3: DNSSEC signing (the zone's dnssec-policy).
|
||||
`
|
||||
ALTER TABLE zones ADD COLUMN dnssec_policy TEXT NOT NULL DEFAULT '';
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
// defaultMaxZoneTTL is max-zone-ttl of BIND's built-in policies (and of
|
||||
// custom ones that don't set it): named refuses to load a signed zone
|
||||
// with a longer TTL.
|
||||
const defaultMaxZoneTTL = 86400
|
||||
|
||||
// Policies returns the custom dnssec-policy statements defined at the top
|
||||
// level, by name.
|
||||
func Policies(s *model.State) map[string]*namedconf.Stmt {
|
||||
out := map[string]*namedconf.Stmt{}
|
||||
for _, o := range s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil || stmt.Arg(0) == "" {
|
||||
continue
|
||||
}
|
||||
out[stmt.Arg(0)] = stmt
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) checkDNSSEC() {
|
||||
policies := Policies(c.s)
|
||||
// A custom policy without keys crashes named 9.20 when it loads the
|
||||
// configuration (an assertion in kaspconf.c), although named-checkconf
|
||||
// accepts it. BIND's built-in "insecure" policy is the way to go
|
||||
// unsigned.
|
||||
for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) {
|
||||
if strings.ToLower(o.Name) != "dnssec-policy" {
|
||||
continue
|
||||
}
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue // reported by checkOptions
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if keys := namedconf.FindOne(b.Stmts, "keys"); keys != nil {
|
||||
if kb := keys.FirstBlock(); kb != nil && len(kb.Stmts) == 0 {
|
||||
c.errf("dnssec_policy_no_keys", "option", o.ID, "dnssec-policy %s has an empty keys list, which makes named 9.20 crash when it loads the configuration; to remove signing from a zone, use BIND's built-in insecure policy (jarl zone dnssec disable)", stmt.Arg(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, z := range c.s.SortedZones() {
|
||||
if z.DNSSECPolicy == "" {
|
||||
continue
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
c.zoneProblem(z, Error, "dnssec_zone_type", "only primary zones can be signed by Jarl; a %s zone gets its signatures from its primary", z.Type)
|
||||
continue
|
||||
}
|
||||
maxTTL := uint32(defaultMaxZoneTTL)
|
||||
switch z.DNSSECPolicy {
|
||||
case model.DNSSECDefault, model.DNSSECInsecure:
|
||||
default:
|
||||
stmt := policies[z.DNSSECPolicy]
|
||||
if stmt == nil {
|
||||
c.zoneProblem(z, Error, "dnssec_policy_unknown", "dnssec-policy %q doesn't exist: use default (BIND's built-in policy), or define it as a top-level dnssec-policy statement", z.DNSSECPolicy)
|
||||
continue
|
||||
}
|
||||
if b := stmt.FirstBlock(); b != nil {
|
||||
if m := namedconf.FindOne(b.Stmts, "max-zone-ttl"); m != nil {
|
||||
if v, ok := parseDuration(m.Arg(0)); ok && v > 0 {
|
||||
maxTTL = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if o := c.s.FindOption(model.ScopeZone, z.ID, "inline-signing"); o != nil && isNo(o.Value) {
|
||||
c.zoneProblem(z, Error, "dnssec_inline_signing", "a signed zone needs inline signing: named keeps its signatures in a separate copy of the zone, because Jarl rewrites the zone file (remove the inline-signing option)")
|
||||
}
|
||||
// named refuses to load a signed zone with a TTL above the
|
||||
// policy's max-zone-ttl, and keeps serving the old version.
|
||||
if z.DefaultTTL > maxTTL {
|
||||
c.zoneProblem(z, Error, "dnssec_ttl_too_long", "the default TTL (%d) is longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", z.DefaultTTL, maxTTL, maxTTL)
|
||||
}
|
||||
for _, r := range c.zoneRecords[z.ID] {
|
||||
if r.TTL != nil && *r.TTL > maxTTL {
|
||||
p := c.zoneProblem(z, Error, "dnssec_ttl_too_long", "%s %s has TTL %d, longer than the DNSSEC policy's max-zone-ttl (%d), so named would refuse to load the signed zone; lower it to %d or less", strings.TrimSuffix(r.Name, "."), r.Type, *r.TTL, maxTTL, maxTTL)
|
||||
p.ObjectType, p.ObjectID, p.Name = "record", r.ID, r.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isNo(v string) bool {
|
||||
switch strings.ToLower(strings.Trim(strings.TrimSpace(v), `";`)) {
|
||||
case "no", "false", "0":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
isoDuration = regexp.MustCompile(`^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`)
|
||||
ttlDuration = regexp.MustCompile(`^(?:\d+[wdhms])+$`)
|
||||
ttlPart = regexp.MustCompile(`(\d+)([wdhms])`)
|
||||
)
|
||||
|
||||
// parseDuration parses a BIND duration: ISO 8601 (P1D, PT12H), seconds, or
|
||||
// TTL style (1d, 12h30m).
|
||||
func parseDuration(s string) (uint32, bool) {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if n, err := strconv.ParseUint(s, 10, 32); err == nil {
|
||||
return uint32(n), true
|
||||
}
|
||||
if m := isoDuration.FindStringSubmatch(s); m != nil && s != "P" && s != "PT" {
|
||||
units := []uint64{31536000, 2592000, 604800, 86400, 3600, 60, 1}
|
||||
var total uint64
|
||||
for i, u := range units {
|
||||
if m[i+1] != "" {
|
||||
n, _ := strconv.ParseUint(m[i+1], 10, 32)
|
||||
total += n * u
|
||||
}
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
l := strings.ToLower(s)
|
||||
if ttlDuration.MatchString(l) {
|
||||
units := map[string]uint64{"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1}
|
||||
var total uint64
|
||||
for _, m := range ttlPart.FindAllStringSubmatch(l, -1) {
|
||||
n, _ := strconv.ParseUint(m[1], 10, 32)
|
||||
total += n * units[m[2]]
|
||||
}
|
||||
return uint32(min(total, 1<<32-1)), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
func TestDNSSEC(t *testing.T) {
|
||||
b := newBuilder()
|
||||
ok := b.primary("signed.example", nil)
|
||||
ok.DNSSECPolicy = model.DNSSECDefault
|
||||
ps := State(b.s, Options{})
|
||||
if len(codes(ps, Error)) != 0 {
|
||||
t.Fatalf("a signed zone: %v", ps)
|
||||
}
|
||||
|
||||
sec := b.zone("copy.example", nil, model.ZoneSecondary)
|
||||
b.opt(model.ScopeZone, sec.ID, "primaries", "{ 192.0.2.1; }")
|
||||
sec.DNSSECPolicy = model.DNSSECDefault
|
||||
unknown := b.primary("custom.example", nil)
|
||||
unknown.DNSSECPolicy = "fast"
|
||||
long := b.primary("long.example", nil)
|
||||
long.DNSSECPolicy = model.DNSSECInsecure
|
||||
r := b.rec(long, "www", "A", "192.0.2.80")
|
||||
r.TTL = model.Ptr(uint32(172800))
|
||||
b.opt(model.ScopeZone, ok.ID, "inline-signing", "no")
|
||||
ps = State(b.s, Options{})
|
||||
for _, c := range []string{"dnssec_zone_type", "dnssec_policy_unknown", "dnssec_ttl_too_long", "dnssec_inline_signing"} {
|
||||
if !hasCode(ps, c) {
|
||||
t.Errorf("missing %s in %v", c, ps)
|
||||
}
|
||||
}
|
||||
|
||||
// A custom policy: its max-zone-ttl applies; an empty keys list would
|
||||
// crash named.
|
||||
b2 := newBuilder()
|
||||
z := b2.primary("custom.example", nil)
|
||||
z.DNSSECPolicy = "fast"
|
||||
z.DefaultTTL = 600
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `fast { keys { csk lifetime unlimited algorithm 13; }; max-zone-ttl PT5M; }`)
|
||||
b2.opt(model.ScopeTopLevel, 0, "dnssec-policy", `"off" { keys { }; }`)
|
||||
ps = State(b2.s, Options{})
|
||||
if !hasCode(ps, "dnssec_ttl_too_long") || !hasCode(ps, "dnssec_policy_no_keys") || hasCode(ps, "dnssec_policy_unknown") {
|
||||
t.Errorf("custom policy: %v", ps)
|
||||
}
|
||||
|
||||
// dnssec-policy is set per zone in Jarl, not as an option.
|
||||
b3 := newBuilder()
|
||||
z = b3.primary("example.com", nil)
|
||||
b3.opt(model.ScopeZone, z.ID, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeOptions, 0, "dnssec-policy", "default")
|
||||
b3.opt(model.ScopeZone, z.ID, "key-directory", `"/tmp"`)
|
||||
if n := len(codes(State(b3.s, Options{}), Error)); n != 3 {
|
||||
t.Errorf("managed options: %v", State(b3.s, Options{}))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
for in, want := range map[string]uint32{"86400": 86400, "P1D": 86400, "PT5M": 300, "P1W": 604800, "1d": 86400, "12h30m": 45000, "pt1h": 3600} {
|
||||
if got, ok := parseDuration(in); !ok || got != want {
|
||||
t.Errorf("parseDuration(%q) = %d %v, want %d", in, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "P", "unlimited", "1x"} {
|
||||
if _, ok := parseDuration(bad); ok {
|
||||
t.Errorf("parseDuration(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,7 @@ func State(s *model.State, opts Options) []Problem {
|
||||
c.checkZones()
|
||||
c.checkOptions()
|
||||
c.checkSecurity()
|
||||
c.checkDNSSEC()
|
||||
sortProblems(c.problems)
|
||||
return c.problems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// The zone page's DNSSEC tab: the zone's signing status, its DS record for
|
||||
// the parent zone, and the steps to turn signing on and off. Every step
|
||||
// that can affect resolvers opens a dialog that says what must be done
|
||||
// first (docs/dnssec.md).
|
||||
import { h, mount, table, badge, modal, toast, errorBox, display, input, select, checkbox, copyBox, fmtTime } from "../ui.js";
|
||||
import { api, get, change, describeResult, state } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { publishDS } from "./registrar.js";
|
||||
|
||||
const phaseBadge = {
|
||||
off: ["off", ""], leftover_keys: ["off · old keys", "warn"], starting: ["starting", "accent"],
|
||||
signing: ["signing", "accent"], ready: ["ready for DS", "warn"], ds_pending: ["DS added", "accent"],
|
||||
secure: ["secure", "ok"], removing_ds: ["turning off: remove DS", "warn"], ds_expiring: ["turning off", "accent"],
|
||||
removing_keys: ["turning off", "accent"], unsigned: ["turning off: finished", "ok"],
|
||||
};
|
||||
|
||||
export async function dnssecTab(el, zone) {
|
||||
const [s, zones] = await Promise.all([get("/zones/" + zone.id + "/dnssec"), get("/zones").catch(() => [])]);
|
||||
const admin = state.me && state.me.admin;
|
||||
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id);
|
||||
const ph = s.phase;
|
||||
const [btext, bkind] = phaseBadge[ph.code] || [ph.code, ""];
|
||||
const signing = s.policy && s.policy !== "insecure";
|
||||
const out = h("div");
|
||||
|
||||
const buttons = [];
|
||||
if (!s.policy) buttons.push(h("button", { class: "primary", onclick: () => enableDialog(zone, s, others) }, "Turn signing on…"));
|
||||
if (s.policy) buttons.push(h("button", { onclick: () => checkDS(zone, out) }, "Check DS at parent"));
|
||||
if (signing) buttons.push(h("button", { onclick: () => disableDialog(zone, s) }, "Turn signing off…"));
|
||||
if (s.policy === "insecure") buttons.push(h("button", { class: ph.can_finish ? "primary" : "", disabled: !ph.can_finish, title: ph.can_finish ? "" : "Not yet: " + (ph.next || ph.summary), onclick: () => finishDialog(zone) }, "Finish turning off"));
|
||||
if (admin && (s.policy || s.keys.length)) buttons.push(h("button", { class: "danger", onclick: () => wipeDialog(zone, s) }, "Wipe (clean slate)…"));
|
||||
|
||||
const parts = [h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" },
|
||||
h("h2", { class: "grow" }, "DNSSEC signing ", badge(btext, bkind)),
|
||||
h("div", { class: "actions" }, buttons)),
|
||||
h("p", null, ph.summary),
|
||||
ph.next ? h("div", { class: "msg " + (ph.code === "removing_ds" || ph.code === "ready" ? "warn" : "info") }, h("strong", null, "Next: "), ph.next) : null,
|
||||
s.policy ? h("dl", { class: "kv small" },
|
||||
h("dt", null, "Policy"), h("dd", { class: "mono" }, s.policy === "insecure" ? "insecure (turning off)" : s.policy),
|
||||
s.named && s.named.secure ? [h("dt", null, "Signed serial"), h("dd", { class: "mono" }, s.named.signed_serial + " (zone file " + s.named.serial + ")")] : null,
|
||||
s.named && s.named.next_key_event ? [h("dt", null, "Next key event"), h("dd", null, s.named.next_key_event)] : null,
|
||||
h("dt", null, "Key directory"), h("dd", { class: "mono" }, s.key_dir)) : null,
|
||||
s.named_error ? h("div", { class: "msg warn small" }, "named: " + s.named_error) : null,
|
||||
s.key_error ? h("div", { class: "msg err small" }, "The keys can't be read: " + s.key_error) : null,
|
||||
!s.policy && !s.keys.length ? h("p", { class: "muted small" },
|
||||
"Signing lets resolvers that validate DNSSEC detect forged answers for the zone. It only matters for a zone on the internet: validation needs a DS record in the parent zone (at your registrar), and resolvers on your own network don't validate zones this server answers itself.") : null,
|
||||
out)];
|
||||
|
||||
const live = s.keys.filter((k) => k.ds && k.goal === "omnipresent");
|
||||
if (live.length && s.policy !== "insecure") parts.push(dsPanel(zone, s, live));
|
||||
if (s.keys.length) {
|
||||
parts.push(h("div", { class: "panel" },
|
||||
h("h2", null, "Keys"),
|
||||
table([
|
||||
{ label: "Tag", render: (k) => k.tag, tdClass: "mono" },
|
||||
{ label: "Role", render: (k) => k.role },
|
||||
{ label: "Algorithm", render: (k) => k.algorithm_name || k.algorithm, tdClass: "small" },
|
||||
{ label: "Goal", render: (k) => stateBadge(k.goal) },
|
||||
{ label: "DNSKEY", render: (k) => stateBadge(k.dnskey_state) },
|
||||
{ label: "DS at parent", render: (k) => stateBadge(k.ds_state) },
|
||||
{ label: "Created", render: (k) => k.created ? fmtTime(k.created) : "–", tdClass: "small" },
|
||||
], s.keys),
|
||||
h("p", { class: "muted small" }, "States are named's: rumoured = published, but caches may not have it yet; omnipresent = everyone has it; unretentive = removed, but caches may still have it; hidden = gone.")));
|
||||
}
|
||||
mount(el, parts);
|
||||
}
|
||||
|
||||
function stateBadge(st) {
|
||||
if (!st) return h("span", { class: "muted" }, "–");
|
||||
return badge(st, { omnipresent: "ok", rumoured: "accent", unretentive: "warn" }[st] || "");
|
||||
}
|
||||
|
||||
function dsPanel(zone, s, keys) {
|
||||
const ph = s.phase;
|
||||
const where = s.parent_zone
|
||||
? ["The parent zone ", h("a", { href: "#/zones" }, display(s.parent_zone)), " is in Jarl: add this as a DS record there (name ", h("code", null, display(zone.name)), ")."]
|
||||
: "Give this to your registrar (at Porkbun: the domain's DNSSEC settings). Registrars ask either for the whole record or for its four fields.";
|
||||
return h("div", { class: "panel" },
|
||||
h("h2", null, "DS record for the parent zone"),
|
||||
ph.can_publish_ds
|
||||
? h("div", { class: "msg ok" }, ph.code === "secure" ? "The parent has this DS record." : "Ready: the DS record can be added at the parent now. Then click Check DS at parent.")
|
||||
: h("div", { class: "msg warn" }, h("strong", null, "Not yet. "),
|
||||
ph.ds_ready_at ? "Wait until " + fmtTime(ph.ds_ready_at) + "." : "Wait until named says the zone is ready.",
|
||||
" Adding the DS earlier can make the zone fail to validate for resolvers that still have unsigned answers cached."),
|
||||
h("p", { class: "small" }, where),
|
||||
s.registrar && state.me && state.me.admin && ph.code !== "secure" && ph.code !== "ds_pending"
|
||||
? h("p", null, h("button", { class: ph.can_publish_ds ? "primary" : "", onclick: () => publishDS(zone) }, "Publish at Porkbun…"),
|
||||
h("span", { class: "muted small" }, " Jarl checks that it's safe first; the Registrar tab shows everything at Porkbun.")) : null,
|
||||
keys.map((k) => h("div", { class: "ds-record" },
|
||||
h("label", { class: "small muted" }, "Key " + k.tag + " — DS record"),
|
||||
copyBox(k.ds.record.replace(/\t/g, " ")),
|
||||
h("dl", { class: "kv small" },
|
||||
h("dt", null, "Key tag"), h("dd", null, copyBox(String(k.ds.key_tag))),
|
||||
h("dt", null, "Algorithm"), h("dd", null, copyBox(String(k.ds.algorithm)), h("span", { class: "muted" }, " " + (k.algorithm_name || ""))),
|
||||
h("dt", null, "Digest type"), h("dd", null, copyBox(String(k.ds.digest_type)), h("span", { class: "muted" }, " SHA-256")),
|
||||
h("dt", null, "Digest"), h("dd", null, copyBox(k.ds.digest))))));
|
||||
}
|
||||
|
||||
async function apply(zone, policy) {
|
||||
const r = await change("PATCH", "/zones/" + zone.id, { dnssec_policy: policy }, { immediate: true });
|
||||
toast(describeResult(r));
|
||||
rerender();
|
||||
}
|
||||
|
||||
function steps(items) {
|
||||
return h("ol", { class: "small" }, items.map((i) => h("li", null, i)));
|
||||
}
|
||||
|
||||
async function enableDialog(zone, s, others) {
|
||||
const pol = select(s.policies.map((p) => [p, p === "default" ? "default (BIND's built-in policy)" : p]), "default");
|
||||
const err = h("div");
|
||||
const split = others.length ? h("div", { class: "msg warn small" },
|
||||
"This signs the zone in view ", h("strong", null, zone.view || "default"), " only; ",
|
||||
others.map((o) => display(o.name) + " in view " + o.view).join(", "),
|
||||
" stays unsigned. That's the usual split-DNS setup. Once the DS record is at the parent, a device that validates DNSSEC itself (a Pi-hole or Unbound with DNSSEC on, systemd-resolved with DNSSEC=yes) and gets the unsigned copy rejects it; resolvers asking this server directly are fine, because BIND doesn't validate zones it serves itself.") : null;
|
||||
await modal("Turn signing on for " + zone.display_name, [
|
||||
h("p", null, "named creates a key and signs the zone: Jarl keeps writing the zone as before, and named keeps a signed copy next to it. ",
|
||||
h("strong", null, "Nothing changes for resolvers yet"), ": they only start validating once the zone's DS record is added at the parent zone, which this page tells you when to do."),
|
||||
h("p", { class: "small" }, "Before you start:"),
|
||||
steps([
|
||||
"The server's clock must be right (NTP): signatures are valid for a fixed time window.",
|
||||
"TTLs in the zone must be at most a day; Jarl refuses longer ones.",
|
||||
"Secondaries get the signed zone by zone transfer; they need nothing else.",
|
||||
"With the default policy the key is never replaced, so the DS at your registrar never has to change. Keep backups: the daily backup includes the keys.",
|
||||
]),
|
||||
split,
|
||||
s.policies.length > 1 ? h("div", { class: "form-grid" }, h("label", null, "Policy"), pol) : null,
|
||||
err,
|
||||
], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Turn signing on", kind: "primary", onclick: async () => {
|
||||
try { await apply(zone, pol.value); } catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
async function disableDialog(zone, s) {
|
||||
const err = h("div");
|
||||
const hadDS = s.keys.some((k) => k.ds_published || k.ds_state === "omnipresent");
|
||||
await modal("Turn signing off for " + zone.display_name, [
|
||||
h("p", null, "Signing is turned off in steps, so the zone is never unsigned while the parent zone still has its DS record (validating resolvers would then reject the whole domain):"),
|
||||
steps([
|
||||
[h("strong", null, "Now: "), "the zone switches to BIND's insecure policy. named keeps it signed and publishes a CDS \"delete\" record. Nothing breaks."],
|
||||
[h("strong", null, "You: "), "remove the DS record at your registrar (", s.registrar ? "the Registrar tab can do that at Porkbun" : "at Porkbun: the domain's DNSSEC settings", "), then click Check DS at parent so named knows it's gone."],
|
||||
[h("strong", null, "named: "), "waits until resolvers have forgotten the DS (the parent's DS TTL, often a day), then removes the keys and signatures."],
|
||||
[h("strong", null, "Then: "), "this page lets you finish, which switches DNSSEC off."],
|
||||
]),
|
||||
hadDS ? null : h("p", { class: "muted small" }, "named has never seen a DS for this zone at its parent, so this will go quickly. If you're sure the parent never had one, Wipe switches off at once."),
|
||||
err,
|
||||
], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Start turning off", kind: "primary", onclick: async () => {
|
||||
try { await apply(zone, "insecure"); } catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
async function finishDialog(zone) {
|
||||
const err = h("div");
|
||||
await modal("Finish turning signing off", [
|
||||
h("p", null, "The parent zone has no DS record for ", h("strong", null, zone.display_name), " any more, and named has stopped counting on it. Switching DNSSEC off now is safe: named serves the zone unsigned."),
|
||||
h("p", { class: "muted small" }, "The old keys stay in the key directory (named keeps them retired); Wipe deletes them."),
|
||||
err,
|
||||
], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Switch DNSSEC off", kind: "primary", onclick: async () => {
|
||||
try { await apply(zone, ""); } catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function wipeDialog(zone, s) {
|
||||
const err = h("div");
|
||||
const confirmName = input({ placeholder: zone.display_name, class: "mono", autocomplete: "off" });
|
||||
const force = checkbox("The parent can't be checked from here, and I'm sure it has no DS record for this zone (for example, the zone isn't on the internet)", false);
|
||||
await modal("Wipe DNSSEC from " + zone.display_name, [
|
||||
h("div", { class: "msg err" },
|
||||
h("strong", null, "This can take the domain offline. "),
|
||||
"Wipe switches signing off at once and deletes the zone's keys and named's signed copy. If the parent zone still has a DS record for the zone, or resolvers still have one cached, every validating resolver rejects the whole domain until it's gone and its TTL has passed (often a day). The deleted keys can't be brought back except from a backup."),
|
||||
h("p", null, "Jarl asks the parent zone's servers first and refuses while they have a DS record. Use Turn signing off instead if the zone is on the internet and ever had a DS."),
|
||||
s.policy ? null : h("p", { class: "small" }, "Signing is already off; this deletes the old keys left in the key directory."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Type the zone name to confirm"), confirmName),
|
||||
force,
|
||||
err,
|
||||
], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Wipe", kind: "danger", onclick: async () => {
|
||||
if (confirmName.value.trim().replace(/\.$/, "").toLowerCase() !== zone.display_name.toLowerCase()) {
|
||||
mount(err, h("div", { class: "msg err" }, "Type the zone name to confirm."));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await api("POST", "/zones/" + zone.id + "/dnssec/wipe", { body: { force: force.input.checked } });
|
||||
toast("Wiped: " + r.removed.length + " files removed");
|
||||
rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
async function checkDS(zone, out) {
|
||||
mount(out, h("div", { class: "muted" }, "Asking the parent zone's servers…"));
|
||||
try {
|
||||
const r = await api("POST", "/zones/" + zone.id + "/dnssec/check-ds");
|
||||
const p = r.parent;
|
||||
mount(out, h("div", { class: "msg " + (p.error ? "err" : "info") },
|
||||
p.error ? p.error : [
|
||||
"Parent zone ", h("strong", { class: "mono" }, display(p.parent)), " (" + p.reached + " of " + p.parent_servers.length + " servers answered): ",
|
||||
p.delegated ? (p.ds.length ? "DS record" + (p.ds.length > 1 ? "s" : "") + " for key " + p.ds.map((d) => d.key_tag).join(", ") : "no DS record") : "the zone isn't delegated",
|
||||
"."],
|
||||
r.marked.map((m) => h("div", null, "named told: " + m)),
|
||||
r.notes.map((n) => h("div", { class: "small" }, n))));
|
||||
if (r.marked.length) setTimeout(rerender, 1500);
|
||||
} catch (e) { mount(out, errorBox(e)); }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// The zone page's Registrar tab: the domain's name servers, glue records
|
||||
// and DS records at Porkbun, next to what the zone says. Every change is
|
||||
// first run as a dry run, whose checks (will this break the domain?) the
|
||||
// dialog shows before anything is changed (docs/dnssec.md#porkbun).
|
||||
import { h, mount, table, badge, modal, toast, errorBox, checkbox } from "../ui.js";
|
||||
import { api, get } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
const lvl = { ok: "ok", info: "info", warning: "warn", error: "err" };
|
||||
|
||||
export async function registrarTab(el, zone) {
|
||||
const r = await get("/zones/" + zone.id + "/registrar");
|
||||
if (r.error) {
|
||||
mount(el, h("div", { class: "panel" },
|
||||
h("h2", null, "Registrar: Porkbun"),
|
||||
h("div", { class: "msg err" }, r.error),
|
||||
h("p", { class: "muted small" }, "Check the API key in jarl.yaml (porkbun:), that \"API Access\" is switched on for the domain at porkbun.com, and that the key may manage it.")));
|
||||
return;
|
||||
}
|
||||
const z = "/zones/" + zone.id + "/registrar";
|
||||
const toDo = r.glue.filter((g) => g.action).length;
|
||||
mount(el,
|
||||
h("div", { class: "panel" },
|
||||
h("h2", null, "Registrar: Porkbun ", h("span", { class: "muted small" }, r.domain)),
|
||||
r.findings.map((f) => h("div", { class: "msg " + lvl[f.level] }, f.message))),
|
||||
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" },
|
||||
h("h2", { class: "grow" }, "Name servers ", r.ns_match ? badge("match the zone", "ok") : badge("differ from the zone", "warn")),
|
||||
h("div", { class: "actions" }, h("button", { disabled: r.ns_match, onclick: () => guarded({
|
||||
title: "Delegate " + r.domain + " to the zone's name servers",
|
||||
intro: [
|
||||
h("p", null, "Porkbun will point the whole domain at ", h("strong", { class: "mono" }, r.jarl_ns.join(", ")), " instead of ", h("span", { class: "mono" }, r.ns.join(", ")), "."),
|
||||
h("p", { class: "small" }, "Jarl checks first that every one of them answers for the zone with the current serial, that the ones inside the domain have matching glue records, and that any DS record at Porkbun matches the zone's keys. Resolvers may keep using the old name servers for a day or two, so keep those serving the zone until then."),
|
||||
],
|
||||
method: "PUT", path: z + "/ns", ok: "Set name servers",
|
||||
}) }, "Set name servers…"))),
|
||||
table([
|
||||
{ label: "", render: (row) => h("strong", null, row[0]) },
|
||||
{ label: "Name servers", render: (row) => row[1].join(", ") || h("span", { class: "muted" }, "none"), tdClass: "mono" },
|
||||
], [["At Porkbun", r.ns], ["In the zone (NS records)", r.jarl_ns]])),
|
||||
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" },
|
||||
h("h2", { class: "grow" }, "Glue records"),
|
||||
h("div", { class: "actions" }, h("button", { disabled: !toDo, onclick: () => guarded({
|
||||
title: "Sync the glue records",
|
||||
intro: h("p", null, "Name servers inside the domain (like ns1." + r.domain + ") can only be found through glue records at the registry. Each one gets all of its A and AAAA records from the zone. Changing the glue of a name server the domain already uses sends resolvers to the new addresses at once, so Jarl checks they answer for the zone first."),
|
||||
method: "POST", path: z + "/glue", ok: "Sync glue",
|
||||
}) }, "Sync glue…"))),
|
||||
r.glue.length ? table([
|
||||
{ label: "Name server", render: (g) => g.host, tdClass: "mono" },
|
||||
{ label: "At Porkbun", render: (g) => g.registry.join(", ") || h("span", { class: "muted" }, "none"), tdClass: "mono small" },
|
||||
{ label: "In the zone", render: (g) => g.needed ? (g.jarl.join(", ") || h("span", { class: "muted" }, "no address record")) : h("span", { class: "muted" }, "not a name server of the zone"), tdClass: "mono small" },
|
||||
{ label: "", render: (g) => g.action ? badge(g.action === "create" ? "missing" : "differs", "warn") : g.needed ? badge("matches", "ok") : g.in_use ? badge("in use", "") : badge("unused", "") },
|
||||
], r.glue) : h("p", { class: "muted small" }, "None needed: no name server of the zone is inside the domain.")),
|
||||
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" },
|
||||
h("h2", { class: "grow" }, "DNSSEC: DS records at Porkbun"),
|
||||
h("div", { class: "actions" }, h("button", { onclick: () => publishDS(zone) }, "Publish the zone's DS…"))),
|
||||
r.ds.length ? table([
|
||||
{ label: "Key tag", render: (d) => d.key_tag, tdClass: "mono" },
|
||||
{ label: "Algorithm", render: (d) => d.algorithm },
|
||||
{ label: "Digest type", render: (d) => d.digest_type },
|
||||
{ label: "Digest", render: (d) => d.digest, tdClass: "mono small break" },
|
||||
{ label: "", render: (d) => d.matches ? badge("matches the zone's key", "ok") : badge("matches no key", "err") },
|
||||
{ label: "", render: (d) => h("button", { class: "small danger", onclick: () => guarded({
|
||||
title: "Remove the DS record for key " + d.key_tag,
|
||||
intro: [
|
||||
d.matches
|
||||
? h("div", { class: "msg warn" }, "This DS record points at the zone's key. Without it, resolvers stop validating the domain; the zone stays signed. This is the right step after starting to turn signing off (DNSSEC tab); otherwise, turn signing off first.")
|
||||
: h("div", { class: "msg info" }, "This DS record matches none of the zone's keys, so validating resolvers reject the domain while it's there: removing it fixes that."),
|
||||
h("p", { class: "small muted" }, "Resolvers may keep it cached for its TTL (often a day). Porkbun notes that most registries remove every DS record with the same key data."),
|
||||
],
|
||||
method: "DELETE", path: z + "/ds/" + d.key_tag, ok: "Remove DS record", kind: "danger",
|
||||
}) }, "Remove…") },
|
||||
], r.ds) : h("p", { class: "muted small" }, "None: resolvers treat the domain as unsigned.")),
|
||||
);
|
||||
}
|
||||
|
||||
// publishDS publishes the zone's DS record at Porkbun (from the DNSSEC tab
|
||||
// too).
|
||||
export function publishDS(zone) {
|
||||
return guarded({
|
||||
title: "Publish the DS record at Porkbun",
|
||||
intro: [
|
||||
h("p", null, "Porkbun adds the zone's DS record to the parent zone, and from then on validating resolvers check the zone's signatures."),
|
||||
h("p", { class: "small" }, "Jarl checks first that named says the zone is ready, and that the domain is delegated to name servers that serve this signed zone: a DS record that points at servers serving it unsigned (or with other keys) takes the domain offline for validating resolvers."),
|
||||
h("p", { class: "small muted" }, "Afterwards, once the parent zone has it (minutes to hours), click Check DS at parent on the DNSSEC tab."),
|
||||
],
|
||||
method: "POST", path: "/zones/" + zone.id + "/registrar/ds", ok: "Publish DS record",
|
||||
});
|
||||
}
|
||||
|
||||
// guarded runs a registrar change as a dry run, shows its checks, and
|
||||
// makes the change when confirmed. Errors block it unless the user
|
||||
// explicitly overrides them.
|
||||
async function guarded({ title, intro, method, path, ok, kind = "primary" }) {
|
||||
const body = h("div", null, h("div", { class: "muted" }, "Checking…"));
|
||||
const out = h("div");
|
||||
let blocked = false;
|
||||
const override = checkbox("I understand the checks above and want to make this change anyway", false);
|
||||
const done = modal(title, [intro, body, out], [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: ok, kind, onclick: async () => {
|
||||
if (blocked && !override.input.checked) {
|
||||
mount(out, h("div", { class: "msg err" }, "The checks found problems: tick the box to go ahead anyway."));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await api(method, path, { body: method === "DELETE" ? undefined : { force: blocked } , query: method === "DELETE" && blocked ? { force: "1" } : undefined });
|
||||
toast(r.done.length ? r.done.join("; ") : "Nothing to change", "ok", 6000);
|
||||
for (const w of r.warnings) toast(w, "ok", 9000);
|
||||
rerender();
|
||||
} catch (e) { mount(out, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
try {
|
||||
const r = await api(method, path, { body: method === "DELETE" ? undefined : { dry_run: true }, query: method === "DELETE" ? { dry_run: "1" } : undefined });
|
||||
blocked = r.checks.some((c) => c.level === "error");
|
||||
mount(body,
|
||||
r.checks.map((c) => h("div", { class: "msg " + lvl[c.level] }, c.message)),
|
||||
r.done.length ? [h("p", { class: "small" }, "This will:"), h("ul", { class: "small" }, r.done.map((d) => h("li", null, d)))] : h("p", { class: "muted" }, "Nothing to change."),
|
||||
blocked ? override : null);
|
||||
} catch (e) { mount(body, errorBox(e)); }
|
||||
return done;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
import { isReverseZone, reverseInfo, ptrOwner, addressOf } from "../names.js";
|
||||
import { dnssecTab } from "./dnssec.js";
|
||||
import { registrarTab } from "./registrar.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const id = params.id;
|
||||
@@ -15,6 +17,8 @@ export async function render(el, params, query) {
|
||||
["options", "Options"],
|
||||
zone.type === "primary" ? ["file", "Zone file"] : null,
|
||||
["primary", "secondary", "mirror"].includes(zone.type) ? ["servers", "Name servers"] : null,
|
||||
zone.type === "primary" ? ["dnssec", "DNSSEC"] : null,
|
||||
zone.registrar && state.me && state.me.admin ? ["registrar", "Registrar"] : null,
|
||||
].filter(Boolean);
|
||||
const body = h("div");
|
||||
mount(el,
|
||||
@@ -26,6 +30,7 @@ export async function render(el, params, query) {
|
||||
zone.pending ? [pendingBadge(zone.pending), " "] : null,
|
||||
zone.view ? ["view ", h("a", { href: "#/views" }, zone.view), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||
h("div", { class: "actions" },
|
||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone"))),
|
||||
@@ -36,6 +41,8 @@ export async function render(el, params, query) {
|
||||
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);
|
||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||
else if (tab === "registrar") await registrarTab(body, zone);
|
||||
}
|
||||
|
||||
// ---- name servers: serials on the other servers, and the delegation
|
||||
|
||||
@@ -243,6 +243,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.grants { font-family: var(--mono); font-size: 12px; }
|
||||
.copy-box { display: flex; gap: 6px; align-items: center; }
|
||||
.copy-box input { flex: 1; font-family: var(--mono); }
|
||||
.ds-record { margin: 12px 0 16px; }
|
||||
.ds-record > .copy-box { max-width: 900px; }
|
||||
.ds-record .kv { margin-top: 8px; align-items: center; grid-template-columns: max-content minmax(0, 1fr); }
|
||||
.ds-record .kv dd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
|
||||
.ds-record .kv dd .copy-box { flex: 1 1 200px; max-width: 640px; min-width: 0; }
|
||||
.copy-box input { min-width: 0; }
|
||||
td.break { word-break: break-all; }
|
||||
.sortable-row { display: flex; align-items: center; gap: 8px; }
|
||||
@media (max-width: 700px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
|
||||
Reference in new issue
Block a user