// 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 == "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 }