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:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+737
View File
@@ -0,0 +1,737 @@
// Package importer reads an existing BIND configuration (named.conf, its
// include files and zone files) into a Jarl State.
package importer
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/miekg/dns"
"jarl/internal/bind"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
"jarl/internal/rr"
)
// Options control the import.
type Options struct {
// Tools, if set, is used to read zone files miekg/dns can't parse
// (named-checkzone -D understands everything named does).
Tools *bind.Tools
// Alloc allocates object IDs.
Alloc func(ctx context.Context, n int) ([]int64, error)
// User is recorded as the creator of imported objects.
User string
}
// ZoneReport describes one imported zone.
type ZoneReport struct {
Name string `json:"name"`
View string `json:"view,omitempty"`
Type string `json:"type"`
File string `json:"file,omitempty"`
Records int `json:"records"`
Skipped int `json:"skipped"`
}
// Report summarizes an import.
type Report struct {
Zones []ZoneReport `json:"zones"`
Kept []string `json:"kept_includes"`
Warnings []string `json:"warnings"`
Skipped []string `json:"skipped"` // records or statements that were not imported
Counts map[string]int
}
type importer struct {
ctx context.Context
opts Options
st *model.State
rep *Report
ids []int64
dir string // the directory option, for relative zone file names
seen map[string]bool
ctrl map[string]bool // key names used by controls
}
func (im *importer) warn(format string, args ...any) {
im.rep.Warnings = append(im.rep.Warnings, fmt.Sprintf(format, args...))
}
func (im *importer) skip(format string, args ...any) {
im.rep.Skipped = append(im.rep.Skipped, fmt.Sprintf(format, args...))
}
func (im *importer) id() (int64, error) {
if len(im.ids) == 0 {
ids, err := im.opts.Alloc(im.ctx, 256)
if err != nil {
return 0, err
}
im.ids = ids
}
id := im.ids[0]
im.ids = im.ids[1:]
return id, nil
}
func (im *importer) meta() model.Meta {
return model.Meta{Version: 1, CreatedBy: im.opts.User, UpdatedBy: im.opts.User}
}
// Import reads the configuration starting at mainPath.
func Import(ctx context.Context, mainPath string, opts Options) (*model.State, *Report, error) {
im := &importer{ctx: ctx, opts: opts, st: model.NewState(), rep: &Report{}, seen: map[string]bool{}, ctrl: map[string]bool{}}
stmts, err := im.readFile(mainPath)
if err != nil {
return nil, nil, err
}
// Learn which keys the control channel uses (they stay outside Jarl).
for _, s := range namedconf.Find(stmts, "controls") {
for _, k := range keyNamesInStmt(s) {
im.ctrl[model.NormalizeKeyName(k)] = true
}
}
stmts, err = im.expand(stmts, filepath.Dir(mainPath), true)
if err != nil {
return nil, nil, err
}
// The directory option is needed before zones are read.
if o := namedconf.FindOne(stmts, "options"); o != nil && o.FirstBlock() != nil {
if d := namedconf.FindOne(o.FirstBlock().Stmts, "directory"); d != nil {
im.dir = d.Arg(0)
}
}
for _, s := range stmts {
if err := im.topLevel(s); err != nil {
return nil, nil, err
}
}
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
sort.Slice(im.rep.Zones, func(i, j int) bool {
a, b := im.rep.Zones[i], im.rep.Zones[j]
if a.View != b.View {
return a.View < b.View
}
return a.Name < b.Name
})
return im.st, im.rep, nil
}
func (im *importer) readFile(path string) ([]*namedconf.Stmt, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
stmts, err := namedconf.Parse(path, string(b))
if err != nil {
return nil, err
}
return stmts, nil
}
func keyNamesInStmt(s *namedconf.Stmt) []string {
var out []string
var walk func(items []namedconf.Item)
walk = func(items []namedconf.Item) {
for i, it := range items {
if it.Value != nil && strings.EqualFold(it.Value.Text, "keys") && i+1 < len(items) && items[i+1].Block != nil {
for _, k := range items[i+1].Block.Stmts {
if len(k.Items) > 0 && k.Items[0].Value != nil {
out = append(out, k.Items[0].Value.Text)
}
}
}
if it.Block != nil {
for _, st := range it.Block.Stmts {
walk(st.Items)
}
}
}
}
walk(s.Items)
return out
}
// expand inlines include directives. At the top level, files holding only
// keys used by the control channel (rndc.key) stay as include statements so
// the rndc secret never ends up in Jarl's database.
func (im *importer) expand(stmts []*namedconf.Stmt, base string, top bool) ([]*namedconf.Stmt, error) {
var out []*namedconf.Stmt
for _, s := range stmts {
if s.Keyword() != "include" {
for _, it := range s.Items {
if it.Block != nil {
inner, err := im.expand(it.Block.Stmts, base, false)
if err != nil {
return nil, err
}
it.Block.Stmts = inner
}
}
out = append(out, s)
continue
}
pattern := s.Arg(0)
if !filepath.IsAbs(pattern) {
pattern = filepath.Join(base, pattern)
}
files, _ := filepath.Glob(pattern)
if len(files) == 0 {
if _, err := os.Stat(pattern); err != nil {
return nil, fmt.Errorf("%s: include %q: %w", s.Pos, s.Arg(0), err)
}
files = []string{pattern}
}
for _, f := range files {
if im.seen[f] {
return nil, fmt.Errorf("%s: include loop through %s", s.Pos, f)
}
inner, err := im.readFile(f)
if err != nil {
if top && errors.Is(err, fs.ErrPermission) {
// e.g. rndc.key readable by named only: keep it.
im.keepInclude(f)
continue
}
return nil, err
}
if top && im.keyOnly(inner) {
im.keepInclude(f)
continue
}
im.seen[f] = true
inner, err = im.expand(inner, filepath.Dir(f), top)
delete(im.seen, f)
if err != nil {
return nil, err
}
out = append(out, inner...)
}
}
return out, nil
}
// keyOnly reports whether stmts only define keys, at least one of which is
// used by the control channel (or the file is the conventional rndc.key).
func (im *importer) keyOnly(stmts []*namedconf.Stmt) bool {
if len(stmts) == 0 {
return false
}
ctrl := false
for _, s := range stmts {
if s.Keyword() != "key" {
return false
}
n := model.NormalizeKeyName(s.Arg(0))
if im.ctrl[n] || n == "rndc-key" {
ctrl = true
}
}
return ctrl
}
func (im *importer) keepInclude(path string) {
im.rep.Kept = append(im.rep.Kept, path)
im.addOption(model.ScopeTopLevel, 0, "include", namedconf.QuoteString(path), "")
}
func (im *importer) addOption(scope string, scopeID int64, name, value, comment string) {
id, err := im.id()
if err != nil {
return
}
pos := 0
for _, o := range im.st.Options {
if o.Scope == scope && o.ScopeID == scopeID && o.Position >= pos {
pos = o.Position + 1
}
}
im.st.Options[id] = &model.Option{ID: id, Scope: scope, ScopeID: scopeID, Name: name, Value: value, Position: pos, Comment: comment, Meta: im.meta()}
}
func restText(s *namedconf.Stmt) string {
return namedconf.Compact(s.Items[1:])
}
func (im *importer) topLevel(s *namedconf.Stmt) error {
switch kw := s.Keyword(); kw {
case "options":
b := s.FirstBlock()
if b == nil {
return fmt.Errorf("%s: options without a block", s.Pos)
}
for _, o := range b.Stmts {
im.addOption(model.ScopeOptions, 0, canonicalName(catalog.ScopeOptions, o.Keyword()), restText(o), "")
}
case "acl":
return im.acl(s)
case "key":
return im.key(s)
case "view":
return im.view(s)
case "zone":
return im.zone(s, nil)
case "":
im.skip("%s: statement without a keyword", s.Pos)
default:
name := canonicalName(catalog.ScopeTop, kw)
if _, ok := catalog.Default().Lookup(catalog.ScopeTop, name); !ok {
im.warn("%s: unknown top-level statement %q imported as is", s.Pos, kw)
}
im.addOption(model.ScopeTopLevel, 0, name, restText(s), "")
}
return nil
}
func canonicalName(scope, name string) string {
n, _ := catalog.Canonical(scope, name)
return n
}
func elementStrings(b *namedconf.Block) []string {
var out []string
for _, st := range b.Stmts {
if e, err := namedconf.ParseAMList(&namedconf.Block{Stmts: []*namedconf.Stmt{st}}); err == nil && len(e) == 1 {
out = append(out, e[0].String())
} else {
out = append(out, namedconf.Compact(st.Items))
}
}
return out
}
func (im *importer) acl(s *namedconf.Stmt) error {
b := s.FirstBlock()
if b == nil {
return fmt.Errorf("%s: acl without a block", s.Pos)
}
id, err := im.id()
if err != nil {
return err
}
im.st.ACLs[id] = &model.ACL{ID: id, Name: s.Arg(0), Elements: nonNil(elementStrings(b)), Meta: im.meta()}
return nil
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
func (im *importer) key(s *namedconf.Stmt) error {
b := s.FirstBlock()
if b == nil {
return fmt.Errorf("%s: key without a block", s.Pos)
}
name := model.NormalizeKeyName(s.Arg(0))
alg, secret := "", ""
if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil {
alg = strings.ToLower(a.Arg(0))
}
if sc := namedconf.FindOne(b.Stmts, "secret"); sc != nil {
secret = sc.Arg(0)
}
if existing := im.st.KeyByName(name); existing != nil {
if existing.Secret != secret || existing.Algorithm != alg {
im.warn("%s: key %s is defined more than once with different secrets; keeping the first", s.Pos, name)
}
return nil
}
id, err := im.id()
if err != nil {
return err
}
im.st.Keys[id] = &model.TSIGKey{ID: id, Name: name, Algorithm: alg, Secret: secret, Meta: im.meta()}
return nil
}
func (im *importer) view(s *namedconf.Stmt) error {
b := s.FirstBlock()
if b == nil {
return fmt.Errorf("%s: view without a block", s.Pos)
}
if class := s.Arg(1); class != "" && !strings.EqualFold(class, "IN") {
im.skip("%s: view %s of class %s (only class IN views are supported)", s.Pos, s.Arg(0), class)
return nil
}
id, err := im.id()
if err != nil {
return err
}
v := &model.View{ID: id, Name: s.Arg(0), Position: len(im.st.Views), MatchClients: []string{}, MatchDestinations: []string{}, Meta: im.meta()}
im.st.Views[id] = v
for _, st := range b.Stmts {
switch kw := st.Keyword(); kw {
case "match-clients":
if mb := st.FirstBlock(); mb != nil {
v.MatchClients = nonNil(elementStrings(mb))
}
case "match-destinations":
if mb := st.FirstBlock(); mb != nil {
v.MatchDestinations = nonNil(elementStrings(mb))
}
case "match-recursive-only":
v.MatchRecursiveOnly = model.Ptr(isYes(st.Arg(0)))
case "zone":
if err := im.zone(st, v); err != nil {
return err
}
case "key":
im.warn("%s: key %s defined inside view %s was moved to the top level", st.Pos, st.Arg(0), v.Name)
if err := im.key(st); err != nil {
return err
}
default:
im.addOption(model.ScopeView, v.ID, canonicalName(catalog.ScopeView, kw), restText(st), "")
}
}
return nil
}
func isYes(s string) bool {
switch strings.ToLower(s) {
case "yes", "true", "1":
return true
}
return false
}
func (im *importer) zone(s *namedconf.Stmt, v *model.View) error {
b := s.FirstBlock()
if b == nil {
return fmt.Errorf("%s: zone without a block", s.Pos)
}
if class := s.Arg(1); class != "" && !strings.EqualFold(class, "IN") {
im.skip("%s: zone %s of class %s (only class IN is supported)", s.Pos, s.Arg(0), class)
return nil
}
name, err := dnsname.ToASCII(s.Arg(0))
if err != nil {
return fmt.Errorf("%s: %v", s.Pos, err)
}
id, err := im.id()
if err != nil {
return err
}
z := &model.Zone{ID: id, Name: dnsname.Fqdn(name), Meta: im.meta()}
if v != nil {
z.ViewID = v.ID
}
file := ""
var opts []*namedconf.Stmt
for _, st := range b.Stmts {
switch kw := st.Keyword(); kw {
case "type":
t := strings.ToLower(st.Arg(0))
if syn, ok := catalog.ZoneTypeSynonyms[t]; ok {
t = syn
}
z.Type = t
case "file":
file = st.Arg(0)
case "in-view":
z.Type = model.ZoneInView
z.InView = st.Arg(0)
default:
opts = append(opts, st)
}
}
if z.Type == "" {
im.skip("%s: zone %s has no type", s.Pos, name)
return nil
}
if z.Type == "delegation-only" {
im.skip("%s: zone %s: delegation-only zones were removed in BIND 9.20", s.Pos, name)
return nil
}
im.st.Zones[z.ID] = z
for _, o := range opts {
im.addOption(model.ScopeZone, z.ID, canonicalName(catalog.ZoneScope(z.Type), o.Keyword()), restText(o), "")
}
report := ZoneReport{Name: dnsname.Display(z.Name), Type: z.Type}
if v != nil {
report.View = v.Name
}
abs := file
if file != "" && !filepath.IsAbs(file) {
abs = filepath.Join(im.dir, file)
}
report.File = abs
switch z.Type {
case model.ZonePrimary:
n, skipped, err := im.loadZoneFile(z, abs)
if err != nil {
return fmt.Errorf("zone %s (%s): %w", dnsname.Display(z.Name), abs, err)
}
report.Records, report.Skipped = n, skipped
for _, o := range opts {
if kw := o.Keyword(); kw == "allow-update" || kw == "update-policy" {
im.warn("zone %s uses %s (dynamic updates); Jarl rewrites zone files and won't apply such zones until %s is removed", dnsname.Display(z.Name), kw, kw)
}
}
case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub, model.ZoneHint, model.ZoneRedirect:
// Keep using the existing file (named maintains it).
z.File = abs
}
im.rep.Zones = append(im.rep.Zones, report)
return nil
}
// loadZoneFile parses a primary zone's file into records.
func (im *importer) loadZoneFile(z *model.Zone, path string) (int, int, error) {
if path == "" {
return 0, 0, errors.New("primary zone without a file")
}
type parsedRR struct {
rr dns.RR
comment string
}
var rrs []parsedRR
var defaultTTL uint32
hasTTL := false
parse := func(src string, file string, withComments bool) error {
rrs = rrs[:0]
// Find $TTL ourselves: miekg/dns doesn't expose it.
hasTTL = false
for _, line := range strings.Split(src, "\n") {
f := strings.Fields(line)
if len(f) >= 2 && strings.EqualFold(f[0], "$TTL") {
if t, ok := ParseTTL(f[1]); ok {
defaultTTL, hasTTL = t, true
}
break
}
}
zp := dns.NewZoneParser(strings.NewReader(src), z.Name, file)
zp.SetIncludeAllowed(true)
for r, ok := zp.Next(); ok; r, ok = zp.Next() {
c := ""
if withComments {
c = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(zp.Comment()), ";"))
}
rrs = append(rrs, parsedRR{r, c})
}
return zp.Err()
}
b, err := os.ReadFile(path)
if err != nil {
return 0, 0, err
}
if err := parse(string(b), path, true); err != nil {
if im.opts.Tools == nil {
return 0, 0, err
}
// Let named-checkzone produce a canonical dump instead.
dump, derr := im.opts.Tools.DumpZone(im.ctx, z.Name, path)
if derr != nil {
return 0, 0, fmt.Errorf("%v (named-checkzone: %v)", err, derr)
}
im.warn("zone %s: read through named-checkzone (%v); comments were not kept", dnsname.Display(z.Name), err)
if err := parse(dump, path, false); err != nil {
return 0, 0, err
}
}
if len(rrs) == 0 {
return 0, 0, errors.New("the zone file has no records")
}
soa, ok := rrs[0].rr.(*dns.SOA)
if !ok {
return 0, 0, errors.New("the first record is not the SOA")
}
z.SOA = model.SOA{MName: strings.ToLower(soa.Ns), RName: strings.ToLower(soa.Mbox), Serial: soa.Serial, Refresh: soa.Refresh, Retry: soa.Retry, Expire: soa.Expire, Minimum: soa.Minttl}
if !hasTTL {
// RFC 1035: without $TTL, the SOA minimum was the default.
defaultTTL = soa.Minttl
im.warn("zone %s has no $TTL; using %d (the SOA minimum) as the default TTL", dnsname.Display(z.Name), defaultTTL)
}
z.DefaultTTL = defaultTTL
n, skipped := 0, 0
seen := map[string]bool{}
rrsetTTL := map[string]uint32{}
for _, p := range rrs[1:] {
h := p.rr.Header()
owner := strings.ToLower(h.Name)
typ := dns.TypeToString[h.Rrtype]
label := fmt.Sprintf("zone %s: %s %s", dnsname.Display(z.Name), owner, typ)
switch {
case h.Class != dns.ClassINET:
im.skip("%s: class %s is not supported", label, dns.ClassToString[h.Class])
skipped++
continue
case !dnsname.IsSubdomain(owner, z.Name):
im.skip("%s: outside the zone (named ignores it too)", label)
skipped++
continue
case h.Rrtype == dns.TypeSOA:
im.skip("%s: extra SOA record", label)
skipped++
continue
}
fields := strings.SplitN(p.rr.String(), "\t", 5)
data := ""
if len(fields) == 5 {
data = fields[4]
}
if typ == "" {
typ = fmt.Sprintf("TYPE%d", h.Rrtype)
}
parsed, err := rr.Parse(z.Name, owner, typ, data)
if err != nil {
im.skip("%s %s: %v", label, data, err)
skipped++
continue
}
k := owner + "|" + parsed.Type + "|" + strings.ToLower(parsed.Data)
if seen[k] {
continue // duplicates are dropped silently, as named does
}
seen[k] = true
ttl := h.Ttl
setKey := owner + "|" + parsed.Type
if first, ok := rrsetTTL[setKey]; ok && first != ttl {
im.warn("%s: records of this RRset had different TTLs; using %d like named does", label, first)
ttl = first
} else {
rrsetTTL[setKey] = ttl
}
id, err := im.id()
if err != nil {
return 0, 0, err
}
r := &model.Record{ID: id, ZoneID: z.ID, Name: owner, Type: parsed.Type, Data: parsed.Data, Comment: p.comment, Meta: im.meta()}
if ttl != defaultTTL {
r.TTL = model.Ptr(ttl)
}
im.st.Records[id] = r
n++
}
return n, skipped, nil
}
// ParseTTL parses a TTL as BIND writes them: seconds, or a combination of
// units such as 1w2d3h4m5s.
func ParseTTL(s string) (uint32, bool) {
if s == "" {
return 0, false
}
var total, cur uint64
digits := false
for _, c := range strings.ToLower(s) {
switch {
case c >= '0' && c <= '9':
cur = cur*10 + uint64(c-'0')
digits = true
case strings.ContainsRune("wdhms", c) && digits:
mult := map[rune]uint64{'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1}[c]
total += cur * mult
cur, digits = 0, false
default:
return 0, false
}
if total+cur > 0xffffffff {
return 0, false
}
}
total += cur
return uint32(total), true
}
// ParsedRecord is a record read from zone file text.
type ParsedRecord struct {
Name string
Type string
TTL uint32
Data string
Comment string
}
// ParsedZone is the content of a zone file.
type ParsedZone struct {
SOA *dns.SOA
DefaultTTL uint32
HasTTL bool
Records []ParsedRecord
Skipped []string
}
// ParseZoneText parses zone file text for zone (an FQDN). $INCLUDE is not
// allowed. Records that are invalid, outside the zone or duplicated are
// reported in Skipped.
func ParseZoneText(zone, text string) (*ParsedZone, error) {
pz := &ParsedZone{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) >= 2 && strings.EqualFold(f[0], "$TTL") {
if t, ok := ParseTTL(f[1]); ok {
pz.DefaultTTL, pz.HasTTL = t, true
}
break
}
}
zp := dns.NewZoneParser(strings.NewReader(text), zone, "")
zp.SetIncludeAllowed(false)
if pz.HasTTL {
zp.SetDefaultTTL(pz.DefaultTTL)
}
seen := map[string]bool{}
for r, ok := zp.Next(); ok; r, ok = zp.Next() {
h := r.Header()
owner := strings.ToLower(h.Name)
typ := dns.TypeToString[h.Rrtype]
if typ == "" {
typ = fmt.Sprintf("TYPE%d", h.Rrtype)
}
if soa, isSOA := r.(*dns.SOA); isSOA {
if pz.SOA == nil && owner == zone {
pz.SOA = soa
} else {
pz.Skipped = append(pz.Skipped, owner+" SOA: only the zone's own SOA is used")
}
continue
}
if h.Class != dns.ClassINET {
pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s: class %s is not supported", owner, typ, dns.ClassToString[h.Class]))
continue
}
if !dnsname.IsSubdomain(owner, zone) {
pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s: outside the zone", owner, typ))
continue
}
fields := strings.SplitN(r.String(), "\t", 5)
data := ""
if len(fields) == 5 {
data = fields[4]
}
p, err := rr.Parse(zone, owner, typ, data)
if err != nil {
pz.Skipped = append(pz.Skipped, fmt.Sprintf("%s %s %s: %v", owner, typ, data, err))
continue
}
k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data)
if seen[k] {
continue
}
seen[k] = true
c := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(zp.Comment()), ";"))
pz.Records = append(pz.Records, ParsedRecord{Name: owner, Type: p.Type, TTL: h.Ttl, Data: p.Data, Comment: c})
}
if err := zp.Err(); err != nil {
return nil, err
}
if !pz.HasTTL && pz.SOA != nil {
pz.DefaultTTL = pz.SOA.Minttl
}
return pz, nil
}
+148
View File
@@ -0,0 +1,148 @@
package importer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"jarl/internal/model"
"jarl/internal/validate"
)
// copyTree copies testdata/<name> to a temp dir, replacing @DIR@.
func copyTree(t *testing.T, name string) string {
t.Helper()
dst := t.TempDir()
src := filepath.Join("testdata", name)
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
b, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
t.Fatal(err)
}
b = []byte(strings.ReplaceAll(string(b), "@DIR@", dst))
if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil {
t.Fatal(err)
}
}
return dst
}
func seqAlloc() func(context.Context, int) ([]int64, error) {
next := int64(0)
return func(_ context.Context, n int) ([]int64, error) {
out := make([]int64, n)
for i := range out {
next++
out[i] = next
}
return out, nil
}
}
func TestImportDebian(t *testing.T) {
dir := copyTree(t, "debian")
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc(), User: "import"})
if err != nil {
t.Fatal(err)
}
// rndc.key stays an include; its secret is not imported.
if len(rep.Kept) != 1 || !strings.HasSuffix(rep.Kept[0], "rndc.key") {
t.Errorf("kept = %v", rep.Kept)
}
if st.KeyByName("rndc-key") != nil {
t.Error("rndc key imported")
}
if k := st.KeyByName("xfer-key"); k == nil || k.Algorithm != "hmac-sha256" {
t.Errorf("xfer-key = %+v", k)
}
if a := st.ACLByName("homelab"); a == nil || strings.Join(a.Elements, ",") != "192.168.1.0/24,10.10.0.0/16,localhost" {
t.Errorf("acl = %+v", a)
}
if o := st.FindOption(model.ScopeOptions, 0, "allow-recursion"); o == nil || o.Value != "{ homelab; }" {
t.Errorf("allow-recursion = %+v", o)
}
if st.FindOption(model.ScopeTopLevel, 0, "controls") == nil || st.FindOption(model.ScopeTopLevel, 0, "logging") == nil {
t.Error("controls/logging not kept")
}
z := st.FindZone("adbraun.lol", 0)
if z == nil || z.Type != model.ZonePrimary || z.SOA.Serial != 2024051201 || z.SOA.RName != "admin.adbraun.lol." || z.DefaultTTL != 3600 {
t.Fatalf("zone = %+v", z)
}
if st.FindOption(model.ScopeZone, z.ID, "allow-transfer") == nil {
t.Error("zone option lost")
}
recs := map[string]*model.Record{}
for _, r := range st.ZoneRecords(z.ID) {
recs[r.Name+" "+r.Type+" "+r.Data] = r
}
if r := recs["nas.adbraun.lol. A 192.168.1.20"]; r == nil || r.Comment != "synology" || r.TTL != nil {
t.Errorf("nas = %+v", r)
}
if r := recs["www.adbraun.lol. A 192.168.1.11"]; r == nil || r.TTL == nil || *r.TTL != 300 {
t.Errorf("www = %+v", r)
}
if recs["adbraun.lol. MX 10 mail.adbraun.lol."] == nil || recs["media.adbraun.lol. CNAME nas.adbraun.lol."] == nil {
t.Errorf("records = %v", recs)
}
rz := st.FindZone("1.168.192.in-addr.arpa", 0)
if rz == nil {
t.Fatal("reverse zone missing")
}
if n := len(st.ZoneRecords(rz.ID)); n != 6 { // NS, 2 PTR, 3 generated PTR
t.Errorf("reverse zone has %d records", n)
}
if fz := st.FindZone("consul", 0); fz == nil || fz.Type != model.ZoneForward {
t.Error("forward zone missing")
}
// The imported configuration is valid (the open allow-query is fine
// because allow-recursion is restricted).
ps := validate.State(st, validate.Options{ExternalKeys: []string{"rndc-key"}})
if validate.HasErrors(ps) {
t.Errorf("imported state has errors: %v", validate.Errors(ps))
}
for _, p := range ps {
if p.Code == "security_open_resolver" {
t.Error("false open resolver warning")
}
}
if rep.Counts["zones"] != 3 || len(rep.Zones) != 3 {
t.Errorf("report = %+v", rep)
}
}
func TestImportErrors(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`include "`+dir+`/missing.conf";`), 0o644)
if _, _, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()}); err == nil {
t.Error("missing include accepted")
}
os.WriteFile(filepath.Join(dir, "named.conf"), []byte(`zone "x.test" { type primary; file "`+dir+`/x.db"; };`), 0o644)
os.WriteFile(filepath.Join(dir, "x.db"), []byte("$TTL 300\n@ IN SOA ns1.x.test. h.x.test. 1 2 3 4 5\n@ IN NS ns1\nns1 IN A 192.0.2.1\nbad_name IN A 192.0.2.2\nother.test. IN A 192.0.2.3\n"), 0o644)
st, rep, err := Import(context.Background(), filepath.Join(dir, "named.conf"), Options{Alloc: seqAlloc()})
if err != nil {
t.Fatal(err)
}
if len(rep.Skipped) != 2 || len(st.Records) != 2 {
t.Errorf("skipped = %v, records = %d", rep.Skipped, len(st.Records))
}
}
func TestParseTTL(t *testing.T) {
cases := map[string]uint32{"3600": 3600, "1h": 3600, "1h30m": 5400, "1W": 604800, "2d3s": 172803}
for in, want := range cases {
if got, ok := ParseTTL(in); !ok || got != want {
t.Errorf("ParseTTL(%q) = %d %v", in, got, ok)
}
}
for _, bad := range []string{"", "h", "1x", "-5"} {
if _, ok := ParseTTL(bad); ok {
t.Errorf("ParseTTL(%q) accepted", bad)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
$TTL 86400
$ORIGIN 1.168.192.in-addr.arpa.
@ IN SOA ns1.adbraun.lol. admin.adbraun.lol. ( 2024051201 3600 900 1209600 300 )
@ IN NS ns1.adbraun.lol.
2 IN PTR ns1.adbraun.lol.
20 IN PTR nas.adbraun.lol.
$GENERATE 100-102 $ IN PTR dhcp-$.adbraun.lol.
+17
View File
@@ -0,0 +1,17 @@
$TTL 3600
@ IN SOA ns1.adbraun.lol. admin.adbraun.lol. (
2024051201 ; serial
3600 ; refresh
900 ; retry
1209600 ; expire
300 ) ; negative cache TTL
IN NS ns1
IN MX 10 mail
@ IN TXT "v=spf1 mx -all"
ns1 IN A 192.168.1.2 ; the DNS server itself
mail IN A 192.168.1.25
www 300 IN A 192.168.1.10
www 300 IN A 192.168.1.11
nas IN A 192.168.1.20 ; synology
media IN CNAME nas
_acme-challenge 60 IN TXT "old-token"
+7
View File
@@ -0,0 +1,7 @@
// This is the primary configuration file for the BIND DNS server named.
include "@DIR@/named.conf.options";
include "@DIR@/named.conf.local";
include "@DIR@/rndc.key";
controls {
inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
};
+14
View File
@@ -0,0 +1,14 @@
zone "adbraun.lol" {
type master;
file "@DIR@/db.adbraun.lol";
allow-transfer { key xfer-key; };
};
zone "1.168.192.in-addr.arpa" {
type master;
file "@DIR@/db.192.168.1";
};
zone "consul" {
type forward;
forward only;
forwarders { 127.0.0.1 port 8600; };
};
+22
View File
@@ -0,0 +1,22 @@
acl "homelab" {
192.168.1.0/24;
10.10.0.0/16;
localhost;
};
key "xfer-key" {
algorithm hmac-sha256;
secret "Y2hhbmdlLW1lLWNoYW5nZS1tZS1jaGFuZ2UtbWUtMTIzNA==";
};
options {
directory "/var/cache/bind";
recursion yes;
allow-recursion { homelab; };
allow-query { any; };
forwarders { 1.1.1.1; 9.9.9.9; };
dnssec-validation auto;
listen-on-v6 { any; };
};
logging {
channel default_log { file "/var/log/named/default.log" versions 3 size 5m; severity info; print-time yes; };
category default { default_log; };
};
+4
View File
@@ -0,0 +1,4 @@
key "rndc-key" {
algorithm hmac-sha256;
secret "cm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQtcm5kYy1zZWNyZXQ=";
};