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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 18:19:10 +01:00
1 parent c696d02513
commit 384f6b3060
26 files changed
+463 -16

No files matched your search

+1 -1
View File
@@ -78,7 +78,7 @@ func (e *Engine) RefreshDue(ctx context.Context) {
e.log.Warn("blocklists: loading their status failed", "err", err)
return
}
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
e.log.Warn("blocklists: creating empty zone files failed", "err", err)
}
+6 -3
View File
@@ -75,6 +75,9 @@ type Engine struct {
Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
seedMu sync.Mutex
seed []byte // the view keys' seed (viewkeys.go)
}
// New creates an engine.
@@ -437,7 +440,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.introduced = append(pl.introduced, problems...)
return pl, nil
}
pl.out = render.Render(work, e.cfg.Paths, e.cfg.Extras)
pl.out = render.Render(work, e.cfg.Paths, e.extras(ctx))
deployed, err := e.store.DeployedFiles(ctx)
if err != nil {
return nil, err
@@ -473,7 +476,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.diff = diff.String()
// Which zones named must reload, and which serials to verify.
baseOut := render.Render(base, e.cfg.Paths, e.cfg.Extras)
baseOut := render.Render(base, e.cfg.Paths, e.extras(ctx))
changedPaths := map[string]bool{}
for _, f := range pl.changed {
changedPaths[f.Path] = true
@@ -529,7 +532,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
}
defer os.RemoveAll(dir)
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")}
sout := render.Render(pl.work, sp, e.cfg.Extras)
sout := render.Render(pl.work, sp, e.extras(ctx))
for _, d := range sout.Dirs {
if err := os.MkdirAll(d, 0o750); err != nil {
return err
+1 -1
View File
@@ -54,7 +54,7 @@ func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed ma
if err != nil {
return nil // named-checkconf couldn't run; an apply reports that
}
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
out := render.Render(st, e.cfg.Paths, e.extras(ctx))
var ps []validate.Problem
for _, m := range res.Messages {
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
+68
View File
@@ -0,0 +1,68 @@
package engine
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"jarl/internal/model"
"jarl/internal/nscheck"
"jarl/internal/render"
"jarl/internal/store"
)
// viewKeySeedMeta is where the seed of the view keys is kept.
const viewKeySeedMeta = "view_key_seed"
// ViewKeySeed returns the seed the view keys' secrets are derived from
// (render/viewkeys.go), creating it the first time.
func ViewKeySeed(ctx context.Context, st *store.Store) ([]byte, error) {
v, err := st.GetMeta(ctx, viewKeySeedMeta)
if err == nil {
return base64.StdEncoding.DecodeString(v)
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
seed := make([]byte, 32)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
if err := st.SetMeta(ctx, viewKeySeedMeta, base64.StdEncoding.EncodeToString(seed)); err != nil {
return nil, err
}
return seed, nil
}
// extras returns what jarld's configuration adds to the rendered
// configuration, with the view keys' seed.
func (e *Engine) extras(ctx context.Context) render.Extras {
x := e.cfg.Extras
e.seedMu.Lock()
defer e.seedMu.Unlock()
if e.seed == nil {
seed, err := ViewKeySeed(ctx, e.store)
if err != nil {
e.log.Warn("view keys: reading their seed failed", "err", err)
return x
}
e.seed = seed
}
x.ViewKeySeed = e.seed
return x
}
// ViewKey returns the TSIG key that selects a zone's view on this server
// (nil without views).
func (e *Engine) ViewKey(ctx context.Context, st *model.State, z *model.Zone) *nscheck.Key {
v := st.Views[z.ViewID]
if v == nil {
return nil
}
x := e.extras(ctx)
if len(x.ViewKeySeed) == 0 {
return nil
}
return &nscheck.Key{Name: render.ViewKeyName(v.Name), Algorithm: render.ViewKeyAlgorithm, Secret: render.ViewKeySecret(x.ViewKeySeed, v.ID)}
}
+25 -1
View File
@@ -393,6 +393,10 @@ func (im *importer) key(s *namedconf.Stmt) error {
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))
@@ -433,7 +437,7 @@ func (im *importer) view(s *namedconf.Stmt) error {
switch kw := st.Keyword(); kw {
case "match-clients":
if mb := st.FirstBlock(); mb != nil {
v.MatchClients = nonNil(elementStrings(mb))
v.MatchClients = withoutViewKeys(nonNil(elementStrings(mb)))
}
case "match-destinations":
if mb := st.FirstBlock(); mb != nil {
@@ -807,3 +811,23 @@ func allowsNone(o *namedconf.Stmt) bool {
}
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
}
+12
View File
@@ -205,3 +205,15 @@ view "internal" {
t.Errorf("imported state has errors: %v", ps)
}
}
func TestWithoutViewKeys(t *testing.T) {
for _, tc := range []struct{ in, want []string }{
{[]string{"!key jarl-view-external", `key "jarl-view-internal"`, "homelab"}, []string{"homelab"}},
{[]string{"key jarl-view-external", "any"}, []string{}},
{[]string{"key xfer", "any"}, []string{"key xfer", "any"}},
} {
if got := withoutViewKeys(tc.in); strings.Join(got, ";") != strings.Join(tc.want, ";") || got == nil {
t.Errorf("%v: %v, want %v", tc.in, got, tc.want)
}
}
}
+7
View File
@@ -384,6 +384,13 @@ func (s *State) KeyByName(name string) *TSIGKey {
return nil
}
// ViewKeyPrefix starts the names of the TSIG keys Jarl gives views
// (render/viewkeys.go); other keys can't use it.
const ViewKeyPrefix = "jarl-view-"
// ViewKeyName returns the name of a view's key.
func ViewKeyName(view string) string { return ViewKeyPrefix + view }
// NormalizeKeyName returns the canonical form of a key name: lowercase,
// without a trailing dot.
func NormalizeKeyName(name string) string {
+52
View File
@@ -0,0 +1,52 @@
package nscheck
import (
"context"
"net"
"net/netip"
"testing"
"time"
"github.com/miekg/dns"
)
// A query to this host's own address is signed with LocalKey, so a server
// with views answers from the view that key selects.
func TestLocalKey(t *testing.T) {
zone := "example.com."
key := &Key{Name: "jarl-view-external.", Algorithm: "hmac-sha256", Secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LXNlY3I="}
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Skip(err)
}
_, port, _ := net.SplitHostPort(pc.LocalAddr().String())
srv := &dns.Server{PacketConn: pc, TsigSecret: map[string]string{key.Name: key.Secret},
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, req *dns.Msg) {
// Signed with the view key: the "external" view's serial.
serial := "1"
signed := req.IsTsig() != nil && w.TsigStatus() == nil
if signed {
serial = "2"
}
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
m.Answer = []dns.RR{rr(zone + " 3600 IN SOA ns1." + zone + " admin." + zone + " " + serial + " 3600 900 1209600 300")}
if signed {
m.SetTsig(key.Name, dns.HmacSHA256, 300, time.Now().Unix())
}
w.WriteMsg(m)
})}
go srv.ActivateAndServe()
t.Cleanup(func() { srv.Shutdown() })
ref := uint32(2)
c := &Checker{Timeout: time.Second, Port: port, IsLocal: func(netip.Addr) bool { return false }}
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "behind" {
t.Errorf("unsigned: %+v (want the other view's serial)", res[0])
}
c.LocalKey = key
if res := c.Serials(context.Background(), zone, &ref, []Target{{Addresses: []string{"127.0.0.1"}}}); res[0].Status != "in_sync" {
t.Errorf("signed with the view key: %+v", res[0])
}
}
+11 -2
View File
@@ -95,6 +95,11 @@ type Checker struct {
// IsLocal reports whether an address belongs to this host (default:
// its interface addresses and loopback).
IsLocal func(netip.Addr) bool
// LocalKey, when set, signs the queries sent to this host's own
// addresses (when they are asked): with views, the key that selects
// the zone's view, so the answer comes from that view and not from
// whichever view matches this host's address.
LocalKey *Key
// Watch, if set, is told about each DNS query the checks send and
// its outcome as it happens (the web UI shows them while it waits).
// It is called from several goroutines at once.
@@ -465,13 +470,17 @@ func (c *Checker) Serials(ctx context.Context, zone string, ref *uint32, targets
}
// Reported here rather than by exchange: the end carries the
// server's status.
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, t.Key))
key := t.Key
if key == nil && c.LocalKey != nil && IsLocalAddr(ip) {
key = c.LocalKey
}
id := c.startEvent(digLine(r.Address, port, zone, "SOA", true, key))
go func(r Result, key *Key) {
defer wg.Done()
c.soa(ctx, zone, key, ref, &r)
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
add(r)
}(r, t.Key)
}(r, key)
}
}
wg.Wait()
+3 -3
View File
@@ -149,7 +149,7 @@ const headerText = "Generated by Jarl. DO NOT EDIT: changes made here are overwr
// 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)
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})
@@ -350,8 +350,8 @@ func renderConfig(s *model.State, p Paths, x Extras, out *Output) (string, []Spa
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 mc := viewMatchClients(s, x, v); len(mc) > 0 {
writeAML(w, "match-clients", mc)
}
if len(v.MatchDestinations) > 0 {
writeAML(w, "match-destinations", v.MatchDestinations)
+3
View File
@@ -15,6 +15,9 @@ type Extras struct {
// Stats, when set, makes named publish what Jarl's statistics
// collector reads.
Stats *StatsHooks
// ViewKeySeed, when set, gives every view a TSIG key that selects it
// (viewkeys.go).
ViewKeySeed []byte
}
// StatsHooks describe the statistics channel and response log Jarl needs.
+84
View File
@@ -0,0 +1,84 @@
package render
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"strconv"
"strings"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// View keys let Jarl choose a view regardless of where a query comes from:
// each view gets a TSIG key, and its match-clients starts with that key
// (after excluding the keys of the views listed after it, so no earlier
// view takes their queries). Queries signed with a view's key always land
// in that view. Jarl signs its own checks of this server's addresses with
// them (a query from this host would otherwise land in whichever view
// matches this host's address), and you can too: dig -k.
//
// The secrets are derived from a random seed kept in Jarl's database
// (Extras.ViewKeySeed), so they stay the same from one apply to the next
// and need no storage of their own. They are in keys.conf with the other
// keys.
// ViewKeyAlgorithm is the view keys' algorithm.
const ViewKeyAlgorithm = "hmac-sha256"
// ViewKeyName returns the name of a view's key.
func ViewKeyName(view string) string { return model.ViewKeyName(view) }
// IsViewKey reports whether a key name is a view key's.
func IsViewKey(name string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSuffix(name, ".")), model.ViewKeyPrefix)
}
// ViewKeySecret returns the secret of the key of the view with this ID.
func ViewKeySecret(seed []byte, viewID int64) string {
m := hmac.New(sha256.New, seed)
m.Write([]byte("jarl view key " + strconv.FormatInt(viewID, 10)))
return base64.StdEncoding.EncodeToString(m.Sum(nil))
}
// viewKeysOn reports whether view keys are rendered.
func viewKeysOn(s *model.State, x Extras) bool { return len(x.ViewKeySeed) > 0 && len(s.Views) > 0 }
// renderViewKeys returns the key statements of the views' keys.
func renderViewKeys(s *model.State, x Extras) string {
if !viewKeysOn(s, x) {
return ""
}
var sb strings.Builder
sb.WriteString("\n" + commentBlock("//", "View keys: a query signed with one of these always lands in its view.\nJarl signs its own checks of this server with them; dig -k works too."))
for _, v := range s.OrderedViews() {
sb.WriteString("key " + namedconf.QuoteString(ViewKeyName(v.Name)) + " {\n\talgorithm " + ViewKeyAlgorithm + ";\n\tsecret " + namedconf.QuoteString(ViewKeySecret(x.ViewKeySeed, v.ID)) + ";\n};\n")
}
return sb.String()
}
// viewMatchClients returns a view's match-clients with the view keys in
// front: "!key" for the keys of the views after it, then its own key, then
// the view's own list ("any" when it has none, which is what an absent
// match-clients means).
func viewMatchClients(s *model.State, x Extras, v *model.View) []string {
if !viewKeysOn(s, x) {
return v.MatchClients
}
var out []string
after := false
for _, o := range s.OrderedViews() {
if after {
out = append(out, "!key "+namedconf.QuoteString(ViewKeyName(o.Name)))
}
if o.ID == v.ID {
after = true
}
}
out = append(out, "key "+namedconf.QuoteString(ViewKeyName(v.Name)))
if len(v.MatchClients) == 0 {
return append(out, "any")
}
return append(out, v.MatchClients...)
}
+51
View File
@@ -0,0 +1,51 @@
package render
import (
"strings"
"testing"
"jarl/internal/model"
)
func TestViewKeys(t *testing.T) {
s := model.NewState()
s.Views[1] = &model.View{ID: 1, Name: "internal", Position: 0, MatchClients: []string{"homelab", "localhost"}}
s.Views[2] = &model.View{ID: 2, Name: "external", Position: 1}
p := Paths{ConfigDir: "/etc/bind/jarl", ZoneDir: "/var/lib/bind/jarl", CacheDir: "/var/cache/bind/jarl"}
seed := []byte("0123456789abcdef0123456789abcdef")
files := func(x Extras) (conf, keys string) {
for _, f := range Render(s, p, x).Files {
switch f.Kind {
case KindConfig:
conf = string(f.Content)
case KindKeys:
keys = string(f.Content)
}
}
return
}
conf, keys := files(Extras{ViewKeySeed: seed})
for _, want := range []string{
// An earlier view mustn't take a later view's key.
"match-clients {\n\t\t!key jarl-view-external;\n\t\tkey jarl-view-internal;\n\t\thomelab;\n\t\tlocalhost;\n\t};",
// A view without a list matched everyone: it still does.
"match-clients { key jarl-view-external; any; };",
} {
if !strings.Contains(conf, want) {
t.Errorf("named.conf lacks\n%s\n---\n%s", want, conf)
}
}
sec := ViewKeySecret(seed, 2)
if !strings.Contains(keys, "key \"jarl-view-external\" {\n\talgorithm hmac-sha256;\n\tsecret \""+sec+"\";\n};") || !strings.Contains(keys, "jarl-view-internal") {
t.Errorf("keys.conf:\n%s", keys)
}
// The same seed gives the same secret; another seed doesn't.
if ViewKeySecret(seed, 2) != sec || ViewKeySecret([]byte("other"), 2) == sec || ViewKeySecret(seed, 1) == sec {
t.Error("secrets aren't derived from the seed and the view")
}
// Without a seed (or without views), nothing changes.
conf, keys = files(Extras{})
if strings.Contains(conf, "jarl-view-") || strings.Contains(keys, "jarl-view-") {
t.Errorf("view keys without a seed:\n%s", conf)
}
}
+26
View File
@@ -14,6 +14,7 @@ import (
"jarl/internal/dnsname"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/rr"
)
@@ -92,6 +93,31 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
return nil
}
// viewKey: GET /views/{id}/key is the TSIG key that selects the view
// (render/viewkeys.go), with its secret: like revealing a TSIG key's, it
// needs tsig:write and is audited.
func (s *Server) viewKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
v, err := s.viewByPath(r, st, p)
if err != nil {
return err
}
if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil {
return forbidden("showing a view's key needs tsig:write")
}
seed, err := engine.ViewKeySeed(r.Context(), s.store)
if err != nil {
return err
}
name := render.ViewKeyName(v.Name)
s.eng.Audit(r.Context(), p, "tsig.reveal", "view", v.ID, v.Name, "success", "view key "+name+" shown")
writeJSON(w, http.StatusOK, map[string]string{"name": name, "algorithm": render.ViewKeyAlgorithm, "secret": render.ViewKeySecret(seed, v.ID)})
return nil
}
func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
return func(st *model.State, id int64) any {
if v := st.Views[id]; v != nil {
+3
View File
@@ -160,6 +160,9 @@ func (s *Server) zoneDelegation(w http.ResponseWriter, r *http.Request, p *auth.
return badRequest("the delegation check compares the parent's delegation with the zone's NS records, so it needs a primary zone (this is a %s zone; check it where its primary is)", z.Type)
}
c := s.checker()
// This server's own addresses are asked too: sign those queries with
// the key of the zone's view, so they get that view's answer.
c.LocalKey = s.eng.ViewKey(r.Context(), st, z)
if v := r.URL.Query().Get("resolver"); v != "" {
// An address only, always port 53: this is a DNS resolver.
a, err := netip.ParseAddr(v)
+1
View File
@@ -52,6 +52,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/views", s.authed(s.createView))
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews))
m.HandleFunc("GET "+p+"/views/{id}", s.authed(s.getView))
m.HandleFunc("GET "+p+"/views/{id}/key", s.authed(s.viewKey))
m.HandleFunc("PATCH "+p+"/views/{id}", s.authed(s.updateView))
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor))
+2
View File
@@ -57,6 +57,8 @@ func (c *checker) checkKeys() {
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
} else if strings.HasPrefix(strings.ToLower(k.Name), model.ViewKeyPrefix) {
c.errf("key_name_reserved", "key", k.ID, "%s: names starting with %s are kept for the keys Jarl gives views", label, model.ViewKeyPrefix)
}
minLen, ok := TSIGAlgorithms[k.Algorithm]
if !ok {
+4
View File
@@ -126,6 +126,10 @@ func State(s *model.State, opts Options) []Problem {
for _, k := range s.Keys {
c.keyNames[k.Name] = true
}
// The views' own keys (render/viewkeys.go) may be referred to too.
for _, v := range s.Views {
c.keyNames[model.NormalizeKeyName(model.ViewKeyName(v.Name))] = true
}
for _, k := range opts.ExternalKeys {
c.keyNames[model.NormalizeKeyName(k)] = true
}