Files
jarl/internal/render/render.go
T
adamandClaude Opus 5.5 bdd7acfd3b Internet exposure checks and response rate limiting
Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:56 +01:00

523 lines
16 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
// 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 := false
for _, o := range s.Options {
logRRL = logRRL || logResponses && o.Name == "rate-limit"
}
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)
}