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,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
|
||||
}
|
||||
Reference in new issue
Block a user