// Package render turns a configuration State into BIND configuration // files: Jarl's named.conf, a separate keys.conf holding TSIG secrets, and // one zone file per primary zone. // // Rendering is deterministic: the same State always produces byte-for-byte // identical files, so unchanged files are not rewritten and diffs only show // real changes. package render import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strings" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/namedconf" ) // Paths says where generated files go. type Paths struct { ConfigDir string `json:"config_dir"` // Jarl's named.conf and keys.conf, e.g. /etc/bind/jarl ZoneDir string `json:"zone_dir"` // primary zone files, e.g. /var/lib/bind/jarl CacheDir string `json:"cache_dir"` // files named writes for secondary/mirror/stub zones, e.g. /var/cache/bind/jarl } // MainConfig returns the path of Jarl's named.conf. func (p Paths) MainConfig() string { return filepath.Join(p.ConfigDir, "named.conf") } // KeysConfig returns the path of the TSIG key file. func (p Paths) KeysConfig() string { return filepath.Join(p.ConfigDir, "keys.conf") } // File kinds. const ( KindConfig = "config" KindKeys = "keys" KindZone = "zone" ) // File is a rendered file. type File struct { Path string Content []byte Mode os.FileMode Kind string Secret bool // contains secrets; never show contents in diffs or logs ZoneID int64 // for zone files } // Span maps lines of a rendered file to the object they came from. type Span struct { Start, End int // 1-based, inclusive ObjectType string ObjectID int64 Label string } // Output is the result of rendering. type Output struct { Files []*File // Dirs are directories named must be able to write to (secondary zone // files, DNSSEC key directories). Dirs []string // Spans maps file paths to source spans. Spans map[string][]Span // ZoneFiles maps zone IDs to the file named loads for the zone. ZoneFiles map[int64]string // Policy lists the response policy zones (docs/blocking.md). Policy []PolicyZone // Blocklists are the zone files of blocklists named loads. jarld // downloads them; until then they must exist, as empty policy zones. Blocklists []string } // File returns the rendered file at path, or nil. func (o *Output) File(path string) *File { for _, f := range o.Files { if f.Path == path { return f } } return nil } // Lookup returns the span covering line in path, if any. When spans nest, // the innermost wins. func (o *Output) Lookup(path string, line int) (Span, bool) { var best Span found := false for _, s := range o.Spans[path] { if line >= s.Start && line <= s.End { if !found || s.End-s.Start < best.End-best.Start { best, found = s, true } } } return best, found } // ViewDir returns the directory component used for a zone's view. func ViewDir(viewName string) string { if viewName == "" { return "_default" } return dnsname.FileSafe(dnsname.Fqdn(strings.ToLower(viewName))) } // KeysDir is the directory under which named keeps the DNSSEC keys of the // zones Jarl signs, one subdirectory per view. func (p Paths) KeysDir() string { return filepath.Join(p.ZoneDir, "dnssec-keys") } // KeyDir returns the key-directory of zone z: one per view, because named // refuses one directory for zones of the same name in two views. It is // named after the view's ID, which unlike its name never changes: a new // directory would make named create new keys, which the parent's DS // record wouldn't match. func KeyDir(s *model.State, z *model.Zone, p Paths) string { if z.ViewID == 0 { return filepath.Join(p.KeysDir(), "_default") } return filepath.Join(p.KeysDir(), fmt.Sprintf("view-%d", z.ViewID)) } // ZoneFilePath returns the file named loads for zone z, or "" for zone // types without files. func ZoneFilePath(s *model.State, z *model.Zone, p Paths) string { switch z.Type { case model.ZonePrimary: return filepath.Join(p.ZoneDir, ViewDir(s.ViewName(z)), dnsname.FileSafe(z.Name)+".zone") case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub: if z.File != "" { return z.File } return filepath.Join(p.CacheDir, ViewDir(s.ViewName(z)), dnsname.FileSafe(z.Name)+".zone") case model.ZoneHint, model.ZoneRedirect: return z.File } return "" } // Header is placed at the top of every generated file. const headerText = "Generated by Jarl. DO NOT EDIT: changes made here are overwritten the next time\nchanges are applied. Manage this server through the Jarl web UI, API or CLI." // Render renders the complete configuration. x adds statements that come // from jarld's configuration (e.g. for query statistics). func Render(s *model.State, p Paths, x Extras) *Output { out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}} keys := renderKeys(s) + renderViewKeys(s, x) out.Files = append(out.Files, &File{Path: p.KeysConfig(), Content: []byte(keys), Mode: 0o640, Kind: KindKeys, Secret: true}) conf, spans := renderConfig(s, p, x, out) out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig}) out.Spans[p.MainConfig()] = spans for _, z := range s.SortedZones() { if z.Type != model.ZonePrimary { continue } path := ZoneFilePath(s, z, p) out.Files = append(out.Files, &File{Path: path, Content: []byte(ZoneFile(s, z)), Mode: 0o640, Kind: KindZone, ZoneID: z.ID}) } sort.Strings(out.Dirs) return out } func renderKeys(s *model.State) string { var sb strings.Builder sb.WriteString(commentBlock("//", headerText+"\nThis file holds TSIG secrets: keep it readable only by named and Jarl.")) for _, k := range s.SortedKeys() { sb.WriteString("\n") if k.Comment != "" { sb.WriteString(commentBlock("//", k.Comment)) } fmt.Fprintf(&sb, "key %s {\n\talgorithm %s;\n\tsecret %s;\n};\n", namedconf.QuoteString(k.Name), k.Algorithm, namedconf.QuoteString(k.Secret)) } return sb.String() } func commentBlock(prefix, text string) string { var sb strings.Builder for _, l := range strings.Split(strings.TrimRight(text, "\n"), "\n") { l = strings.TrimRight(l, " \t\r") if l == "" { sb.WriteString(prefix + "\n") } else { sb.WriteString(prefix + " " + l + "\n") } } return sb.String() } // confWriter tracks line numbers for the source map. type confWriter struct { sb strings.Builder line int // number of the next line to be written spans []Span open []int } func newConfWriter() *confWriter { return &confWriter{line: 1} } func (w *confWriter) write(s string) { w.sb.WriteString(s) w.line += strings.Count(s, "\n") } func (w *confWriter) begin(objType string, id int64, label string) { w.spans = append(w.spans, Span{Start: w.line, ObjectType: objType, ObjectID: id, Label: label}) w.open = append(w.open, len(w.spans)-1) } func (w *confWriter) end() { i := w.open[len(w.open)-1] w.open = w.open[:len(w.open)-1] w.spans[i].End = w.line - 1 } // optionText formats an option as a statement at the given indentation. func optionText(o *model.Option, indent int) string { var prefix string if o.Comment != "" { prefix = indentLines(commentBlock("//", o.Comment), indent) } st, err := namedconf.ParseOptionValue(o.Name, o.Value) if err != nil { // Validation rejects unparsable options; emit as-is so // named-checkconf reports the problem. return prefix + strings.Repeat("\t", indent) + o.Text() + "\n" } return prefix + formatStmt(st, indent) } func indentLines(s string, indent int) string { tabs := strings.Repeat("\t", indent) var sb strings.Builder for _, l := range strings.SplitAfter(s, "\n") { if l != "" { sb.WriteString(tabs + l) } } return sb.String() } func amlBlock(elems []string) string { if len(elems) == 0 { return "{ }" } var parts []string for _, e := range elems { parts = append(parts, normalizeElement(e)+";") } return "{ " + strings.Join(parts, " ") + " }" } func normalizeElement(e string) string { if el, err := namedconf.ParseAMElement(e); err == nil { return el.String() } return strings.TrimSuffix(strings.TrimSpace(e), ";") } func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Span) { w := newConfWriter() w.write(commentBlock("//", headerText)) w.write("\n") w.write(fmt.Sprintf("include %s;\n", namedconf.QuoteString(p.KeysConfig()))) st := x.Stats logResponses := st != nil && st.LogFile != "" pzs := PolicyZones(s, p) out.Policy = pzs logRewrites := logResponses && len(pzs) > 0 // Rate limiting logs what it limits (or would, in log-only mode) as // query-errors; the statistics count it (docs/exposure.md). logRRL := logRateLimit(s, st) sawStats, sawLogging := false, false if top := s.ScopedOptions(model.ScopeTopLevel, 0); len(top) > 0 { w.write("\n") for _, o := range top { w.begin("option", o.ID, "top-level statement "+o.Name) switch { case st != nil && o.Name == "statistics-channels" && !sawStats: w.write(st.statsStatement(o)) sawStats = true case logResponses && o.Name == "logging" && !sawLogging: w.write(st.loggingStatement(o, logRewrites, logRRL)) sawLogging = true default: w.write(optionText(o, 0)) } w.end() } } if st != nil && (!sawStats || logResponses && !sawLogging) { w.write("\n") w.begin("settings", 0, "query statistics settings (stats in jarl.yaml)") if !sawStats { w.write(st.statsStatement(nil)) } if logResponses && !sawLogging { w.write(st.loggingStatement(nil, logRewrites, logRRL)) } w.end() } if acls := s.SortedACLs(); len(acls) > 0 { w.write("\n") for _, a := range acls { w.begin("acl", a.ID, "ACL "+a.Name) if a.Comment != "" { w.write(commentBlock("//", a.Comment)) } st, err := namedconf.ParseStatement("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements)) if err != nil { w.write("acl " + namedconf.QuoteString(a.Name) + " " + amlBlock(a.Elements) + ";\n") } else { w.write(formatStmt(st, 0)) } w.end() } } w.write("\noptions {\n") for _, o := range s.ScopedOptions(model.ScopeOptions, 0) { w.begin("option", o.ID, "server option "+o.Name) w.write(optionText(o, 1)) w.end() } if logResponses && s.FindOption(model.ScopeOptions, 0, "responselog") == nil { w.begin("settings", 0, "query statistics settings (stats in jarl.yaml)") w.write("\t// " + statsComment + "\n\tresponselog yes;\n") w.end() } views := s.OrderedViews() if len(views) == 0 { writeResponsePolicy(w, scopeZones(pzs, 0), logResponses) } w.write("};\n") if len(views) == 0 { writePolicyZones(w, s, scopeZones(pzs, 0), out, 0) for _, z := range s.ZonesInView(0) { w.write("\n") renderZone(w, s, z, p, out, 0) } } for _, v := range views { w.write("\n") w.begin("view", v.ID, "view "+v.Name) if v.Comment != "" { w.write(commentBlock("//", v.Comment)) } w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name))) if mc := viewMatchClients(s, x, v); len(mc) > 0 { writeAML(w, "match-clients", mc) } if len(v.MatchDestinations) > 0 { writeAML(w, "match-destinations", v.MatchDestinations) } if v.MatchRecursiveOnly != nil { w.write(fmt.Sprintf("\tmatch-recursive-only %s;\n", yesNo(*v.MatchRecursiveOnly))) } for _, o := range s.ScopedOptions(model.ScopeView, v.ID) { w.begin("option", o.ID, "view "+v.Name+" option "+o.Name) w.write(optionText(o, 1)) w.end() } writeResponsePolicy(w, scopeZones(pzs, v.ID), logResponses) writePolicyZones(w, s, scopeZones(pzs, v.ID), out, 1) for _, z := range s.ZonesInView(v.ID) { w.write("\n") renderZone(w, s, z, p, out, 1) } w.write("};\n") w.end() } return w.sb.String(), w.spans } func writeAML(w *confWriter, name string, elems []string) { st, err := namedconf.ParseStatement(name + " " + amlBlock(elems)) if err != nil { w.write("\t" + name + " " + amlBlock(elems) + ";\n") return } w.write(formatStmt(st, 1)) } func yesNo(b bool) string { if b { return "yes" } return "no" } func renderZone(w *confWriter, s *model.State, z *model.Zone, p Paths, out *Output, indent int) { tabs := strings.Repeat("\t", indent) label := "zone " + dnsname.Display(z.Name) if v := s.ViewName(z); v != "" { label += " [view " + v + "]" } if z.Disabled { // Kept in Jarl (its zone file too, so enabling it is quick), not // loaded by named. w.write(fmt.Sprintf("%s// zone %s is disabled in Jarl: named doesn't load it.\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name)))) return } w.begin("zone", z.ID, label) if z.Comment != "" { w.write(indentLines(commentBlock("//", z.Comment), indent)) } w.write(fmt.Sprintf("%szone %s {\n", tabs, namedconf.QuoteString(dnsname.Display(z.Name)))) if z.Type == model.ZoneInView { w.write(fmt.Sprintf("%s\tin-view %s;\n", tabs, namedconf.QuoteString(z.InView))) } else { w.write(fmt.Sprintf("%s\ttype %s;\n", tabs, z.Type)) } if path := ZoneFilePath(s, z, p); path != "" { w.write(fmt.Sprintf("%s\tfile %s;\n", tabs, namedconf.QuoteString(path))) out.ZoneFiles[z.ID] = path switch z.Type { case model.ZoneSecondary, model.ZoneMirror, model.ZoneStub: if z.File == "" { out.Dirs = appendUnique(out.Dirs, filepath.Dir(path)) } } } if z.Signed() { // named signs the zone itself (inline signing): it keeps the // signed copy next to Jarl's file (.signed) and its keys in dir. dir := KeyDir(s, z, p) w.write(fmt.Sprintf("%s\tdnssec-policy %s;\n", tabs, namedconf.QuoteString(z.DNSSECPolicy))) w.write(fmt.Sprintf("%s\tkey-directory %s;\n", tabs, namedconf.QuoteString(dir))) out.Dirs = appendUnique(out.Dirs, dir) } for _, o := range s.ScopedOptions(model.ScopeZone, z.ID) { w.begin("option", o.ID, label+" option "+o.Name) w.write(optionText(o, indent+1)) w.end() } w.write(tabs + "};\n") w.end() } func appendUnique(list []string, s string) []string { for _, x := range list { if x == s { return list } } return append(list, s) } // ZoneFile renders the zone file of a primary zone. func ZoneFile(s *model.State, z *model.Zone) string { var sb strings.Builder title := "Zone " + dnsname.Display(z.Name) if v := s.ViewName(z); v != "" { title += " (view " + v + ")" } sb.WriteString(commentBlock(";", title+"\n"+headerText)) if z.Comment != "" { sb.WriteString(commentBlock(";", z.Comment)) } fmt.Fprintf(&sb, "\n$ORIGIN %s\n$TTL %d\n", z.Name, z.DefaultTTL) soa := z.SOA fmt.Fprintf(&sb, "@\tIN\tSOA\t%s %s (\n\t\t\t%d\t; serial\n\t\t\t%d\t; refresh\n\t\t\t%d\t; retry\n\t\t\t%d\t; expire\n\t\t\t%d )\t; minimum (negative caching TTL)\n", soa.MName, soa.RName, soa.Serial, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum) recs := s.ZoneRecords(z.ID) prev := "" for _, r := range recs { owner := dnsname.Relative(r.Name, z.Name) if r.Name != prev { sb.WriteString("\n") prev = r.Name } ttl := "" if r.TTL != nil { ttl = fmt.Sprint(*r.TTL) } line := fmt.Sprintf("%s\t%s\tIN\t%s\t%s", owner, ttl, r.Type, r.Data) if r.Comment != "" { line += "\t; " + oneLine(r.Comment) } if r.Disabled { // Kept in Jarl, left out of the zone. line = "; disabled: " + line } sb.WriteString(line + "\n") } return sb.String() } func oneLine(s string) string { s = strings.ReplaceAll(s, "\r", " ") return strings.ReplaceAll(s, "\n", " ") } // ZoneContentHash returns a hash of the DNS data of a primary zone: its // records and SOA fields, excluding the serial and comments. It changes // exactly when the zone's serial must be bumped. func ZoneContentHash(s *model.State, z *model.Zone) string { h := sha256.New() soa := z.SOA fmt.Fprintf(h, "%s|%d|%s|%s|%d|%d|%d|%d\n", z.Name, z.DefaultTTL, soa.MName, soa.RName, soa.Refresh, soa.Retry, soa.Expire, soa.Minimum) for _, r := range s.ServedRecords(z.ID) { fmt.Fprintf(h, "%s|%d|%s|%s\n", r.Name, r.EffectiveTTL(z), r.Type, r.Data) } return hex.EncodeToString(h.Sum(nil)) } // formatStmt lays out a statement for named.conf, with address ranges // (Jarl's own syntax) written as the prefixes that cover them. func formatStmt(st *namedconf.Stmt, indent int) string { namedconf.ExpandRanges(st) return namedconf.FormatStmt(st, indent) }