Jarl 0.1.0: manage BIND through a web UI, REST API and CLI
Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG keys, zones of every type, records, named.conf options) in SQLite and renders named.conf, keys.conf and zone files from it. Every change goes through one changeset pipeline: whole-configuration validation, named-checkconf -z and named-checkzone in a staging directory, atomic install, rndc reconfig/reload, serial verification with rndc zonestatus, and rollback if named does not load the change. Changes apply one at a time by default or are bundled and applied together. Applied changesets can be reverted. - jarld: the daemon with the REST API and an embedded web UI, plus import, adopt, check, resync, backup and local user recovery commands - jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh - users with argon2id passwords and CSRF-protected sessions; hashed API tokens with granular grants and roles; an audit log - systemd unit, sysusers.d/tmpfiles.d files, Makefile - unit tests, integration tests against a real named, scripts/e2e.sh - docs/ guides, README and CHANGELOG Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -0,0 +1,249 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/importer"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// ImportResult is returned by Import.
|
||||
type ImportResult struct {
|
||||
Report *importer.Report `json:"report"`
|
||||
Changeset *model.Changeset `json:"changeset"`
|
||||
Result *Result `json:"result,omitempty"`
|
||||
Applied bool `json:"applied"`
|
||||
}
|
||||
|
||||
// Import loads an existing BIND configuration into Jarl. The import becomes
|
||||
// a changeset; if the configuration is valid it is applied right away
|
||||
// (writing Jarl's files), otherwise it stays open so problems can be fixed
|
||||
// before applying.
|
||||
func (e *Engine) Import(ctx context.Context, mainPath string, p *auth.Principal, dryRun bool) (*ImportResult, error) {
|
||||
cur, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cur.Zones)+len(cur.Views)+len(cur.ACLs)+len(cur.Keys)+len(cur.Options) > 0 {
|
||||
return nil, errf(KindConflict, "Jarl already has a configuration; import only works into an empty Jarl")
|
||||
}
|
||||
st, rep, err := importer.Import(ctx, mainPath, importer.Options{Tools: e.tools, Alloc: e.store.AllocateIDs, User: p.Name()})
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "reading %s: %v", mainPath, err)
|
||||
}
|
||||
// Keep Jarl's own settings (defaults for new zones), but take the
|
||||
// name servers and contact from the imported zones if unset.
|
||||
st.Settings = cur.Settings
|
||||
if len(st.Settings.Nameservers) == 0 || st.Settings.RName == "" {
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
if len(st.Settings.Nameservers) == 0 {
|
||||
for _, r := range st.ZoneRecords(z.ID) {
|
||||
if r.Name == z.Name && r.Type == "NS" {
|
||||
st.Settings.Nameservers = append(st.Settings.Nameservers, r.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.Settings.RName == "" {
|
||||
st.Settings.RName = z.SOA.RName
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
ops := DiffOps(cur, st)
|
||||
now := e.Now()
|
||||
for i := range ops {
|
||||
ops[i].AddedAt = now
|
||||
}
|
||||
out := &ImportResult{Report: rep}
|
||||
cs := &model.Changeset{Description: "import from " + mainPath, UserID: p.User.ID, Username: p.Name(), Via: "import", Status: model.ChangesetOpen, Ops: ops}
|
||||
if dryRun {
|
||||
out.Changeset = cs
|
||||
return out, nil
|
||||
}
|
||||
if err := e.store.CreateChangeset(ctx, cs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Changeset = cs
|
||||
e.audit(ctx, p, "import", "changeset", cs.ID, mainPath, cs.ID, "success", fmt.Sprintf("%d zones, %d records", len(st.Zones), len(st.Records)), nil)
|
||||
res, err := e.Apply(ctx, cs.ID, p, ApplyOptions{NoReload: true})
|
||||
out.Result = res
|
||||
if err != nil {
|
||||
// Leave the changeset open for fixing.
|
||||
if _, ok := AsError(err); ok {
|
||||
return out, nil
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
out.Applied = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdoptResult describes an adoption.
|
||||
type AdoptResult struct {
|
||||
MainConfig string `json:"main_config"`
|
||||
Backup string `json:"backup"`
|
||||
Serials []SerialChange `json:"serials"`
|
||||
Already bool `json:"already_adopted"`
|
||||
}
|
||||
|
||||
// mainConfigText is what Jarl writes to named's main configuration file.
|
||||
func mainConfigText(jarlConf, backup string) string {
|
||||
return fmt.Sprintf(`// %s
|
||||
//
|
||||
// This server's DNS configuration is managed by Jarl. Jarl generates the
|
||||
// file included below; make changes through the Jarl web UI, API or CLI.
|
||||
// The configuration from before Jarl took over was saved as:
|
||||
// %s
|
||||
|
||||
include "%s";
|
||||
`, "named.conf", backup, jarlConf)
|
||||
}
|
||||
|
||||
// Adopt switches named over to Jarl's configuration: it bumps every
|
||||
// primary zone's serial, makes named's main configuration file include
|
||||
// Jarl's named.conf (saving the old file), reloads named and checks that
|
||||
// every zone is served from Jarl's files. On failure the old main
|
||||
// configuration is restored.
|
||||
func (e *Engine) Adopt(ctx context.Context, p *auth.Principal) (*AdoptResult, error) {
|
||||
main := e.cfg.MainConfig
|
||||
jarlConf := e.cfg.Paths.MainConfig()
|
||||
if main == "" || filepath.Clean(main) == filepath.Clean(jarlConf) {
|
||||
return nil, errf(KindBadRequest, "bind.main_config is not set (or is Jarl's own named.conf); nothing to adopt")
|
||||
}
|
||||
res := &AdoptResult{MainConfig: main}
|
||||
if ok, _ := e.Adopted(); ok {
|
||||
res.Already = true
|
||||
return res, nil
|
||||
}
|
||||
st, err := e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(st.Zones) == 0 {
|
||||
return nil, errf(KindConflict, "Jarl has no zones yet; import your configuration (jarld import) or create zones first")
|
||||
}
|
||||
if ps := e.validate(st); validate.HasErrors(ps) {
|
||||
return nil, &Error{Kind: KindValidation, Message: "the configuration has errors; fix them before adopting: " + describeErrors(ps), Problems: validate.Errors(ps)}
|
||||
}
|
||||
// Bump every primary zone's serial so named (and secondaries) load
|
||||
// the zones from Jarl's files.
|
||||
var reqs []Request
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type == model.ZonePrimary {
|
||||
policy := z.SerialPolicy
|
||||
if policy == "" {
|
||||
policy = st.Settings.SerialPolicy
|
||||
}
|
||||
next := NextSerial(z.SOA.Serial, policy, e.Now())
|
||||
reqs = append(reqs, NewRequest(OpZoneUpdate, z.ID, ZoneInput{SOA: &SOAInput{Serial: &next}}))
|
||||
res.Serials = append(res.Serials, SerialChange{ZoneID: z.ID, Zone: z.Name, View: st.ViewName(z), Old: z.SOA.Serial, New: next})
|
||||
}
|
||||
}
|
||||
if len(reqs) > 0 {
|
||||
if _, err := e.Submit(ctx, reqs, p, "adopt", "bump serials for adoption", ApplyOptions{NoReload: true, Force: true}); err != nil {
|
||||
return nil, fmt.Errorf("preparing zones: %w", err)
|
||||
}
|
||||
}
|
||||
// Make sure the files on disk are exactly Jarl's.
|
||||
if _, err := e.Resync(ctx, p, ApplyOptions{NoReload: true, Force: true}); err != nil {
|
||||
return nil, fmt.Errorf("writing Jarl's files: %w", err)
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
old, err := os.ReadFile(main)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", main, err)
|
||||
}
|
||||
fi, err := os.Stat(main)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backup := main + ".pre-jarl-" + e.Now().Format("20060102-150405")
|
||||
res.Backup = backup
|
||||
text := mainConfigText(jarlConf, backup)
|
||||
// Check the new main configuration before touching anything.
|
||||
dir, err := os.MkdirTemp(e.cfg.StagingDir, "adopt-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
staged := filepath.Join(dir, "named.conf")
|
||||
if err := os.WriteFile(staged, []byte(text), 0o640); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
check, err := e.tools.CheckConfig(ctx, staged, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !check.OK {
|
||||
return nil, &Error{Kind: KindValidation, Message: "named-checkconf rejected the configuration:\n" + check.Output}
|
||||
}
|
||||
if err := os.WriteFile(backup, old, fi.Mode().Perm()); err != nil {
|
||||
return nil, fmt.Errorf("saving %s: %w", backup, err)
|
||||
}
|
||||
restore := func() {
|
||||
if err := os.WriteFile(main, old, fi.Mode().Perm()); err != nil {
|
||||
e.log.Error("restoring main configuration failed", "path", main, "err", err)
|
||||
}
|
||||
if err := e.tools.Reconfig(ctx); err != nil {
|
||||
e.log.Error("rndc reconfig after restoring failed", "err", err)
|
||||
}
|
||||
}
|
||||
tmp := main + ".jarl-new"
|
||||
if err := os.WriteFile(tmp, []byte(text), fi.Mode().Perm()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copyOwner(fi, tmp)
|
||||
if err := os.Rename(tmp, main); err != nil {
|
||||
os.Remove(tmp)
|
||||
return nil, err
|
||||
}
|
||||
if err := e.tools.Reconfig(ctx); err != nil {
|
||||
restore()
|
||||
return nil, &Error{Kind: KindBIND, Message: "named rejected Jarl's configuration (the old configuration was restored): " + err.Error()}
|
||||
}
|
||||
st, err = e.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
restore()
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(e.cfg.ReloadTimeout)
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
view := e.namedView(st, z)
|
||||
_ = e.tools.ReloadZone(ctx, z.Name, view)
|
||||
want := z.SOA.Serial
|
||||
for {
|
||||
zs, err := e.tools.ZoneStatus(ctx, z.Name, view)
|
||||
if err == nil && zs.HasSerial && zs.Serial == want && strings.Contains(zs.Files, e.cfg.Paths.ZoneDir) {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
restore()
|
||||
detail := ""
|
||||
if err != nil {
|
||||
detail = err.Error()
|
||||
} else {
|
||||
detail = fmt.Sprintf("named serves serial %d from %s", zs.Serial, zs.Files)
|
||||
}
|
||||
return nil, &Error{Kind: KindBIND, Message: fmt.Sprintf("zone %s was not loaded from Jarl's file with serial %d (%s); the old configuration was restored", zoneDesc(st, z), want, detail)}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
e.audit(ctx, p, "system.adopt", "", 0, main, 0, "success", "named now loads "+jarlConf+"; previous configuration saved as "+backup, nil)
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/bind"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
"jarl/internal/testutil"
|
||||
)
|
||||
|
||||
func TestImportAndAdopt(t *testing.T) {
|
||||
bins := testutil.FindBind()
|
||||
if bins == nil || bins.Named == "" || bins.RNDC == "" || bins.Dig == "" {
|
||||
t.Skip("named, rndc and dig are needed")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
legacy := filepath.Join(dir, "legacy")
|
||||
run := filepath.Join(dir, "run")
|
||||
os.MkdirAll(legacy, 0o755)
|
||||
os.MkdirAll(run, 0o755)
|
||||
keyOut, err := runEnv(bins.Env, bins.TSIGKeygen, "-a", "hmac-sha256", "rndc-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyFile := filepath.Join(legacy, "rndc.key")
|
||||
os.WriteFile(keyFile, []byte(keyOut), 0o600)
|
||||
dnsPort, ctlPort := freePort(t), freePort(t)
|
||||
files := map[string]string{
|
||||
"named.conf": fmt.Sprintf(`include "%[1]s/named.conf.options";
|
||||
include "%[1]s/named.conf.local";
|
||||
include "%[1]s/rndc.key";
|
||||
controls { inet 127.0.0.1 port %[2]d allow { 127.0.0.1; } keys { "rndc-key"; }; };
|
||||
`, legacy, ctlPort),
|
||||
"named.conf.options": fmt.Sprintf(`acl homelab { 127.0.0.0/8; 192.168.1.0/24; };
|
||||
options {
|
||||
directory "%[1]s";
|
||||
pid-file "%[1]s/named.pid";
|
||||
session-keyfile "%[1]s/session.key";
|
||||
listen-on port %[2]d { 127.0.0.1; };
|
||||
listen-on-v6 { none; };
|
||||
recursion no;
|
||||
allow-query { any; };
|
||||
};
|
||||
`, run, dnsPort),
|
||||
"named.conf.local": fmt.Sprintf(`zone "adbraun.test" { type master; file "%[1]s/db.adbraun.test"; };
|
||||
zone "1.168.192.in-addr.arpa" { type master; file "%[1]s/db.192.168.1"; };
|
||||
`, legacy),
|
||||
"db.adbraun.test": `$TTL 3600
|
||||
@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 )
|
||||
IN NS ns1
|
||||
ns1 IN A 127.0.0.1
|
||||
nas IN A 192.168.1.20 ; synology
|
||||
www 300 IN CNAME nas
|
||||
`,
|
||||
"db.192.168.1": `$TTL 86400
|
||||
@ IN SOA ns1.adbraun.test. admin.adbraun.test. ( 2024051201 3600 900 1209600 300 )
|
||||
@ IN NS ns1.adbraun.test.
|
||||
20 IN PTR nas.adbraun.test.
|
||||
`,
|
||||
}
|
||||
for name, content := range files {
|
||||
os.WriteFile(filepath.Join(legacy, name), []byte(content), 0o644)
|
||||
}
|
||||
mainConf := filepath.Join(legacy, "named.conf")
|
||||
var namedLog bytes.Buffer
|
||||
cmd := exec.Command(bins.Named, "-c", mainConf, "-g", "-n", "1")
|
||||
cmd.Env = append(os.Environ(), bins.Env...)
|
||||
cmd.Stdout, cmd.Stderr = &namedLog, &namedLog
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if t.Failed() {
|
||||
t.Logf("named log:\n%s", namedLog.String())
|
||||
}
|
||||
}()
|
||||
tools := &bind.Tools{NamedCheckConf: bins.CheckConf, NamedCheckZone: bins.CheckZone, RNDC: bins.RNDC, Env: bins.Env,
|
||||
RNDCArgs: []string{"-k", keyFile, "-s", "127.0.0.1", "-p", fmt.Sprint(ctlPort)}}
|
||||
for i := 0; ; i++ {
|
||||
if _, err := tools.Status(context.Background()); err == nil {
|
||||
break
|
||||
}
|
||||
if i > 100 {
|
||||
t.Fatalf("named did not start:\n%s", namedLog.String())
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
dig := func(name, typ string) string {
|
||||
out, _ := runEnv(bins.Env, bins.Dig, "+short", "+norec", "-p", fmt.Sprint(dnsPort), "@127.0.0.1", name, typ)
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
if got := dig("nas.adbraun.test", "A"); got != "192.168.1.20" {
|
||||
t.Fatalf("legacy setup broken: %q", got)
|
||||
}
|
||||
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
cfg := Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "jarl/etc"), ZoneDir: filepath.Join(dir, "jarl/zones"), CacheDir: filepath.Join(dir, "jarl/cache")},
|
||||
MainConfig: mainConf,
|
||||
StagingDir: filepath.Join(dir, "staging"),
|
||||
ReloadTimeout: 10 * time.Second,
|
||||
}
|
||||
e := New(st, tools, cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
st.CreateUser(context.Background(), admin)
|
||||
p := &auth.Principal{User: admin, Method: "local"}
|
||||
ctx := context.Background()
|
||||
|
||||
imp, err := e.Import(ctx, mainConf, p, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !imp.Applied {
|
||||
t.Fatalf("import not applied: %+v %+v", imp.Result.Error, imp.Result.Problems)
|
||||
}
|
||||
if ok, _ := e.Adopted(); ok {
|
||||
t.Fatal("adopted before adopt")
|
||||
}
|
||||
ad, err := e.Adopt(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatalf("adopt: %v", err)
|
||||
}
|
||||
if len(ad.Serials) != 2 || ad.Serials[0].New <= ad.Serials[0].Old {
|
||||
t.Errorf("serials = %+v", ad.Serials)
|
||||
}
|
||||
if b, _ := os.ReadFile(ad.Backup); !strings.Contains(string(b), "named.conf.options") {
|
||||
t.Error("backup missing")
|
||||
}
|
||||
if ok, _ := e.Adopted(); !ok {
|
||||
t.Error("not adopted after adopt")
|
||||
}
|
||||
// Same answers, now from Jarl's files.
|
||||
if got := dig("www.adbraun.test", "A"); !strings.Contains(got, "192.168.1.20") {
|
||||
t.Errorf("www after adopt = %q", got)
|
||||
}
|
||||
zs, err := tools.ZoneStatus(ctx, "adbraun.test", "")
|
||||
if err != nil || !strings.Contains(zs.Files, cfg.Paths.ZoneDir) {
|
||||
t.Errorf("zone not loaded from Jarl's file: %+v %v", zs, err)
|
||||
}
|
||||
// And Jarl manages the server from now on.
|
||||
if _, err := e.Submit(ctx, []Request{rec("adbraun.test", "printer", "A", "192.168.1.30")}, p, "test", "", ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dig("printer.adbraun.test", "A"); got != "192.168.1.30" {
|
||||
t.Errorf("printer = %q", got)
|
||||
}
|
||||
if got := dig("20.1.168.192.in-addr.arpa", "PTR"); got != "nas.adbraun.test." {
|
||||
t.Errorf("PTR = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
udiff "github.com/aymanbagabas/go-udiff"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
)
|
||||
|
||||
// Deployer writes rendered files to disk.
|
||||
type Deployer struct {
|
||||
// Group, if set, is the group generated files and directories belong
|
||||
// to (named must be able to read them; on Debian this is "bind").
|
||||
Group string
|
||||
// NamedWritable lists directories (and their subdirectories) named
|
||||
// must be able to write to: zone files (for journals and DNSSEC
|
||||
// signing) and secondary zone files. Other directories are only
|
||||
// readable by the group.
|
||||
NamedWritable []string
|
||||
gid int
|
||||
}
|
||||
|
||||
func (d *Deployer) dirMode(dir string) fs.FileMode {
|
||||
for _, w := range d.NamedWritable {
|
||||
if w != "" && (dir == w || strings.HasPrefix(dir, w+string(filepath.Separator))) {
|
||||
return 0o770 | fs.ModeSetgid
|
||||
}
|
||||
}
|
||||
return 0o750 | fs.ModeSetgid
|
||||
}
|
||||
|
||||
func (d *Deployer) groupID() (int, error) {
|
||||
if d.Group == "" {
|
||||
return -1, nil
|
||||
}
|
||||
if d.gid != 0 {
|
||||
return d.gid, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(d.Group); err == nil {
|
||||
d.gid = n
|
||||
return n, nil
|
||||
}
|
||||
g, err := user.LookupGroup(d.Group)
|
||||
if err != nil {
|
||||
return -1, fmt.Errorf("group %q: %w", d.Group, err)
|
||||
}
|
||||
n, _ := strconv.Atoi(g.Gid)
|
||||
d.gid = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type fileBackup struct {
|
||||
path string
|
||||
existed bool
|
||||
content []byte
|
||||
mode fs.FileMode
|
||||
}
|
||||
|
||||
// Backup holds the previous contents of files changed by Install.
|
||||
type Backup struct {
|
||||
d *Deployer
|
||||
files []fileBackup
|
||||
}
|
||||
|
||||
func readFile(path string) ([]byte, bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return b, true, nil
|
||||
}
|
||||
|
||||
// EnsureDir creates dir (and missing parents) with mode and the deploy
|
||||
// group. Existing directories are left alone.
|
||||
func (d *Deployer) EnsureDir(dir string, mode fs.FileMode) error {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
return nil
|
||||
}
|
||||
if parent := filepath.Dir(dir); parent != dir {
|
||||
if err := d.EnsureDir(parent, d.dirMode(parent)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(dir, mode.Perm()); err != nil && !errors.Is(err, fs.ErrExist) {
|
||||
return err
|
||||
}
|
||||
gid, err := d.groupID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gid >= 0 {
|
||||
if err := os.Chown(dir, -1, gid); err != nil {
|
||||
return fmt.Errorf("chgrp %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return os.Chmod(dir, mode)
|
||||
}
|
||||
|
||||
func (d *Deployer) writeAtomic(path string, content []byte, mode fs.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := d.EnsureDir(dir, d.dirMode(dir)); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".jarl-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := tmp.Name()
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
tmp.Close()
|
||||
os.Remove(name)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
gid, err := d.groupID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gid >= 0 {
|
||||
if err := tmp.Chown(-1, gid); err != nil {
|
||||
return fmt.Errorf("chgrp %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
return err
|
||||
}
|
||||
ok = true
|
||||
if df, err := os.Open(dir); err == nil {
|
||||
df.Sync()
|
||||
df.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Install writes files and removes paths, returning a backup that undoes
|
||||
// the changes. On error the files already written are restored.
|
||||
func (d *Deployer) Install(files []*render.File, remove []string, dirs []string) (*Backup, error) {
|
||||
b := &Backup{d: d}
|
||||
for _, dir := range dirs {
|
||||
// Directories named writes into (secondary zone files).
|
||||
if err := d.EnsureDir(dir, 0o770|fs.ModeSetgid); err != nil {
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
save := func(path string) error {
|
||||
old, existed, err := readFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := fs.FileMode(0o640)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
b.files = append(b.files, fileBackup{path: path, existed: existed, content: old, mode: mode})
|
||||
return nil
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := save(f.Path); err != nil {
|
||||
b.Restore()
|
||||
return nil, err
|
||||
}
|
||||
if err := d.writeAtomic(f.Path, f.Content, f.Mode); err != nil {
|
||||
b.Restore()
|
||||
return nil, fmt.Errorf("writing %s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
for _, p := range remove {
|
||||
if err := save(p); err != nil {
|
||||
b.Restore()
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
b.Restore()
|
||||
return nil, fmt.Errorf("removing %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Restore puts back the previous file contents.
|
||||
func (b *Backup) Restore() error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for i := len(b.files) - 1; i >= 0; i-- {
|
||||
f := b.files[i]
|
||||
if f.existed {
|
||||
if err := b.d.writeAtomic(f.path, f.content, f.mode); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
} else if err := os.Remove(f.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func sha(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// DriftEntry is a managed file that was changed outside Jarl.
|
||||
type DriftEntry struct {
|
||||
Path string `json:"path"`
|
||||
Reason string `json:"reason"` // modified, missing
|
||||
}
|
||||
|
||||
// CheckDrift compares managed files on disk with what Jarl last wrote.
|
||||
func CheckDrift(deployed map[string]*model.DeployedFile) []DriftEntry {
|
||||
var out []DriftEntry
|
||||
for path, f := range deployed {
|
||||
b, exists, err := readFile(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
out = append(out, DriftEntry{Path: path, Reason: "unreadable: " + err.Error()})
|
||||
case !exists:
|
||||
out = append(out, DriftEntry{Path: path, Reason: "missing"})
|
||||
case sha(b) != f.SHA256:
|
||||
out = append(out, DriftEntry{Path: path, Reason: "modified"})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
// fileDiff returns a unified diff from old to new for display. Secret
|
||||
// files only show that they changed.
|
||||
func fileDiff(path string, old []byte, existed bool, new []byte, removed, secret bool) string {
|
||||
from, to := path, path
|
||||
if !existed {
|
||||
from = "/dev/null"
|
||||
}
|
||||
if removed {
|
||||
to = "/dev/null"
|
||||
}
|
||||
if secret {
|
||||
return fmt.Sprintf("--- %s\n+++ %s\n@@ contents hidden: this file holds TSIG secrets @@\n", from, to)
|
||||
}
|
||||
d := udiff.Unified(from, to, string(old), string(new))
|
||||
if d != "" && !strings.HasSuffix(d, "\n") {
|
||||
d += "\n"
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// DiffOps returns operations that turn from into to. It is used to record
|
||||
// how to undo an applied changeset and to turn an imported configuration
|
||||
// into a changeset. Create operations keep the object IDs of to.
|
||||
func DiffOps(from, to *model.State) []model.Op {
|
||||
var ops []model.Op
|
||||
add := func(kind string, id int64, base int, data any, summary string) {
|
||||
ops = append(ops, newOp(kind, id, base, data, summary))
|
||||
}
|
||||
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
|
||||
s := to.Settings
|
||||
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
|
||||
Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, NegativeTTL: &s.NegativeTTL, SerialPolicy: &s.SerialPolicy}, "restore settings")
|
||||
}
|
||||
|
||||
for _, k := range sortedKeys(to.Keys) {
|
||||
n := to.Keys[k]
|
||||
in := TSIGInput{Name: &n.Name, Algorithm: &n.Algorithm, Secret: &n.Secret, Comment: &n.Comment}
|
||||
if o, ok := from.Keys[k]; !ok {
|
||||
add(OpTSIGCreate, k, 0, in, "create TSIG key "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpTSIGUpdate, k, o.Version, in, "restore TSIG key "+n.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.ACLs) {
|
||||
n := to.ACLs[k]
|
||||
in := ACLInput{Name: &n.Name, Elements: model.Ptr(append([]string{}, n.Elements...)), Comment: &n.Comment}
|
||||
if o, ok := from.ACLs[k]; !ok {
|
||||
add(OpACLCreate, k, 0, in, "create ACL "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpACLUpdate, k, o.Version, in, "restore ACL "+n.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Views) {
|
||||
n := to.Views[k]
|
||||
in := ViewInput{Name: &n.Name, MatchClients: model.Ptr(append([]string{}, n.MatchClients...)),
|
||||
MatchDestinations: model.Ptr(append([]string{}, n.MatchDestinations...)), Comment: &n.Comment, Position: model.Ptr(n.Position)}
|
||||
if n.MatchRecursiveOnly != nil {
|
||||
in.MatchRecursiveOnly = model.Some(*n.MatchRecursiveOnly)
|
||||
} else {
|
||||
in.MatchRecursiveOnly = model.Opt[bool]{Set: true, Null: true}
|
||||
}
|
||||
if o, ok := from.Views[k]; !ok {
|
||||
add(OpViewCreate, k, 0, in, "create view "+n.Name)
|
||||
} else if !sameObject(o, n) {
|
||||
add(OpViewUpdate, k, o.Version, in, "restore view "+n.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Zones) {
|
||||
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}
|
||||
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}
|
||||
}
|
||||
if o, ok := from.Zones[k]; !ok {
|
||||
add(OpZoneCreate, k, 0, in, "create zone "+zoneDesc(to, n))
|
||||
} else if !sameObject(o, n) {
|
||||
in.Name = nil // zones can't be renamed
|
||||
if n.Type == model.ZonePrimary && o.SOA.Serial != s.Serial {
|
||||
// Never move a serial backwards: let the normal bump
|
||||
// happen instead.
|
||||
in.SOA.Serial = nil
|
||||
}
|
||||
add(OpZoneUpdate, k, o.Version, in, "restore zone "+zoneDesc(to, n))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Options) {
|
||||
n := to.Options[k]
|
||||
in := OptionInput{Scope: &n.Scope, ScopeID: &n.ScopeID, Name: &n.Name, Value: &n.Value, Comment: &n.Comment, Position: &n.Position}
|
||||
if o, ok := from.Options[k]; !ok {
|
||||
add(OpOptionCreate, k, 0, in, "set "+optionLabel(to, n)+" "+n.Value)
|
||||
} else if !sameObject(o, n) {
|
||||
in.Scope, in.ScopeID = nil, nil
|
||||
add(OpOptionUpdate, k, o.Version, in, "restore "+optionLabel(to, n))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(to.Records) {
|
||||
n := to.Records[k]
|
||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment}
|
||||
if n.TTL != nil {
|
||||
in.TTL = model.Some(*n.TTL)
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
if o, ok := from.Records[k]; !ok {
|
||||
add(OpRecordCreate, k, 0, in, fmt.Sprintf("add %s %s %s", n.Name, n.Type, n.Data))
|
||||
} else if !sameObject(o, n) {
|
||||
in.ZoneID = 0
|
||||
add(OpRecordUpdate, k, o.Version, in, fmt.Sprintf("restore %s %s %s", n.Name, n.Type, n.Data))
|
||||
}
|
||||
}
|
||||
// Deletions, most dependent objects first.
|
||||
for _, k := range sortedKeys(from.Records) {
|
||||
if _, ok := to.Records[k]; !ok {
|
||||
o := from.Records[k]
|
||||
// Records of zones that are deleted go with the zone.
|
||||
if _, zoneStays := to.Zones[o.ZoneID]; zoneStays {
|
||||
add(OpRecordDelete, k, o.Version, nil, fmt.Sprintf("delete %s %s %s", o.Name, o.Type, o.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Options) {
|
||||
if _, ok := to.Options[k]; !ok {
|
||||
o := from.Options[k]
|
||||
stays := true
|
||||
switch o.Scope {
|
||||
case model.ScopeZone:
|
||||
_, stays = to.Zones[o.ScopeID]
|
||||
case model.ScopeView:
|
||||
_, stays = to.Views[o.ScopeID]
|
||||
}
|
||||
if stays {
|
||||
add(OpOptionDelete, k, o.Version, nil, "delete "+optionLabel(from, o))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Zones) {
|
||||
if _, ok := to.Zones[k]; !ok {
|
||||
o := from.Zones[k]
|
||||
add(OpZoneDelete, k, o.Version, nil, "delete zone "+dnsname.Display(o.Name))
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Views) {
|
||||
if _, ok := to.Views[k]; !ok {
|
||||
o := from.Views[k]
|
||||
add(OpViewDelete, k, o.Version, DeleteInput{Cascade: true}, "delete view "+o.Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.ACLs) {
|
||||
if _, ok := to.ACLs[k]; !ok {
|
||||
add(OpACLDelete, k, from.ACLs[k].Version, nil, "delete ACL "+from.ACLs[k].Name)
|
||||
}
|
||||
}
|
||||
for _, k := range sortedKeys(from.Keys) {
|
||||
if _, ok := to.Keys[k]; !ok {
|
||||
add(OpTSIGDelete, k, from.Keys[k].Version, nil, "delete TSIG key "+from.Keys[k].Name)
|
||||
}
|
||||
}
|
||||
for i := range ops {
|
||||
ops[i].Seq = i + 1
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
func stripVersion(s model.Settings) model.Settings {
|
||||
s.Version = 0
|
||||
return s
|
||||
}
|
||||
|
||||
func sortedKeys[T any](m map[int64]*T) []int64 {
|
||||
keys := make([]int64, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
||||
return keys
|
||||
}
|
||||
|
||||
// sameObject compares two objects ignoring their meta data.
|
||||
func sameObject[T any](a, b *T) bool {
|
||||
ca, cb := *a, *b
|
||||
clearMeta(&ca)
|
||||
clearMeta(&cb)
|
||||
return reflect.DeepEqual(ca, cb)
|
||||
}
|
||||
|
||||
func clearMeta(v any) {
|
||||
rv := reflect.ValueOf(v).Elem()
|
||||
if f := rv.FieldByName("Meta"); f.IsValid() {
|
||||
f.Set(reflect.Zero(f.Type()))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,452 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
e *Engine
|
||||
st *store.Store
|
||||
admin *auth.Principal
|
||||
dir string
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
cfg := Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"),
|
||||
NoReload: true,
|
||||
SkipCheck: true,
|
||||
}
|
||||
e := New(st, nil, cfg, nil)
|
||||
e.Now = func() time.Time { return time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) }
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
if err := st.CreateUser(context.Background(), admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}
|
||||
// Defaults for new zones.
|
||||
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.com."}, RName: model.Ptr("hostmaster@example.com")}))
|
||||
return env
|
||||
}
|
||||
|
||||
func (env *testEnv) submit(p *auth.Principal, reqs ...Request) *Result {
|
||||
env.t.Helper()
|
||||
res, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
|
||||
if err != nil {
|
||||
env.t.Fatalf("submit %v: %v (%+v)", reqs[0].Op, err, res)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (env *testEnv) submitErr(p *auth.Principal, reqs ...Request) *Error {
|
||||
env.t.Helper()
|
||||
_, err := env.e.Submit(env.ctx, reqs, p, "test", "", ApplyOptions{})
|
||||
if err == nil {
|
||||
env.t.Fatalf("submit %v succeeded, want error", reqs[0].Op)
|
||||
}
|
||||
ee, ok := AsError(err)
|
||||
if !ok {
|
||||
env.t.Fatalf("unexpected error type %T: %v", err, err)
|
||||
}
|
||||
return ee
|
||||
}
|
||||
|
||||
func (env *testEnv) state() *model.State {
|
||||
s, err := env.st.LoadState(env.ctx)
|
||||
if err != nil {
|
||||
env.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func rec(zone, name, typ, data string) Request {
|
||||
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: zone, Name: &name, Type: &typ, Data: &data})
|
||||
}
|
||||
|
||||
func (env *testEnv) records(zone string) []string {
|
||||
s := env.state()
|
||||
zs := s.ZonesNamed(zone)
|
||||
var out []string
|
||||
for _, r := range s.ZoneRecords(zs[0].ID) {
|
||||
out = append(out, r.Name+" "+r.Type+" "+r.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestZoneLifecycle(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
res := env.submit(env.admin,
|
||||
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
|
||||
rec("example.com", "ns1", "A", "192.0.2.1"),
|
||||
rec("example.com", "www", "A", "192.0.2.10"),
|
||||
)
|
||||
if !res.Applied || len(res.Files) != 2 || !strings.HasSuffix(res.Files[0], "etc/named.conf") || !strings.HasSuffix(res.Files[1], "example.com.zone") {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
s := env.state()
|
||||
z := s.ZonesNamed("example.com")[0]
|
||||
if z.SOA.Serial != 2026100700 || z.SOA.MName != "ns1.example.com." || z.SOA.RName != "hostmaster.example.com." || z.DefaultTTL != 3600 {
|
||||
t.Errorf("zone defaults = %+v", z.SOA)
|
||||
}
|
||||
got := strings.Join(env.records("example.com"), "|")
|
||||
if got != "example.com. NS ns1.example.com.|ns1.example.com. A 192.0.2.1|www.example.com. A 192.0.2.10" {
|
||||
t.Errorf("records = %s", got)
|
||||
}
|
||||
zf, err := os.ReadFile(filepath.Join(env.dir, "zones/_default/example.com.zone"))
|
||||
if err != nil || !strings.Contains(string(zf), "www\t\tIN\tA\t192.0.2.10") {
|
||||
t.Errorf("zone file: %v\n%s", err, zf)
|
||||
}
|
||||
// A change bumps the serial once.
|
||||
env.submit(env.admin, rec("example.com", "mail", "A", "192.0.2.25"), rec("example.com", "@", "MX", "10 mail"))
|
||||
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
|
||||
t.Errorf("serial after change = %d", s)
|
||||
}
|
||||
// Comment-only changes don't.
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Comment: model.Ptr("web server")}))
|
||||
if s := env.state().ZonesNamed("example.com")[0].SOA.Serial; s != 2026100701 {
|
||||
t.Errorf("serial after comment change = %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
func findRec(s *model.State, name, typ string) *model.Record {
|
||||
for _, r := range s.Records {
|
||||
if r.Name == name && r.Type == typ {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupZone(env *testEnv) {
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com")}),
|
||||
rec("example.com", "ns1", "A", "192.0.2.1"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestConflictsRejected(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "CNAME", "ns1"))
|
||||
// The headline rule: no A record next to a CNAME.
|
||||
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if e.Kind != KindValidation || !strings.Contains(e.Message, "CNAME") {
|
||||
t.Errorf("error = %+v", e)
|
||||
}
|
||||
// Bad data is rejected before validation.
|
||||
e = env.submitErr(env.admin, rec("example.com", "bad", "A", "300.1.1.1"))
|
||||
if e.Kind != KindBadRequest {
|
||||
t.Errorf("bad data error = %+v", e)
|
||||
}
|
||||
// Swapping CNAME for A works when both happen in one changeset.
|
||||
r := findRec(env.state(), "www.example.com.", "CNAME")
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if findRec(env.state(), "www.example.com.", "A") == nil {
|
||||
t.Error("swap failed")
|
||||
}
|
||||
// Nothing is stored when a submit fails.
|
||||
cs, _ := env.st.ListChangesets(env.ctx, store.ChangesetFilter{})
|
||||
for _, c := range cs {
|
||||
if c.Status != model.ChangesetApplied {
|
||||
t.Errorf("unexpected changeset %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStagedChangeset(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
cs, err := env.e.CreateChangeset(env.ctx, env.admin, "web", "add web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "CNAME", "ns1")}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Staging a conflicting record is rejected right away.
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "www", "TXT", "hello")}, env.admin); err == nil {
|
||||
t.Error("conflicting record staged")
|
||||
}
|
||||
prev, err := env.e.Preview(env.ctx, cs.ID, env.admin)
|
||||
if err != nil || !prev.Valid || !strings.Contains(prev.Diff, "+www\t\tIN\tCNAME\tns1.example.com.") {
|
||||
t.Fatalf("preview = %+v %v", prev, err)
|
||||
}
|
||||
if findRec(env.state(), "www.example.com.", "CNAME") != nil {
|
||||
t.Error("preview changed the state")
|
||||
}
|
||||
// Someone else changes the configuration meanwhile.
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err == nil {
|
||||
t.Fatal("stale changeset applied")
|
||||
}
|
||||
// Staging the delete of the conflicting A record fixes the
|
||||
// changeset (it removes an error rather than adding one).
|
||||
a := findRec(env.state(), "www.example.com.", "A")
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordDelete, a.ID, nil)}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if err != nil || !res.Applied {
|
||||
t.Fatalf("apply: %v %+v", err, res)
|
||||
}
|
||||
got, _ := env.st.GetChangeset(env.ctx, cs.ID)
|
||||
if got.Status != model.ChangesetApplied || got.Diff == "" {
|
||||
t.Errorf("changeset = %+v", got)
|
||||
}
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{rec("example.com", "x", "A", "192.0.2.3")}, env.admin); err == nil {
|
||||
t.Error("staged into applied changeset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionConflict(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
cs, _ := env.e.CreateChangeset(env.ctx, env.admin, "web", "")
|
||||
if _, err := env.e.Stage(env.ctx, cs.ID, []Request{NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")})}, env.admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.12")}))
|
||||
_, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{})
|
||||
if ee, ok := AsError(err); !ok || ee.Kind != KindConflict {
|
||||
t.Errorf("expected version conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
g, _ := auth.ParseGrant("records:write zones=example.com names=_acme-challenge.* types=TXT")
|
||||
u := &model.User{Username: "acme", Grants: []model.Grant{g}}
|
||||
env.st.CreateUser(env.ctx, u)
|
||||
p := &auth.Principal{User: u, Method: "token"}
|
||||
env.submit(p, rec("example.com", "_acme-challenge.www", "TXT", "token123"))
|
||||
if e := env.submitErr(p, rec("example.com", "www", "A", "192.0.2.66")); e.Kind != KindForbidden {
|
||||
t.Errorf("A record allowed: %+v", e)
|
||||
}
|
||||
if e := env.submitErr(p, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("evil.com")})); e.Kind != KindForbidden {
|
||||
t.Errorf("zone create allowed: %+v", e)
|
||||
}
|
||||
// ACME cleanup: delete by match, idempotent.
|
||||
del := NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123")})
|
||||
env.submit(p, del)
|
||||
if findRec(env.state(), "_acme-challenge.www.example.com.", "TXT") != nil {
|
||||
t.Error("challenge not deleted")
|
||||
}
|
||||
e := env.submitErr(p, del)
|
||||
if e.Kind != KindNotFound {
|
||||
t.Errorf("second delete = %+v", e)
|
||||
}
|
||||
env.submit(p, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", Name: model.Ptr("_acme-challenge.www"), Type: model.Ptr("TXT"), Data: model.Ptr("token123"), MissingOK: true}))
|
||||
}
|
||||
|
||||
func TestPTRAndRRSet(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.0/24")}))
|
||||
in := RecordInput{Zone: "example.com", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.20"), PTR: true}
|
||||
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, in))
|
||||
if len(res.Ops) != 2 || res.Ops[1].Summary != "add 20.2.0.192.in-addr.arpa. IN PTR nas.example.com." {
|
||||
t.Fatalf("ops = %+v", res.Ops)
|
||||
}
|
||||
ptr := findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR")
|
||||
if ptr == nil || ptr.Data != "nas.example.com." {
|
||||
t.Fatalf("PTR = %+v", ptr)
|
||||
}
|
||||
// Changing the address moves the PTR.
|
||||
a := findRec(env.state(), "nas.example.com.", "A")
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, a.ID, RecordInput{Data: model.Ptr("192.0.2.21"), PTR: true}))
|
||||
if findRec(env.state(), "20.2.0.192.in-addr.arpa.", "PTR") != nil || findRec(env.state(), "21.2.0.192.in-addr.arpa.", "PTR") == nil {
|
||||
t.Error("PTR not moved")
|
||||
}
|
||||
// RRset replace keeps matching records and sets the TTL on all.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", TTL: model.Some(uint32(60)), Data: []string{"192.0.2.30", "192.0.2.31"}}))
|
||||
keep := findRecData(env.state(), "api.example.com.", "192.0.2.30")
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", Name: "api", Type: "A", Data: []string{"192.0.2.30", "192.0.2.32"}}))
|
||||
s := env.state()
|
||||
if findRecData(s, "api.example.com.", "192.0.2.31") != nil || findRecData(s, "api.example.com.", "192.0.2.32") == nil {
|
||||
t.Error("rrset replace wrong")
|
||||
}
|
||||
if k := findRecData(s, "api.example.com.", "192.0.2.30"); k == nil || k.ID != keep.ID || *k.TTL != 60 || *findRecData(s, "api.example.com.", "192.0.2.32").TTL != 60 {
|
||||
t.Error("rrset replace did not keep record or TTL")
|
||||
}
|
||||
// A new record joining an RRset inherits its TTL.
|
||||
env.submit(env.admin, rec("example.com", "api", "A", "192.0.2.33"))
|
||||
if r := findRecData(env.state(), "api.example.com.", "192.0.2.33"); r.TTL == nil || *r.TTL != 60 {
|
||||
t.Error("TTL not inherited")
|
||||
}
|
||||
// A conflicting TTL is rejected.
|
||||
ttl := RecordInput{Zone: "example.com", Name: model.Ptr("api"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.34"), TTL: model.Some(uint32(300))}
|
||||
if e := env.submitErr(env.admin, NewRequest(OpRecordCreate, 0, ttl)); !strings.Contains(e.Message, "same TTL") {
|
||||
t.Errorf("TTL mismatch error = %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func findRecData(s *model.State, name, data string) *model.Record {
|
||||
for _, r := range s.Records {
|
||||
if r.Name == name && r.Data == data {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevert(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
before := env.records("example.com")
|
||||
r := findRec(env.state(), "www.example.com.", "A")
|
||||
res := env.submit(env.admin, NewRequest(OpRecordDelete, r.ID, nil), rec("example.com", "web", "A", "192.0.2.11"))
|
||||
rev, err := env.e.Revert(env.ctx, res.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(env.records("example.com"), "|"); got != strings.Join(before, "|") {
|
||||
t.Errorf("after revert %s, want %s", got, strings.Join(before, "|"))
|
||||
}
|
||||
// Reverting a zone deletion restores records and options.
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: &z.ID, Name: model.Ptr("notify"), Value: model.Ptr("no")}))
|
||||
del := env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if len(env.state().Zones) != 0 {
|
||||
t.Fatal("zone not deleted")
|
||||
}
|
||||
rev, _ = env.e.Revert(env.ctx, del.ChangesetID, env.admin)
|
||||
if _, err := env.e.Apply(env.ctx, rev.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := env.state()
|
||||
if len(s.Zones) != 1 || len(s.Records) != 3 || s.FindOption(model.ScopeZone, z.ID, "notify") == nil {
|
||||
t.Errorf("zone not restored: %d zones, %d records", len(s.Zones), len(s.Records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsAndACLs(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
env.submit(env.admin, NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("lan"), Elements: &[]string{"192.168.0.0/16", "10/8"}}))
|
||||
a := env.state().ACLByName("lan")
|
||||
if strings.Join(a.Elements, ",") != "192.168.0.0/16,10.0.0.0/8" {
|
||||
t.Errorf("elements = %v", a.Elements)
|
||||
}
|
||||
// Creating a view moves existing zones into it.
|
||||
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"lan", "localhost"}, AdoptZones: true}))
|
||||
s := env.state()
|
||||
v := s.ViewByName("internal")
|
||||
if s.ZonesNamed("example.com")[0].ViewID != v.ID {
|
||||
t.Error("zone not adopted into view")
|
||||
}
|
||||
// A view without adopt_zones and zones outside views is rejected.
|
||||
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||
// Clone the zone into the external view.
|
||||
z := env.state().ZonesNamed("example.com")[0]
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), CloneFrom: z.ID}))
|
||||
if zs := env.state().ZonesNamed("example.com"); len(zs) != 2 {
|
||||
t.Fatalf("clone failed: %d zones", len(zs))
|
||||
}
|
||||
// Records now need a view to say which zone.
|
||||
if e := env.submitErr(env.admin, rec("example.com", "x", "A", "192.0.2.5")); !strings.Contains(e.Message, "several views") {
|
||||
t.Errorf("ambiguous zone error = %+v", e)
|
||||
}
|
||||
// Renaming an ACL rewrites references.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &v.ID, Name: model.Ptr("allow-recursion"), Value: model.Ptr("{ lan; }")}))
|
||||
env.submit(env.admin, NewRequest(OpACLUpdate, a.ID, ACLInput{Name: model.Ptr("home")}))
|
||||
s = env.state()
|
||||
if strings.Join(s.ViewByName("internal").MatchClients, ",") != "home,localhost" || s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value != "{ home; }" {
|
||||
t.Errorf("references not renamed: %v %q", s.ViewByName("internal").MatchClients, s.FindOption(model.ScopeView, v.ID, "allow-recursion").Value)
|
||||
}
|
||||
// Deleting an ACL in use is refused with the users listed.
|
||||
e := env.submitErr(env.admin, NewRequest(OpACLDelete, a.ID, nil))
|
||||
if !strings.Contains(e.Message, "view internal") || !strings.Contains(e.Message, "allow-recursion") {
|
||||
t.Errorf("delete in-use ACL error = %+v", e)
|
||||
}
|
||||
// Invalid options are rejected with the catalog's help.
|
||||
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("recursion"), Value: model.Ptr("maybe")}))
|
||||
if !strings.Contains(e.Message, "boolean") {
|
||||
t.Errorf("bad option error = %+v", e)
|
||||
}
|
||||
e = env.submitErr(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("match-clients"), Value: model.Ptr("{ any; }")}))
|
||||
if e.Kind != KindBadRequest {
|
||||
t.Errorf("option in wrong scope = %+v", e)
|
||||
}
|
||||
// String options get quoted automatically.
|
||||
env.submit(env.admin, NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeOptions), Name: model.Ptr("directory"), Value: model.Ptr("/var/cache/bind")}))
|
||||
if o := env.state().FindOption(model.ScopeOptions, 0, "directory"); o.Value != `"/var/cache/bind"` {
|
||||
t.Errorf("directory = %q", o.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTSIGAndDrift(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
setupZone(env)
|
||||
res := env.submit(env.admin, NewRequest(OpTSIGCreate, 0, TSIGInput{Name: model.Ptr("xfer")}))
|
||||
if strings.Contains(string(res.Ops[0].Data), `"secret":"`) && !strings.Contains(string(res.Ops[0].Data), "(hidden)") {
|
||||
t.Errorf("secret not redacted: %s", res.Ops[0].Data)
|
||||
}
|
||||
k := env.state().KeyByName("xfer")
|
||||
if k == nil || len(k.Secret) != 44 {
|
||||
t.Fatalf("key = %+v", k)
|
||||
}
|
||||
if !strings.Contains(res.Diff, "contents hidden") || strings.Contains(res.Diff, k.Secret) {
|
||||
t.Errorf("secret in diff:\n%s", res.Diff)
|
||||
}
|
||||
// Edit a managed file behind Jarl's back.
|
||||
zf := filepath.Join(env.dir, "zones/_default/example.com.zone")
|
||||
os.WriteFile(zf, []byte("hand edited\n"), 0o640)
|
||||
e := env.submitErr(env.admin, rec("example.com", "www", "A", "192.0.2.10"))
|
||||
if e.Kind != KindDrift {
|
||||
t.Errorf("drift not detected: %+v", e)
|
||||
}
|
||||
if _, err := env.e.Submit(env.ctx, []Request{rec("example.com", "www", "A", "192.0.2.10")}, env.admin, "test", "", ApplyOptions{Force: true}); err != nil {
|
||||
t.Errorf("forced apply: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(zf); strings.Contains(string(b), "hand edited") {
|
||||
t.Error("drifted file not overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneCreateWithOptions(t *testing.T) {
|
||||
env := newEnv(t)
|
||||
// A secondary zone is only valid together with its primaries option.
|
||||
e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave")}))
|
||||
if e.Kind != KindValidation || !strings.Contains(e.Message, "primaries") {
|
||||
t.Errorf("secondary without primaries = %+v", e)
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("partner.example"), Type: model.Ptr("slave"),
|
||||
Options: []ZoneOption{{Name: "masters", Value: "{ 192.0.2.53; }"}}}))
|
||||
s := env.state()
|
||||
z := s.ZonesNamed("partner.example")[0]
|
||||
if z.Type != model.ZoneSecondary {
|
||||
t.Errorf("type = %s", z.Type)
|
||||
}
|
||||
o := s.FindOption(model.ScopeZone, z.ID, "primaries")
|
||||
if o == nil || o.Value != "{ 192.0.2.53; }" {
|
||||
t.Errorf("primaries option = %+v (the masters synonym should be normalized)", o)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"jarl/internal/bind"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// ErrorKind classifies engine errors (the API maps them to HTTP status
|
||||
// codes).
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
KindBadRequest ErrorKind = "bad_request" // malformed or invalid input
|
||||
KindForbidden ErrorKind = "forbidden" // permission denied
|
||||
KindNotFound ErrorKind = "not_found" // object does not exist
|
||||
KindConflict ErrorKind = "conflict" // concurrent change, wrong state
|
||||
KindValidation ErrorKind = "validation_failed" // the resulting config has errors
|
||||
KindBIND ErrorKind = "bind_failed" // named-checkconf or rndc failed
|
||||
KindDrift ErrorKind = "drift" // files were changed outside Jarl
|
||||
KindNotAdopted ErrorKind = "not_adopted" // named does not load Jarl's config yet
|
||||
)
|
||||
|
||||
// Error is an engine error with details for the caller.
|
||||
type Error struct {
|
||||
Kind ErrorKind `json:"code"`
|
||||
Message string `json:"message"`
|
||||
OpSeq int `json:"op,omitempty"` // the operation that failed
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
Check []CheckMessage `json:"check,omitempty"`
|
||||
Diff string `json:"diff,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.OpSeq > 0 {
|
||||
return fmt.Sprintf("operation %d: %s", e.OpSeq, e.Message)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func errf(kind ErrorKind, format string, args ...any) *Error {
|
||||
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// AsError returns err as *Error, if it is one.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
var e *Error
|
||||
ok := errors.As(err, &e)
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// CheckMessage is a named-checkconf message mapped back to the Jarl object
|
||||
// that produced the offending line.
|
||||
type CheckMessage struct {
|
||||
bind.Message
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
ObjectID int64 `json:"object_id,omitempty"`
|
||||
Object string `json:"object,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/rr"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// IDAllocator reserves object IDs.
|
||||
type IDAllocator func(ctx context.Context, n int) ([]int64, error)
|
||||
|
||||
type expander struct {
|
||||
ctx context.Context
|
||||
st *model.State // working state: base plus the changeset's ops so far
|
||||
alloc IDAllocator
|
||||
p *auth.Principal
|
||||
now time.Time
|
||||
warnings []string
|
||||
}
|
||||
|
||||
func (x *expander) ids(n int) ([]int64, error) {
|
||||
ids, err := x.alloc(x.ctx, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("allocating IDs: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func newOp(kind string, id int64, base int, data any, summary string) model.Op {
|
||||
op := model.Op{Kind: kind, ID: id, BaseVersion: base, Summary: summary}
|
||||
if data != nil {
|
||||
op.Data = mustJSON(data)
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
// resolveZone finds the zone a record request refers to.
|
||||
func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, error) {
|
||||
if zoneID != 0 {
|
||||
z := x.st.Zones[zoneID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d does not exist", zoneID)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
if zone == "" {
|
||||
return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)")
|
||||
}
|
||||
return FindZoneRef(x.st, zone, view)
|
||||
}
|
||||
|
||||
// FindZoneRef finds a zone by name and optional view name.
|
||||
func FindZoneRef(st *model.State, zone, view string) (*model.Zone, error) {
|
||||
name, err := ParseZoneName(zone)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
if view != "" {
|
||||
v := st.ViewByName(view)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", view)
|
||||
}
|
||||
z := st.FindZone(name, v.ID)
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %s does not exist in view %s", dnsname.Display(name), v.Name)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
zs := st.ZonesNamed(name)
|
||||
switch len(zs) {
|
||||
case 0:
|
||||
return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name))
|
||||
case 1:
|
||||
return zs[0], nil
|
||||
}
|
||||
var views []string
|
||||
for _, z := range zs {
|
||||
views = append(views, st.ViewName(z))
|
||||
}
|
||||
return nil, errf(KindBadRequest, "zone %s exists in several views (%s); say which view", dnsname.Display(name), strings.Join(views, ", "))
|
||||
}
|
||||
|
||||
func zoneDesc(st *model.State, z *model.Zone) string {
|
||||
s := dnsname.Display(z.Name)
|
||||
if v := st.ViewName(z); v != "" {
|
||||
s += " [" + v + "]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ttlText(o model.Opt[uint32]) string {
|
||||
if o.Set && !o.Null {
|
||||
return fmt.Sprintf(" %d", o.Value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// InitialSerial returns the first serial for a new zone.
|
||||
func InitialSerial(policy string, now time.Time) uint32 {
|
||||
switch policy {
|
||||
case model.SerialIncrement:
|
||||
return 1
|
||||
case model.SerialUnixtime:
|
||||
return uint32(now.Unix())
|
||||
}
|
||||
return dateSerial(now)
|
||||
}
|
||||
|
||||
func dateSerial(now time.Time) uint32 {
|
||||
y, m, d := now.UTC().Date()
|
||||
return uint32(y*1000000 + int(m)*10000 + d*100)
|
||||
}
|
||||
|
||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
switch req.Op {
|
||||
case OpZoneCreate:
|
||||
return x.zoneCreate(req)
|
||||
case OpZoneUpdate, OpZoneDelete:
|
||||
z := x.st.Zones[req.ID]
|
||||
if z == nil {
|
||||
return nil, errf(KindNotFound, "zone %d does not exist", req.ID)
|
||||
}
|
||||
verb := "update"
|
||||
if req.Op == OpZoneDelete {
|
||||
verb = "delete"
|
||||
if n := len(x.st.ZoneRecords(z.ID)); n > 0 {
|
||||
verb += fmt.Sprintf(" (with its %d records)", n)
|
||||
}
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
||||
case OpRecordCreate:
|
||||
return x.recordCreate(req)
|
||||
case OpRecordUpdate:
|
||||
return x.recordUpdate(req)
|
||||
case OpRecordDelete:
|
||||
return x.recordDelete(req)
|
||||
case OpRRSetReplace:
|
||||
return x.rrsetReplace(req)
|
||||
case OpViewCreate:
|
||||
return x.viewCreate(req)
|
||||
case OpViewUpdate, OpViewDelete:
|
||||
v := x.st.Views[req.ID]
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %d does not exist", req.ID)
|
||||
}
|
||||
return []model.Op{x.versioned(req, v.Version, strings.TrimPrefix(req.Op, "view.")+" view "+v.Name)}, nil
|
||||
case OpViewReorder:
|
||||
var in ReorderInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, id := range in.IDs {
|
||||
if v := x.st.Views[id]; v != nil {
|
||||
names = append(names, v.Name)
|
||||
}
|
||||
}
|
||||
return []model.Op{newOp(OpViewReorder, 0, 0, in, "reorder views: "+strings.Join(names, ", "))}, nil
|
||||
case OpACLCreate:
|
||||
return x.simpleCreate(req, OpACLCreate, func(data json.RawMessage) (string, error) {
|
||||
var in ACLInput
|
||||
if err := decode(data, &in); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if in.Name == nil {
|
||||
return "", errf(KindBadRequest, "an ACL needs a name")
|
||||
}
|
||||
return "create ACL " + *in.Name, nil
|
||||
})
|
||||
case OpACLUpdate, OpACLDelete:
|
||||
a := x.st.ACLs[req.ID]
|
||||
if a == nil {
|
||||
return nil, errf(KindNotFound, "ACL %d does not exist", req.ID)
|
||||
}
|
||||
return []model.Op{x.versioned(req, a.Version, strings.TrimPrefix(req.Op, "acl.")+" ACL "+a.Name)}, nil
|
||||
case OpTSIGCreate:
|
||||
return x.tsigCreate(req)
|
||||
case OpTSIGUpdate, OpTSIGDelete:
|
||||
k := x.st.Keys[req.ID]
|
||||
if k == nil {
|
||||
return nil, errf(KindNotFound, "TSIG key %d does not exist", req.ID)
|
||||
}
|
||||
return []model.Op{x.versioned(req, k.Version, strings.TrimPrefix(req.Op, "tsig.")+" TSIG key "+k.Name)}, nil
|
||||
case OpOptionCreate:
|
||||
return x.optionCreate(req)
|
||||
case OpOptionUpdate, OpOptionDelete:
|
||||
o := x.st.Options[req.ID]
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "option %d does not exist", req.ID)
|
||||
}
|
||||
summary := strings.TrimPrefix(req.Op, "option.") + " " + optionLabel(x.st, o)
|
||||
if req.Op == OpOptionUpdate {
|
||||
var in OptionInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Value != nil {
|
||||
summary += ": " + o.Value + " → " + *in.Value
|
||||
}
|
||||
}
|
||||
return []model.Op{x.versioned(req, o.Version, summary)}, nil
|
||||
case OpSettingsUpdate:
|
||||
return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil
|
||||
}
|
||||
return nil, errf(KindBadRequest, "unknown operation %q", req.Op)
|
||||
}
|
||||
|
||||
func newOpRaw(kind string, id int64, base int, data json.RawMessage, summary string) model.Op {
|
||||
return model.Op{Kind: kind, ID: id, BaseVersion: base, Data: data, Summary: summary}
|
||||
}
|
||||
|
||||
// versioned builds an update/delete operation, recording the object's
|
||||
// version for conflict detection.
|
||||
func (x *expander) versioned(req Request, current int, summary string) model.Op {
|
||||
base := req.BaseVersion
|
||||
if base == 0 {
|
||||
base = current
|
||||
}
|
||||
return newOpRaw(req.Op, req.ID, base, req.Data, summary)
|
||||
}
|
||||
|
||||
func (x *expander) simpleCreate(req Request, kind string, describe func(json.RawMessage) (string, error)) ([]model.Op, error) {
|
||||
summary, err := describe(req.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []model.Op{newOpRaw(kind, ids[0], 0, req.Data, summary)}, nil
|
||||
}
|
||||
|
||||
func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
var in ZoneInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
|
||||
return nil, errf(KindBadRequest, "a zone needs a name")
|
||||
}
|
||||
name, err := ParseZoneName(*in.Name)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
in.Name = &name
|
||||
if in.Type == nil {
|
||||
in.Type = model.Ptr(model.ZonePrimary)
|
||||
}
|
||||
zt, err := normalizeZoneType(*in.Type)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
in.Type = &zt
|
||||
if in.View != nil {
|
||||
if *in.View != "" {
|
||||
v := x.st.ViewByName(*in.View)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", *in.View)
|
||||
}
|
||||
in.ViewID = &v.ID
|
||||
}
|
||||
in.View = nil
|
||||
}
|
||||
nameservers := in.Nameservers
|
||||
records := in.Records
|
||||
cloneFrom := in.CloneFrom
|
||||
zoneOpts := in.Options
|
||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
||||
|
||||
set := x.st.Settings
|
||||
var src *model.Zone
|
||||
if cloneFrom != 0 {
|
||||
src = x.st.Zones[cloneFrom]
|
||||
if src == nil {
|
||||
return nil, errf(KindNotFound, "zone %d (clone_from) does not exist", cloneFrom)
|
||||
}
|
||||
if src.Name != name {
|
||||
return nil, errf(KindBadRequest, "clone_from must be a zone with the same name in another view (it is %s)", dnsname.Display(src.Name))
|
||||
}
|
||||
if !x.p.Can(zoneAction(x.st, src, false)) || !x.p.Can(recordAction(x.st, src, "", "", false)) {
|
||||
return nil, errf(KindForbidden, "permission denied: you can't read zone %s", zoneDesc(x.st, src))
|
||||
}
|
||||
}
|
||||
if zt == model.ZonePrimary {
|
||||
if len(nameservers) == 0 && src == nil {
|
||||
nameservers = set.Nameservers
|
||||
}
|
||||
if len(nameservers) == 0 && src == nil {
|
||||
return nil, errf(KindBadRequest, "a primary zone needs at least one name server: give nameservers, or set default name servers in the settings")
|
||||
}
|
||||
if in.SOA == nil {
|
||||
in.SOA = &SOAInput{}
|
||||
}
|
||||
s := in.SOA
|
||||
if src != nil {
|
||||
// Copy the source zone's SOA and TTL unless overridden.
|
||||
if s.MName == nil {
|
||||
s.MName = model.Ptr(src.SOA.MName)
|
||||
}
|
||||
if s.RName == nil {
|
||||
s.RName = model.Ptr(src.SOA.RName)
|
||||
}
|
||||
if s.Refresh == nil {
|
||||
s.Refresh = model.Ptr(src.SOA.Refresh)
|
||||
}
|
||||
if s.Retry == nil {
|
||||
s.Retry = model.Ptr(src.SOA.Retry)
|
||||
}
|
||||
if s.Expire == nil {
|
||||
s.Expire = model.Ptr(src.SOA.Expire)
|
||||
}
|
||||
if s.Minimum == nil {
|
||||
s.Minimum = model.Ptr(src.SOA.Minimum)
|
||||
}
|
||||
if in.DefaultTTL == nil {
|
||||
in.DefaultTTL = model.Ptr(src.DefaultTTL)
|
||||
}
|
||||
}
|
||||
if s.MName == nil {
|
||||
first, err := dnsname.Owner(nameservers[0], name)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
s.MName = &first
|
||||
}
|
||||
if s.RName == nil {
|
||||
if set.RName != "" {
|
||||
s.RName = model.Ptr(set.RName)
|
||||
} else {
|
||||
s.RName = model.Ptr("hostmaster." + name)
|
||||
}
|
||||
}
|
||||
if s.Refresh == nil {
|
||||
s.Refresh = model.Ptr(set.Refresh)
|
||||
}
|
||||
if s.Retry == nil {
|
||||
s.Retry = model.Ptr(set.Retry)
|
||||
}
|
||||
if s.Expire == nil {
|
||||
s.Expire = model.Ptr(set.Expire)
|
||||
}
|
||||
if s.Minimum == nil {
|
||||
s.Minimum = model.Ptr(set.NegativeTTL)
|
||||
}
|
||||
if in.DefaultTTL == nil {
|
||||
in.DefaultTTL = model.Ptr(set.DefaultTTL)
|
||||
}
|
||||
if s.Serial == nil {
|
||||
policy := set.SerialPolicy
|
||||
if in.SerialPolicy != nil && *in.SerialPolicy != "" {
|
||||
policy = *in.SerialPolicy
|
||||
}
|
||||
serial := InitialSerial(policy, x.now)
|
||||
if src != nil && serialGreater(src.SOA.Serial, serial) {
|
||||
serial = src.SOA.Serial
|
||||
}
|
||||
s.Serial = &serial
|
||||
}
|
||||
} else if len(nameservers) > 0 || len(records) > 0 || src != nil {
|
||||
return nil, errf(KindBadRequest, "nameservers, records and clone_from only apply to primary zones")
|
||||
}
|
||||
|
||||
type recSpec struct {
|
||||
in RecordInput
|
||||
summary string
|
||||
}
|
||||
var recs []recSpec
|
||||
seen := map[string]bool{}
|
||||
addRec := func(ri RecordInput) error {
|
||||
tmp := &model.Zone{Name: name, Type: zt}
|
||||
owner, p, err := parseRecord(tmp, deref(ri.Name), deref(ri.Type), deref(ri.Data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data)
|
||||
if seen[k] {
|
||||
return nil
|
||||
}
|
||||
seen[k] = true
|
||||
ri.Name, ri.Type, ri.Data = &owner, &p.Type, &p.Data
|
||||
recs = append(recs, recSpec{ri, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(ri.TTL), p.Type, p.Data)})
|
||||
return nil
|
||||
}
|
||||
for _, ns := range nameservers {
|
||||
target, err := dnsname.Owner(ns, name)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "name server %q: %v", ns, err)
|
||||
}
|
||||
if err := addRec(RecordInput{Name: model.Ptr("@"), Type: model.Ptr("NS"), Data: model.Ptr(target)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if src != nil {
|
||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||
if r.TTL != nil {
|
||||
ri.TTL = model.Some(*r.TTL)
|
||||
} else {
|
||||
ri.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
if err := addRec(ri); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, ri := range records {
|
||||
ri.ZoneID, ri.Zone, ri.View = 0, "", ""
|
||||
if err := addRec(ri); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ids, err := x.ids(1 + len(recs) + len(zoneOpts))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
summary := fmt.Sprintf("create %s zone %s", zt, dnsname.Display(name))
|
||||
if in.ViewID != nil && *in.ViewID != 0 {
|
||||
if v := x.st.Views[*in.ViewID]; v != nil {
|
||||
summary += " in view " + v.Name
|
||||
}
|
||||
}
|
||||
if src != nil {
|
||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||
}
|
||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
||||
for i, zo := range zoneOpts {
|
||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||
if zo.Comment != "" {
|
||||
oi.Comment = model.Ptr(zo.Comment)
|
||||
}
|
||||
ops = append(ops, newOp(OpOptionCreate, ids[1+len(recs)+i], 0, oi, fmt.Sprintf("set zone %s option %s %s", dnsname.Display(name), zo.Name, zo.Value)))
|
||||
}
|
||||
for i, r := range recs {
|
||||
r.in.ZoneID = ids[0]
|
||||
ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary))
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// reverseZoneFor finds the primary reverse zone for addr in a view.
|
||||
func reverseZoneFor(st *model.State, viewID int64, addr netip.Addr) (*model.Zone, string) {
|
||||
name := dnsname.ReverseName(addr)
|
||||
var best *model.Zone
|
||||
for _, z := range st.ZonesInView(viewID) {
|
||||
if z.Type == model.ZonePrimary && dnsname.IsSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
return best, name
|
||||
}
|
||||
|
||||
func addrOf(p *rr.Parsed) (netip.Addr, bool) {
|
||||
switch v := p.RR.(type) {
|
||||
case *dns.A:
|
||||
a, ok := netip.AddrFromSlice(v.A.To4())
|
||||
return a, ok
|
||||
case *dns.AAAA:
|
||||
a, ok := netip.AddrFromSlice(v.AAAA)
|
||||
return a, ok
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// ptrOps returns operations creating the PTR record for an address record.
|
||||
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added", addr))
|
||||
return nil, nil
|
||||
}
|
||||
for _, r := range x.st.ZoneRecords(rz.ID) {
|
||||
if r.Name == ptrName && r.Type == "PTR" {
|
||||
if strings.EqualFold(r.Data, owner) {
|
||||
return nil, nil // already there
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
|
||||
}
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)}
|
||||
return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil
|
||||
}
|
||||
|
||||
func (x *expander) ptrDelete(z *model.Zone, owner string, p *rr.Parsed) []model.Op {
|
||||
addr, ok := addrOf(p)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rz, ptrName := reverseZoneFor(x.st, z.ViewID, addr)
|
||||
if rz == nil {
|
||||
return nil
|
||||
}
|
||||
in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner), MissingOK: true}
|
||||
return []model.Op{newOp(OpRecordDelete, 0, 0, in, fmt.Sprintf("delete %s IN PTR %s (if present)", ptrName, owner))}
|
||||
}
|
||||
|
||||
func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
var in RecordInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil || in.Type == nil || in.Data == nil {
|
||||
return nil, errf(KindBadRequest, "a record needs a name, a type and data")
|
||||
}
|
||||
owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr := in.PTR
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
||||
if ptr {
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
r := x.st.Records[req.ID]
|
||||
if r == nil {
|
||||
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
|
||||
}
|
||||
z := x.st.Zones[r.ZoneID]
|
||||
var in RecordInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptr := in.PTR
|
||||
in.PTR = false
|
||||
in.Zone, in.View = "", ""
|
||||
name, typ, data := r.Name, r.Type, r.Data
|
||||
if in.Name != nil {
|
||||
name = *in.Name
|
||||
}
|
||||
if in.Type != nil {
|
||||
typ = *in.Type
|
||||
}
|
||||
if in.Data != nil {
|
||||
data = *in.Data
|
||||
}
|
||||
owner, p, err := parseRecord(z, name, typ, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name != nil {
|
||||
in.Name = &owner
|
||||
}
|
||||
if in.Type != nil {
|
||||
in.Type = &p.Type
|
||||
}
|
||||
if in.Data != nil {
|
||||
in.Data = &p.Data
|
||||
}
|
||||
var changes []string
|
||||
if owner != r.Name {
|
||||
changes = append(changes, "name "+r.Name+" → "+owner)
|
||||
}
|
||||
if p.Type != r.Type {
|
||||
changes = append(changes, "type "+r.Type+" → "+p.Type)
|
||||
}
|
||||
if p.Data != r.Data {
|
||||
changes = append(changes, "data "+r.Data+" → "+p.Data)
|
||||
}
|
||||
if in.TTL.Set {
|
||||
old := "default"
|
||||
if r.TTL != nil {
|
||||
old = fmt.Sprint(*r.TTL)
|
||||
}
|
||||
nw := "default"
|
||||
if !in.TTL.Null {
|
||||
nw = fmt.Sprint(in.TTL.Value)
|
||||
}
|
||||
if old != nw {
|
||||
changes = append(changes, "ttl "+old+" → "+nw)
|
||||
}
|
||||
}
|
||||
if in.Comment != nil && *in.Comment != r.Comment {
|
||||
changes = append(changes, "comment")
|
||||
}
|
||||
summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data)
|
||||
if len(changes) > 0 {
|
||||
summary += ": " + strings.Join(changes, ", ")
|
||||
}
|
||||
base := req.BaseVersion
|
||||
if base == 0 {
|
||||
base = r.Version
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
||||
if ptr && (p.Data != r.Data || owner != r.Name) {
|
||||
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
|
||||
}
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) recordDelete(req Request) ([]model.Op, error) {
|
||||
var in RecordInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.ID != 0 {
|
||||
r := x.st.Records[req.ID]
|
||||
if r == nil {
|
||||
if in.MissingOK {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errf(KindNotFound, "record %d does not exist", req.ID)
|
||||
}
|
||||
base := req.BaseVersion
|
||||
if base == 0 {
|
||||
base = r.Version
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
|
||||
if in.PTR {
|
||||
z := x.st.Zones[r.ZoneID]
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil || in.Type == nil {
|
||||
return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)")
|
||||
}
|
||||
owner, err := dnsname.Owner(*in.Name, z.Name)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
typ, _, err := rr.NormalizeType(*in.Type)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
what := owner + " " + typ
|
||||
var parsed *rr.Parsed
|
||||
if in.Data != nil && strings.TrimSpace(*in.Data) != "" {
|
||||
_, p, err := parseRecord(z, owner, typ, *in.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Data = &p.Data
|
||||
what += " " + p.Data
|
||||
parsed = p
|
||||
} else {
|
||||
in.Data = nil
|
||||
what += " (all)"
|
||||
}
|
||||
if in.MissingOK {
|
||||
what += " (if present)"
|
||||
}
|
||||
ptr := in.PTR
|
||||
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
|
||||
in.Name, in.Type = &owner, &typ
|
||||
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
|
||||
if ptr && parsed != nil {
|
||||
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
var in RRSetInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owner, err := dnsname.Owner(in.Name, z.Name)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
typ, _, err := rr.NormalizeType(in.Type)
|
||||
if err != nil {
|
||||
return nil, errf(KindBadRequest, "%v", err)
|
||||
}
|
||||
var canon []string
|
||||
for _, d := range in.Data {
|
||||
_, p, err := parseRecord(z, owner, typ, d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canon = append(canon, p.Data)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||
if len(canon) > 0 {
|
||||
in.NewIDs, err = x.ids(len(canon))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
||||
if len(canon) == 0 {
|
||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
||||
}
|
||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
||||
}
|
||||
|
||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||
var in ViewInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil || strings.TrimSpace(*in.Name) == "" {
|
||||
return nil, errf(KindBadRequest, "a view needs a name")
|
||||
}
|
||||
adopt := in.AdoptZones
|
||||
in.AdoptZones = false
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops := []model.Op{newOp(OpViewCreate, ids[0], 0, in, "create view "+*in.Name)}
|
||||
if adopt {
|
||||
for _, z := range x.st.ZonesInView(0) {
|
||||
ops = append(ops, newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{ViewID: model.Ptr(ids[0])}, "move zone "+dnsname.Display(z.Name)+" into view "+*in.Name))
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) tsigCreate(req Request) ([]model.Op, error) {
|
||||
var in TSIGInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Name == nil {
|
||||
return nil, errf(KindBadRequest, "a TSIG key needs a name")
|
||||
}
|
||||
alg := "hmac-sha256"
|
||||
if in.Algorithm != nil {
|
||||
alg = strings.ToLower(*in.Algorithm)
|
||||
}
|
||||
in.Algorithm = &alg
|
||||
if in.Secret == nil || *in.Secret == "" {
|
||||
n := validate.TSIGAlgorithms[alg]
|
||||
if n == 0 {
|
||||
return nil, errf(KindBadRequest, "unsupported TSIG algorithm %q", alg)
|
||||
}
|
||||
in.Secret = model.Ptr(auth.GenerateTSIGSecret(n))
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []model.Op{newOp(OpTSIGCreate, ids[0], 0, in, "create TSIG key "+model.NormalizeKeyName(*in.Name)+" ("+alg+")")}, nil
|
||||
}
|
||||
|
||||
func (x *expander) optionCreate(req Request) ([]model.Op, error) {
|
||||
var in OptionInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Scope == nil || in.Name == nil {
|
||||
return nil, errf(KindBadRequest, "an option needs a scope and a name")
|
||||
}
|
||||
scopeID := int64(0)
|
||||
if in.ScopeID != nil {
|
||||
scopeID = *in.ScopeID
|
||||
}
|
||||
val := ""
|
||||
if in.Value != nil {
|
||||
val = *in.Value
|
||||
}
|
||||
n, v, err := NormalizeOption(x.st, *in.Scope, scopeID, *in.Name, val)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Name, in.Value = &n, &v
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o := &model.Option{Scope: *in.Scope, ScopeID: scopeID, Name: n}
|
||||
return []model.Op{newOp(OpOptionCreate, ids[0], 0, in, "set "+optionLabel(x.st, o)+" "+v)}, nil
|
||||
}
|
||||
|
||||
// RedactOps hides TSIG secrets in operation data.
|
||||
func RedactOps(ops []model.Op) []model.Op {
|
||||
out := make([]model.Op, len(ops))
|
||||
for i, op := range ops {
|
||||
out[i] = op
|
||||
if op.Kind != OpTSIGCreate && op.Kind != OpTSIGUpdate || len(op.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(op.Data, &m) == nil {
|
||||
if _, ok := m["secret"]; ok {
|
||||
m["secret"] = "(hidden)"
|
||||
out[i].Data = mustJSON(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Operation kinds.
|
||||
const (
|
||||
OpZoneCreate = "zone.create"
|
||||
OpZoneUpdate = "zone.update"
|
||||
OpZoneDelete = "zone.delete"
|
||||
OpRecordCreate = "record.create"
|
||||
OpRecordUpdate = "record.update"
|
||||
OpRecordDelete = "record.delete"
|
||||
OpRRSetReplace = "rrset.replace"
|
||||
OpViewCreate = "view.create"
|
||||
OpViewUpdate = "view.update"
|
||||
OpViewDelete = "view.delete"
|
||||
OpViewReorder = "view.reorder"
|
||||
OpACLCreate = "acl.create"
|
||||
OpACLUpdate = "acl.update"
|
||||
OpACLDelete = "acl.delete"
|
||||
OpTSIGCreate = "tsig.create"
|
||||
OpTSIGUpdate = "tsig.update"
|
||||
OpTSIGDelete = "tsig.delete"
|
||||
OpOptionCreate = "option.create"
|
||||
OpOptionUpdate = "option.update"
|
||||
OpOptionDelete = "option.delete"
|
||||
OpSettingsUpdate = "settings.update"
|
||||
)
|
||||
|
||||
// OpKinds lists all operation kinds.
|
||||
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
|
||||
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
|
||||
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate}
|
||||
|
||||
// Request is an operation as submitted by a client. Requests are expanded
|
||||
// into one or more stored operations (for example an A record with "ptr"
|
||||
// also creates the matching PTR record).
|
||||
type Request struct {
|
||||
Op string `json:"op"`
|
||||
// ID is the object to update or delete.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
// BaseVersion, if set, makes the request fail when the object's
|
||||
// version differs (optimistic locking). Otherwise the version at
|
||||
// staging time is recorded.
|
||||
BaseVersion int `json:"base_version,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// NewRequest builds a request with data marshaled from an input struct.
|
||||
func NewRequest(op string, id int64, data any) Request {
|
||||
r := Request{Op: op, ID: id}
|
||||
if data != nil {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r.Data = b
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// SOAInput sets SOA fields of a primary zone.
|
||||
type SOAInput struct {
|
||||
MName *string `json:"mname,omitempty"`
|
||||
RName *string `json:"rname,omitempty"` // mailbox name or email address
|
||||
Serial *uint32 `json:"serial,omitempty"`
|
||||
Refresh *uint32 `json:"refresh,omitempty"`
|
||||
Retry *uint32 `json:"retry,omitempty"`
|
||||
Expire *uint32 `json:"expire,omitempty"`
|
||||
Minimum *uint32 `json:"minimum,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneInput creates or updates a zone.
|
||||
type ZoneInput struct {
|
||||
// Name is the zone name. For create, a CIDR prefix such as
|
||||
// "192.168.1.0/24" creates the matching reverse zone.
|
||||
Name *string `json:"name,omitempty"`
|
||||
ViewID *int64 `json:"view_id,omitempty"`
|
||||
View *string `json:"view,omitempty"` // view name, alternative to view_id
|
||||
Type *string `json:"type,omitempty"`
|
||||
InView *string `json:"in_view,omitempty"`
|
||||
File *string `json:"file,omitempty"`
|
||||
SOA *SOAInput `json:"soa,omitempty"`
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_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
|
||||
// copy records from.
|
||||
Nameservers []string `json:"nameservers,omitempty"`
|
||||
Records []RecordInput `json:"records,omitempty"`
|
||||
CloneFrom int64 `json:"clone_from,omitempty"`
|
||||
// Options (create only) are zone options to set, e.g. primaries for a
|
||||
// secondary zone or forwarders for a forward zone.
|
||||
Options []ZoneOption `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// ZoneOption is an option given when creating a zone.
|
||||
type ZoneOption struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// RecordInput creates, updates or (by match) deletes a record.
|
||||
type RecordInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
Zone string `json:"zone,omitempty"` // zone name, alternative to zone_id
|
||||
View string `json:"view,omitempty"` // view of Zone
|
||||
Name *string `json:"name,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
TTL model.Opt[uint32] `json:"ttl,omitzero"` // null: use the zone default
|
||||
Data *string `json:"data,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// PTR also creates (or deletes) the matching PTR record in the
|
||||
// reverse zone, for A and AAAA records.
|
||||
PTR bool `json:"ptr,omitempty"`
|
||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||
MissingOK bool `json:"missing_ok,omitempty"`
|
||||
}
|
||||
|
||||
// RRSetInput replaces all records of one name and type.
|
||||
type RRSetInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||
Data []string `json:"data"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// NewIDs are allocated for records that need to be created.
|
||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
||||
}
|
||||
|
||||
// ViewInput creates or updates a view.
|
||||
type ViewInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
MatchClients *[]string `json:"match_clients,omitempty"`
|
||||
MatchDestinations *[]string `json:"match_destinations,omitempty"`
|
||||
MatchRecursiveOnly model.Opt[bool] `json:"match_recursive_only,omitzero"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
// AdoptZones (create) moves all zones that are not in a view into the
|
||||
// new view; named requires every zone to be in a view once views
|
||||
// are used.
|
||||
AdoptZones bool `json:"adopt_zones,omitempty"`
|
||||
// Cascade (delete) also deletes the view's zones.
|
||||
Cascade bool `json:"cascade,omitempty"`
|
||||
}
|
||||
|
||||
// ReorderInput sets the order of views.
|
||||
type ReorderInput struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// ACLInput creates or updates an ACL.
|
||||
type ACLInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Elements *[]string `json:"elements,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// TSIGInput creates or updates a TSIG key. A missing secret on create is
|
||||
// generated.
|
||||
type TSIGInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Algorithm *string `json:"algorithm,omitempty"`
|
||||
Secret *string `json:"secret,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// OptionInput creates or updates an option.
|
||||
type OptionInput struct {
|
||||
Scope *string `json:"scope,omitempty"` // options, view, zone, toplevel
|
||||
ScopeID *int64 `json:"scope_id,omitempty"` // view or zone ID
|
||||
Name *string `json:"name,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsInput updates Jarl's settings.
|
||||
type SettingsInput struct {
|
||||
DefaultTTL *uint32 `json:"default_ttl,omitempty"`
|
||||
Nameservers *[]string `json:"nameservers,omitempty"`
|
||||
RName *string `json:"rname,omitempty"`
|
||||
Refresh *uint32 `json:"refresh,omitempty"`
|
||||
Retry *uint32 `json:"retry,omitempty"`
|
||||
Expire *uint32 `json:"expire,omitempty"`
|
||||
NegativeTTL *uint32 `json:"negative_ttl,omitempty"`
|
||||
SerialPolicy *string `json:"serial_policy,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteInput carries options for delete operations.
|
||||
type DeleteInput struct {
|
||||
Cascade bool `json:"cascade,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/bind"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
"jarl/internal/testutil"
|
||||
)
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// namedEnv runs a real named on high ports, loading Jarl's configuration.
|
||||
type namedEnv struct {
|
||||
*testEnv
|
||||
bins *testutil.BindBinaries
|
||||
dnsPort int
|
||||
cmd *exec.Cmd
|
||||
log *bytes.Buffer
|
||||
}
|
||||
|
||||
func newNamedEnv(t *testing.T) *namedEnv {
|
||||
bins := testutil.FindBind()
|
||||
if bins == nil || bins.Named == "" || bins.RNDC == "" || bins.Dig == "" {
|
||||
t.Skip("named, rndc and dig are needed (install bind9 or set JARL_BIND_ROOT)")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
keyFile := filepath.Join(dir, "rndc.key")
|
||||
out, err := runEnv(bins.Env, bins.TSIGKeygen, "-a", "hmac-sha256", "rndc-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(keyFile, []byte(out), 0o600)
|
||||
dnsPort, ctlPort := freePort(t), freePort(t)
|
||||
paths := render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
|
||||
tools := &bind.Tools{NamedCheckConf: bins.CheckConf, NamedCheckZone: bins.CheckZone, RNDC: bins.RNDC, Env: bins.Env,
|
||||
RNDCArgs: []string{"-k", keyFile, "-s", "127.0.0.1", "-p", fmt.Sprint(ctlPort)}}
|
||||
cfg := Config{Paths: paths, StagingDir: filepath.Join(dir, "staging"), ReloadTimeout: 10 * time.Second, NoReload: true}
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e := New(st, tools, cfg, quiet)
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
st.CreateUser(context.Background(), admin)
|
||||
env := &namedEnv{testEnv: &testEnv{t: t, ctx: context.Background(), e: e, st: st, admin: &auth.Principal{User: admin, Method: "local"}, dir: dir}, bins: bins, dnsPort: dnsPort}
|
||||
run := filepath.Join(dir, "run")
|
||||
os.MkdirAll(run, 0o755)
|
||||
opt := func(scope, name, value string) Request {
|
||||
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value})
|
||||
}
|
||||
// Bootstrap the configuration without named running.
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.test."}}),
|
||||
opt(model.ScopeTopLevel, "include", `"`+keyFile+`"`),
|
||||
opt(model.ScopeTopLevel, "controls", fmt.Sprintf("{ inet 127.0.0.1 port %d allow { 127.0.0.1; } keys { rndc-key; }; }", ctlPort)),
|
||||
opt(model.ScopeOptions, "directory", `"`+run+`"`),
|
||||
opt(model.ScopeOptions, "pid-file", `"`+filepath.Join(run, "named.pid")+`"`),
|
||||
opt(model.ScopeOptions, "session-keyfile", `"`+filepath.Join(run, "session.key")+`"`),
|
||||
opt(model.ScopeOptions, "listen-on", fmt.Sprintf("port %d { 127.0.0.1; }", dnsPort)),
|
||||
opt(model.ScopeOptions, "listen-on-v6", "{ none; }"),
|
||||
opt(model.ScopeOptions, "recursion", "no"),
|
||||
NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.test")}),
|
||||
rec("example.test", "ns1", "A", "127.0.0.1"),
|
||||
rec("example.test", "www", "A", "192.0.2.10"),
|
||||
)
|
||||
env.log = &bytes.Buffer{}
|
||||
env.cmd = exec.Command(bins.Named, "-c", paths.MainConfig(), "-g", "-n", "1")
|
||||
env.cmd.Env = append(os.Environ(), bins.Env...)
|
||||
env.cmd.Stdout, env.cmd.Stderr = env.log, env.log
|
||||
if err := env.cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
env.cmd.Process.Kill()
|
||||
env.cmd.Wait()
|
||||
if t.Failed() {
|
||||
t.Logf("named log:\n%s", env.log.String())
|
||||
}
|
||||
})
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if _, err := tools.Status(context.Background()); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("named did not start:\n%s", env.log.String())
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
// From now on, reload named on every change.
|
||||
e.cfg.NoReload = false
|
||||
return env
|
||||
}
|
||||
|
||||
func runEnv(env []string, bin string, args ...string) (string, error) {
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (n *namedEnv) dig(name, typ string) string {
|
||||
out, err := runEnv(n.bins.Env, n.bins.Dig, "+short", "+norec", "-p", fmt.Sprint(n.dnsPort), "@127.0.0.1", name, typ)
|
||||
if err != nil {
|
||||
n.t.Fatalf("dig %s %s: %v %s", name, typ, err, out)
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
func TestWithRealNamed(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Fatalf("initial www = %q", got)
|
||||
}
|
||||
// Change a record: named serves it as soon as Apply returns.
|
||||
r := findRec(n.state(), "www.example.test.", "A")
|
||||
res := n.submit(n.admin, NewRequest(OpRecordUpdate, r.ID, RecordInput{Data: model.Ptr("192.0.2.11")}))
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.11" {
|
||||
t.Errorf("www after update = %q (reloaded %v)", got, res.Reloaded)
|
||||
}
|
||||
if len(res.Serials) != 1 || res.Serials[0].New != res.Serials[0].Old+1 {
|
||||
t.Errorf("serials = %+v", res.Serials)
|
||||
}
|
||||
// A new zone is loaded via reconfig.
|
||||
n.submit(n.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("second.test"), Nameservers: []string{"ns1.example.test."}}),
|
||||
rec("second.test", "@", "TXT", "hello from jarl"))
|
||||
if got := n.dig("second.test", "TXT"); got != `"hello from jarl"` {
|
||||
t.Errorf("second.test TXT = %q", got)
|
||||
}
|
||||
// ACME-style challenge add and cleanup.
|
||||
n.submit(n.admin, rec("example.test", "_acme-challenge", "TXT", "abc-123"))
|
||||
if got := n.dig("_acme-challenge.example.test", "TXT"); got != `"abc-123"` {
|
||||
t.Errorf("challenge = %q", got)
|
||||
}
|
||||
n.submit(n.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.test", Name: model.Ptr("_acme-challenge"), Type: model.Ptr("TXT")}))
|
||||
if got := n.dig("_acme-challenge.example.test", "TXT"); got != "" {
|
||||
t.Errorf("challenge after cleanup = %q", got)
|
||||
}
|
||||
// Deleting a zone removes it from named and its file from disk.
|
||||
z := n.state().ZonesNamed("second.test")[0]
|
||||
n.submit(n.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if got := n.dig("second.test", "TXT"); got != "" {
|
||||
t.Errorf("deleted zone still served: %q", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/second.test.zone")); !os.IsNotExist(err) {
|
||||
t.Errorf("zone file of deleted zone still exists: %v", err)
|
||||
}
|
||||
st, err := n.e.Status(n.ctx)
|
||||
if err != nil || st.Named == nil || !st.Named.Running || len(st.Drift) != 0 {
|
||||
t.Errorf("status = %+v %v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackOnReloadFailure(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
// Make rndc fail for "reconfig" to simulate named rejecting the change.
|
||||
wrapper := filepath.Join(n.dir, "rndc-wrapper")
|
||||
os.WriteFile(wrapper, []byte(fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = reconfig ] && { echo 'rndc: reconfig failed: simulated' >&2; exit 1; }; done\nexec %s \"$@\"\n", n.bins.RNDC)), 0o755)
|
||||
n.e.tools.RNDC = wrapper
|
||||
confBefore, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
_, err := n.e.Submit(n.ctx, []Request{NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("broken.test"), Nameservers: []string{"ns1.example.test."}})}, n.admin, "test", "", ApplyOptions{})
|
||||
ee, ok := AsError(err)
|
||||
if !ok || ee.Kind != KindBIND || !strings.Contains(ee.Message, "restored") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
confAfter, _ := os.ReadFile(n.e.cfg.Paths.MainConfig())
|
||||
if !bytes.Equal(confBefore, confAfter) {
|
||||
t.Error("named.conf was not restored")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(n.dir, "zones/_default/broken.test.zone")); !os.IsNotExist(err) {
|
||||
t.Error("zone file of failed change left behind")
|
||||
}
|
||||
if len(n.state().ZonesNamed("broken.test")) != 0 {
|
||||
t.Error("failed change recorded in the database")
|
||||
}
|
||||
// named keeps serving the old data and Jarl keeps working.
|
||||
n.e.tools.RNDC = n.bins.RNDC
|
||||
if got := n.dig("www.example.test", "A"); got != "192.0.2.10" {
|
||||
t.Errorf("www = %q", got)
|
||||
}
|
||||
n.submit(n.admin, rec("example.test", "after", "A", "192.0.2.99"))
|
||||
if got := n.dig("after.example.test", "A"); got != "192.0.2.99" {
|
||||
t.Errorf("after = %q", got)
|
||||
}
|
||||
evs, _ := n.st.ListAudit(n.ctx, store.AuditFilter{Outcome: "failure"})
|
||||
if len(evs) == 0 || !strings.Contains(evs[0].Message, "simulated") {
|
||||
t.Errorf("failure not audited: %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamedCheckconfRejects(t *testing.T) {
|
||||
n := newNamedEnv(t)
|
||||
// An option value Jarl's quick checks accept but named rejects.
|
||||
name, value := "max-cache-size", "lots"
|
||||
scope := model.ScopeOptions
|
||||
_, err := n.e.Submit(n.ctx, []Request{NewRequest(OpOptionCreate, 0, OptionInput{Scope: &scope, Name: &name, Value: &value})}, n.admin, "test", "", ApplyOptions{})
|
||||
ee, ok := AsError(err)
|
||||
if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "named-checkconf") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, m := range ee.Check {
|
||||
if m.Error && strings.Contains(m.Object, "server option max-cache-size") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("checkconf message not mapped to the option: %+v", ee.Check)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// copyOwner gives path the owner and group of fi (when running as root).
|
||||
func copyOwner(fi os.FileInfo, path string) {
|
||||
if st, ok := fi.Sys().(*syscall.Stat_t); ok && os.Geteuid() == 0 {
|
||||
os.Chown(path, int(st.Uid), int(st.Gid))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/catalog"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/namedconf"
|
||||
)
|
||||
|
||||
type refKind int
|
||||
|
||||
const (
|
||||
refACL refKind = iota
|
||||
refKey
|
||||
)
|
||||
|
||||
func sameRef(kind refKind, a, b string) bool {
|
||||
if kind == refKey {
|
||||
return model.NormalizeKeyName(a) == model.NormalizeKeyName(b)
|
||||
}
|
||||
return strings.EqualFold(a, b)
|
||||
}
|
||||
|
||||
// rewriteElements renames references in ACL-style element strings. It
|
||||
// returns the new list and whether anything matched.
|
||||
func rewriteElements(list []string, kind refKind, old, new string) ([]string, bool) {
|
||||
changed := false
|
||||
var fix func(es []namedconf.AMElement) []namedconf.AMElement
|
||||
fix = func(es []namedconf.AMElement) []namedconf.AMElement {
|
||||
for i := range es {
|
||||
e := &es[i]
|
||||
switch {
|
||||
case kind == refACL && e.Kind == namedconf.AMName && !e.IsBuiltin() && sameRef(kind, e.Name, old):
|
||||
e.Name, changed = new, true
|
||||
case kind == refKey && e.Kind == namedconf.AMKey && sameRef(kind, e.Name, old):
|
||||
e.Name, changed = new, true
|
||||
case e.Kind == namedconf.AMNested:
|
||||
e.Nested = fix(e.Nested)
|
||||
}
|
||||
}
|
||||
return es
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
e, err := namedconf.ParseAMElement(s)
|
||||
if err != nil {
|
||||
out[i] = s
|
||||
continue
|
||||
}
|
||||
res := fix([]namedconf.AMElement{e})
|
||||
out[i] = res[0].String()
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// walkRefs calls fn for every reference of the given kind in items. fn may
|
||||
// change the value's text.
|
||||
func walkRefs(items []namedconf.Item, kind refKind, aml bool, fn func(v *namedconf.Value)) {
|
||||
for i := 0; i < len(items); i++ {
|
||||
it := items[i]
|
||||
if it.Value != nil && !it.Value.Quoted && i+1 < len(items) {
|
||||
switch strings.ToLower(it.Value.Text) {
|
||||
case "key":
|
||||
if kind == refKey && items[i+1].Value != nil {
|
||||
fn(items[i+1].Value)
|
||||
}
|
||||
i++
|
||||
continue
|
||||
case "keys":
|
||||
next := items[i+1]
|
||||
if kind == refKey {
|
||||
if next.Value != nil {
|
||||
fn(next.Value)
|
||||
} else if next.Block != nil {
|
||||
for _, s := range next.Block.Stmts {
|
||||
if len(s.Items) == 1 && s.Items[0].Value != nil {
|
||||
fn(s.Items[0].Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if it.Block == nil {
|
||||
continue
|
||||
}
|
||||
for _, s := range it.Block.Stmts {
|
||||
if kind == refACL && aml {
|
||||
var v *namedconf.Value
|
||||
switch {
|
||||
case len(s.Items) == 1 && s.Items[0].Value != nil:
|
||||
v = s.Items[0].Value
|
||||
case len(s.Items) == 2 && s.Items[0].Bang && s.Items[1].Value != nil:
|
||||
v = s.Items[1].Value
|
||||
}
|
||||
if v != nil && !namedconf.BuiltinACLs[strings.ToLower(v.Text)] {
|
||||
fn(v)
|
||||
continue
|
||||
}
|
||||
}
|
||||
walkRefs(s.Items, kind, aml, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// optionHasAML reports whether the option's grammar contains address match
|
||||
// lists (so single words in its blocks are ACL names).
|
||||
func optionHasAML(st *model.State, o *model.Option) bool {
|
||||
cs := catalogScope(st, o.Scope, o.ScopeID)
|
||||
stmt, ok := catalog.Default().Lookup(cs, o.Name)
|
||||
return ok && strings.Contains(stmt.Grammar, "<address_match_element>")
|
||||
}
|
||||
|
||||
func optionLabel(st *model.State, o *model.Option) string {
|
||||
switch o.Scope {
|
||||
case model.ScopeView:
|
||||
if v := st.Views[o.ScopeID]; v != nil {
|
||||
return fmt.Sprintf("view %s option %s", v.Name, o.Name)
|
||||
}
|
||||
case model.ScopeZone:
|
||||
if z := st.Zones[o.ScopeID]; z != nil {
|
||||
label := "zone " + dnsname.Display(z.Name)
|
||||
if v := st.ViewName(z); v != "" {
|
||||
label += " [view " + v + "]"
|
||||
}
|
||||
return label + " option " + o.Name
|
||||
}
|
||||
case model.ScopeTopLevel:
|
||||
return "top-level statement " + o.Name
|
||||
}
|
||||
return "server option " + o.Name
|
||||
}
|
||||
|
||||
// FindRefs lists the objects that refer to an ACL or key by name.
|
||||
func FindRefs(st *model.State, kind refKind, name string) []string {
|
||||
var out []string
|
||||
for _, a := range st.SortedACLs() {
|
||||
if _, hit := rewriteElements(a.Elements, kind, name, name); hit {
|
||||
out = append(out, "ACL "+a.Name)
|
||||
}
|
||||
}
|
||||
for _, v := range st.OrderedViews() {
|
||||
_, h1 := rewriteElements(v.MatchClients, kind, name, name)
|
||||
_, h2 := rewriteElements(v.MatchDestinations, kind, name, name)
|
||||
if h1 || h2 {
|
||||
out = append(out, "view "+v.Name)
|
||||
}
|
||||
}
|
||||
var opts []string
|
||||
for _, o := range st.Options {
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hit := false
|
||||
walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) {
|
||||
if sameRef(kind, v.Text, name) {
|
||||
hit = true
|
||||
}
|
||||
})
|
||||
if hit {
|
||||
opts = append(opts, optionLabel(st, o))
|
||||
}
|
||||
}
|
||||
sort.Strings(opts)
|
||||
return append(out, opts...)
|
||||
}
|
||||
|
||||
// renameRefs rewrites references to an ACL or key after a rename.
|
||||
func renameRefs(st *model.State, kind refKind, old, new string, c *applyCtx) {
|
||||
for _, a := range st.ACLs {
|
||||
if l, hit := rewriteElements(a.Elements, kind, old, new); hit {
|
||||
a.Elements = l
|
||||
a.Touch(c.user(), c.now)
|
||||
}
|
||||
}
|
||||
for _, v := range st.Views {
|
||||
l1, h1 := rewriteElements(v.MatchClients, kind, old, new)
|
||||
l2, h2 := rewriteElements(v.MatchDestinations, kind, old, new)
|
||||
if h1 || h2 {
|
||||
v.MatchClients, v.MatchDestinations = l1, l2
|
||||
v.Touch(c.user(), c.now)
|
||||
}
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hit := false
|
||||
walkRefs(stmt.Items, kind, optionHasAML(st, o), func(v *namedconf.Value) {
|
||||
if sameRef(kind, v.Text, old) {
|
||||
v.Text, hit = new, true
|
||||
}
|
||||
})
|
||||
if hit {
|
||||
o.Value = namedconf.Compact(stmt.Items[1:])
|
||||
o.Touch(c.user(), c.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FindACLRefs lists the objects that use the ACL called name.
|
||||
func FindACLRefs(st *model.State, name string) []string { return FindRefs(st, refACL, name) }
|
||||
|
||||
// FindKeyRefs lists the objects that use the TSIG key called name.
|
||||
func FindKeyRefs(st *model.State, name string) []string { return FindRefs(st, refKey, name) }
|
||||
@@ -0,0 +1,75 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/render"
|
||||
)
|
||||
|
||||
// serialGreater reports whether a > b in RFC 1982 serial number
|
||||
// arithmetic.
|
||||
func serialGreater(a, b uint32) bool { return int32(a-b) > 0 }
|
||||
|
||||
// NextSerial returns the serial after old under policy, with the same
|
||||
// semantics as BIND's serial-update-method.
|
||||
func NextSerial(old uint32, policy string, now time.Time) uint32 {
|
||||
switch policy {
|
||||
case model.SerialUnixtime:
|
||||
t := uint32(now.Unix())
|
||||
if serialGreater(t, old) {
|
||||
return t
|
||||
}
|
||||
return old + 1
|
||||
case model.SerialIncrement:
|
||||
return old + 1
|
||||
default: // date: YYYYMMDDnn
|
||||
d := dateSerial(now)
|
||||
if serialGreater(d, old) {
|
||||
return d
|
||||
}
|
||||
return old + 1
|
||||
}
|
||||
}
|
||||
|
||||
// SerialChange records a serial bump.
|
||||
type SerialChange struct {
|
||||
ZoneID int64 `json:"zone_id"`
|
||||
Zone string `json:"zone"`
|
||||
View string `json:"view,omitempty"`
|
||||
Old uint32 `json:"old"`
|
||||
New uint32 `json:"new"`
|
||||
}
|
||||
|
||||
// bumpSerials increments the serial of every primary zone in work whose
|
||||
// DNS data differs from base, unless the serial was set explicitly.
|
||||
func bumpSerials(base, work *model.State, now time.Time, user string) []SerialChange {
|
||||
var out []SerialChange
|
||||
for _, z := range work.SortedZones() {
|
||||
if z.Type != model.ZonePrimary {
|
||||
continue
|
||||
}
|
||||
policy := z.SerialPolicy
|
||||
if policy == "" {
|
||||
policy = work.Settings.SerialPolicy
|
||||
}
|
||||
old := base.Zones[z.ID]
|
||||
if old == nil || old.Type != model.ZonePrimary || old.ViewID != z.ViewID {
|
||||
// New to named (or moved): keep the serial it was created
|
||||
// with, which InitialSerial or the source zone provided.
|
||||
continue
|
||||
}
|
||||
if z.SOA.Serial != old.SOA.Serial {
|
||||
// Set explicitly by the user.
|
||||
continue
|
||||
}
|
||||
if render.ZoneContentHash(base, old) == render.ZoneContentHash(work, z) {
|
||||
continue
|
||||
}
|
||||
next := NextSerial(old.SOA.Serial, policy, now)
|
||||
out = append(out, SerialChange{ZoneID: z.ID, Zone: z.Name, View: work.ViewName(z), Old: old.SOA.Serial, New: next})
|
||||
z.SOA.Serial = next
|
||||
z.Touch(user, now)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user