Files
jarl/internal/importer/importer.go
T
adamandClaude Opus 5.5 384f6b3060 Give every view a TSIG key that selects it
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>
2026-10-11 18:19:10 +01:00

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
}