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

+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)
}
}