A disabled record stays in Jarl with its ID, TTL and comment but is left out of the zone; the zone file lists it as a comment. Web UI toggle, jarl record disable|enable, record.disable|enable operations and API endpoints. Database schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
490 lines
15 KiB
Go
490 lines
15 KiB
Go
// 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
|
|
}
|
|
|
|
// 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)
|
|
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 + namedconf.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 != ""
|
|
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))
|
|
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))
|
|
}
|
|
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(namedconf.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()
|
|
}
|
|
w.write("};\n")
|
|
|
|
views := s.OrderedViews()
|
|
if len(views) == 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 len(v.MatchClients) > 0 {
|
|
writeAML(w, "match-clients", v.MatchClients)
|
|
}
|
|
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()
|
|
}
|
|
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(namedconf.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 + "]"
|
|
}
|
|
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))
|
|
}
|