Each view's match-clients starts with "key jarl-view-NAME" (after
"!key" for the views listed after it), so a query signed with a view's
key lands in that view from any address. The secrets are derived from a
random seed in Jarl's database. The delegation check signs its queries
to this server's own addresses with the key of the zone's view: before,
they got whichever view matched the server's address, usually internal.
jarl view key / GET /views/{id}/key show a key for dig -k (tsig:write,
audited). jarl-view- key names are reserved; the importer drops view
keys from a configuration Jarl wrote.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
834 lines
23 KiB
Go
834 lines
23 KiB
Go
// 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
|
|
// policies are dnssec-policy statements of the options block (key
|
|
// "options") and of views ("view:ID"), which Jarl sets per zone.
|
|
policies map[string]string
|
|
}
|
|
|
|
func (im *importer) warn(format string, args ...any) {
|
|
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{}, policies: map[string]string{}}
|
|
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.dnssecZones()
|
|
im.rep.Counts = map[string]int{"views": len(im.st.Views), "zones": len(im.st.Zones), "records": len(im.st.Records), "acls": len(im.st.ACLs), "tsig_keys": len(im.st.Keys), "options": len(im.st.Options)}
|
|
sort.Slice(im.rep.Zones, func(i, j int) bool {
|
|
a, b := im.rep.Zones[i], im.rep.Zones[j]
|
|
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) {
|
|
switch {
|
|
case name == "dnssec-policy" && scope == model.ScopeOptions:
|
|
im.policies["options"] = value
|
|
return
|
|
case name == "dnssec-policy" && scope == model.ScopeView:
|
|
im.policies[fmt.Sprintf("view:%d", scopeID)] = value
|
|
return
|
|
case name == "dnssec-policy" && scope == model.ScopeZone:
|
|
im.policies[fmt.Sprintf("zone:%d", scopeID)] = value
|
|
return
|
|
case name == "key-directory" && scope == model.ScopeZone:
|
|
// Jarl chooses signed zones' key directories (see dnssecKeys).
|
|
return
|
|
}
|
|
id, err := im.id()
|
|
if err != nil {
|
|
return
|
|
}
|
|
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()}
|
|
}
|
|
|
|
// policyName turns a dnssec-policy value into a zone's DNSSECPolicy.
|
|
func policyName(v string) string {
|
|
v = strings.Trim(strings.TrimSpace(v), `"`)
|
|
switch l := strings.ToLower(v); l {
|
|
case "none":
|
|
return ""
|
|
case model.DNSSECDefault, model.DNSSECInsecure:
|
|
return l
|
|
}
|
|
return v
|
|
}
|
|
|
|
// dnssecZones gives primary zones the dnssec-policy they inherit from
|
|
// their view or the options block (Jarl sets it per zone), and warns
|
|
// about signed zones' keys: Jarl keeps them in its own key directory, and
|
|
// unless the existing keys are moved there, named makes new ones that the
|
|
// parent's DS record doesn't match.
|
|
func (im *importer) dnssecZones() {
|
|
for _, z := range im.st.SortedZones() {
|
|
v, ok := im.policies[fmt.Sprintf("zone:%d", z.ID)]
|
|
if !ok {
|
|
v, ok = im.policies[fmt.Sprintf("view:%d", z.ViewID)]
|
|
}
|
|
if !ok {
|
|
v = im.policies["options"]
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
if p := policyName(v); p != "" {
|
|
im.warn("zone %s: named signs this %s zone (dnssec-policy %s); Jarl only signs primary zones, so the setting was dropped", dnsname.Display(z.Name), z.Type, p)
|
|
}
|
|
continue
|
|
}
|
|
z.DNSSECPolicy = policyName(v)
|
|
if z.DNSSECPolicy != "" {
|
|
im.warn("zone %s is signed (dnssec-policy %s). Before 'jarld adopt', copy its key files (K%s+*) into the key directory Jarl uses for it ('jarl zone dnssec status %s' shows it), owned by named's user; otherwise named makes new keys, and if the parent zone has a DS record for the old ones, the zone stops validating (docs/dnssec.md#adopting-a-signed-zone)", dnsname.Display(z.Name), z.DNSSECPolicy, z.Name, dnsname.Display(z.Name))
|
|
}
|
|
}
|
|
}
|
|
|
|
func restText(s *namedconf.Stmt) string {
|
|
return namedconf.Compact(s.Items[1:])
|
|
}
|
|
|
|
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))
|
|
if strings.HasPrefix(name, model.ViewKeyPrefix) {
|
|
// A view key from a configuration Jarl wrote: Jarl makes its own.
|
|
return nil
|
|
}
|
|
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 = withoutViewKeys(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 == "update-policy" || kw == "allow-update" && !allowsNone(o) {
|
|
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
|
|
}
|
|
|
|
// allowsNone says whether an address match list option is { none; }
|
|
// (allow-update { none; } turns dynamic updates off).
|
|
func allowsNone(o *namedconf.Stmt) bool {
|
|
b := o.FirstBlock()
|
|
if b == nil {
|
|
return false
|
|
}
|
|
for _, s := range b.Stmts {
|
|
if namedconf.Compact(s.Items) != "none" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// withoutViewKeys drops the view keys Jarl puts in front of match-clients
|
|
// (render/viewkeys.go) from a list read from a configuration Jarl wrote,
|
|
// and the "any" it adds after them to a view that had no list.
|
|
func withoutViewKeys(elems []string) []string {
|
|
out := []string{}
|
|
dropped := false
|
|
for _, e := range elems {
|
|
f := strings.Fields(strings.TrimPrefix(strings.TrimSpace(e), "!"))
|
|
if len(f) == 2 && f[0] == "key" && strings.HasPrefix(strings.Trim(f[1], `"`), model.ViewKeyPrefix) {
|
|
dropped = true
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
if dropped && len(out) == 1 && out[0] == "any" {
|
|
return []string{}
|
|
}
|
|
return out
|
|
}
|