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

+8
View File
@@ -8,6 +8,14 @@ All notable changes to Jarl are documented here. The format follows
### Added ### Added
- View keys: every view gets a TSIG key, `jarl-view-NAME`, at the front of
its match-clients, so a query signed with it lands in that view from any
address. Jarl signs its delegation check's queries to this server's own
addresses with the key of the zone's view: before, they got whichever
view matched the server's own address (usually internal), so the check
could report the wrong serial for the external view. `jarl view key
VIEW` shows a key, for `dig -k` (docs/views.md). The first apply after
upgrading adds the keys to named.conf and keys.conf.
- Server statistics: named's memory (resident, and its own count) and - Server statistics: named's memory (resident, and its own count) and
CPU use, its cache size, sockets, and recursive and TCP clients against CPU use, its cache size, sockets, and recursive and TCP clients against
their limits; the host's memory and load; free space where zone files, their limits; the host's memory and load; free space where zone files,
+39 -1
View File
@@ -201,7 +201,45 @@ func viewCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.AddCommand(list, create, update, del, order, color) var keyNamed bool
key := &cobra.Command{
Use: "key VIEW",
Short: "Show the TSIG key that selects a view, to query it from anywhere",
Long: `Every view has a TSIG key (jarl-view-NAME) at the front of its
match-clients: a query signed with it lands in that view, whatever address
it comes from. Jarl signs its own checks of this server with it; use it to
see what a view answers:
jarl view key external # shows a ready dig -y command
jarl view key external --named-conf > external.key
dig -k external.key @SERVER adbraun.lol SOA
Showing it needs tsig:write and is recorded in the audit log.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var k struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Secret string `json:"secret"`
}
if err := c.Get(ctx(), "/views/"+url.PathEscape(args[0])+"/key", nil, &k); err != nil {
return err
}
if keyNamed {
fmt.Printf("key %q {\n\talgorithm %s;\n\tsecret %q;\n};\n", k.Name, k.Algorithm, k.Secret)
return nil
}
return output(k, func(w io.Writer) {
fmt.Fprintf(w, "Key:\t%s\nAlgorithm:\t%s\nSecret:\t%s\ndig:\tdig -y %s:%s:%s @SERVER NAME\n", k.Name, k.Algorithm, k.Secret, k.Algorithm, k.Name, k.Secret)
})
},
}
key.Flags().BoolVar(&keyNamed, "named-conf", false, "print a key statement (for dig -k FILE)")
cmd.AddCommand(list, create, update, del, order, color, key)
return cmd return cmd
} }
+5 -1
View File
@@ -302,7 +302,11 @@ func renderCmd() *cobra.Command {
return err return err
} }
paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")} paths := render.Paths{ConfigDir: filepath.Join(out, "config"), ZoneDir: filepath.Join(out, "zones"), CacheDir: filepath.Join(out, "cache")}
o := render.Render(st, paths, renderExtras(a.cfg)) x := renderExtras(a.cfg)
if x.ViewKeySeed, err = engine.ViewKeySeed(context.Background(), a.store); err != nil {
return err
}
o := render.Render(st, paths, x)
for _, f := range o.Files { for _, f := range o.Files {
if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil {
return err return err
+1
View File
@@ -386,6 +386,7 @@ Disabling a record that is already disabled is a 409; with `missing_ok=1`
| GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones | | GET / PATCH / DELETE | `/views/{id}` | DELETE `?cascade=1` also deletes the view's zones |
| POST | `/views/order` | `{"ids": [3, 1, 2]}` | | POST | `/views/order` | `{"ids": [3, 1, 2]}` |
| PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default | | PUT | `/views/{id}/color` | `{"color": "green"}`; `""` for the default |
| GET | `/views/{id}/key` | `{name, algorithm, secret}`: the TSIG key that selects the view (docs/views.md); needs `tsig:write`, audited |
`adopt_zones` moves zones that are not in a view into the new view. named `adopt_zones` moves zones that are not in a view into the new view. named
requires every zone to be in a view as soon as one view exists. requires every zone to be in a view as soon as one view exists.
+1
View File
@@ -368,6 +368,7 @@ jarl view order internal external
jarl view update external --match-clients "!homelab,any" jarl view update external --match-clients "!homelab,any"
jarl view delete old [--cascade] jarl view delete old [--cascade]
jarl view color internal green # web UI colour; "default" resets it jarl view color internal green # web UI colour; "default" resets it
jarl view key external [--named-conf] # the key that selects the view (dig -k), docs/views.md
jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost jarl acl create homelab 192.168.1.0/24 10.10.0.0/16 localhost
jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16 jarl acl update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16
+1 -1
View File
@@ -71,7 +71,7 @@ showing them.
| `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist | | `acl_ref_missing`, `key_ref_missing` | error | Every ACL or TSIG key that is referenced must exist |
| `acl_cycle` | error | ACLs that include each other | | `acl_cycle` | error | ACLs that include each other |
| `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match | | `acl_duplicate`, `acl_name_invalid`, `acl_empty`, `acl_shadowed` | error / warning | Unique names, built-in names reserved, elements that can never match |
| `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak | | `key_*` | error / warning | Valid key names, a supported algorithm, a base64 secret of a sensible length. hmac-md5/sha1 are flagged as weak. Names starting with `jarl-view-` are reserved for the view keys (`key_name_reserved`) |
| `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists | | `view_duplicate`, `view_name_invalid`, `view_acl_invalid` | error | Valid, unique views and match lists |
| `view_unreachable` | warning | A view that comes after one matching every client is never used | | `view_unreachable` | warning | A view that comes after one matching every client is never used |
| `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) | | `option_unknown`, `option_scope_invalid` | error | The option must exist in BIND 9.20 and be allowed where it is set (server, view, zone of this type, top level) |
+41 -2
View File
@@ -116,7 +116,44 @@ dig @<public-ip> example.com # from outside: status R
jarl check jarl check
``` ```
On the server, `dig -b <source address>` picks which view you test. On the server, `dig -b <source address>` picks which view you test, or,
from anywhere, sign the query with the view's key (below):
```sh
jarl view key external --named-conf > external.key
dig -k external.key @ns1.adbraun.lol nas.adbraun.lol +short # 203.0.113.20, even from the LAN
```
## View keys
named picks a view by where a query comes from, so a query this server
sends to its own public address usually lands in the internal view: its
source is one of your own networks. That makes checks of the external view
from the server itself (such as Jarl's delegation check) see the wrong
view.
So Jarl gives every view a TSIG key, `jarl-view-NAME`, and puts it at the
front of the view's `match-clients`:
```
view "internal" {
match-clients { !key jarl-view-external; key jarl-view-internal; homelab; };
...
view "external" {
match-clients { key jarl-view-external; any; };
```
A query signed with a view's key lands in that view, whatever its source;
the `!key` stops an earlier view from taking a later view's queries.
Clients don't send these keys, so for them nothing changes. Jarl signs its
own queries to this server's addresses with the key of the zone's view.
The keys live in `keys.conf` with your other keys, never in diffs or in
`jarl tsig list`; their secrets are derived from a random value in Jarl's
database (and in its backups), so they stay the same from one apply to
the next. `jarl view key VIEW`
shows one (it needs `tsig:write` and is audited). Key names starting with
`jarl-view-` are reserved, and the view keys can be used in your own
options (`allow-transfer { key jarl-view-external; }`).
## Living with views ## Living with views
@@ -130,7 +167,9 @@ On the server, `dig -b <source address>` picks which view you test.
`jarl view list` shows it. `jarl view list` shows it.
* Deleting a view is refused while it still has zones, unless you pass * Deleting a view is refused while it still has zones, unless you pass
`--cascade`. `--cascade`.
* Give each view a colour (Views → Edit → Colour, or * Each view has its own page (Views → the view's name): its settings,
options and zones.
* Give each view a colour (the view's page → Settings → Colour, or
`jarl view color internal green`) to tell the zones of each view apart `jarl view color internal green`) to tell the zones of each view apart
in the zone list. The colour only affects the web UI, so it applies at in the zone list. The colour only affects the web UI, so it applies at
once, with no changeset or deploy. `jarl view color internal default` once, with no changeset or deploy. `jarl view color internal default`
+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) e.log.Warn("blocklists: loading their status failed", "err", err)
return 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 { if err := e.ensureBlocklistFiles(out.Blocklists); err != nil {
e.log.Warn("blocklists: creating empty zone files failed", "err", err) e.log.Warn("blocklists: creating empty zone files failed", "err", err)
} }
+6 -3
View File
@@ -75,6 +75,9 @@ type Engine struct {
Limits BlocklistLimits Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go) 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. // 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...) pl.introduced = append(pl.introduced, problems...)
return pl, nil 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) deployed, err := e.store.DeployedFiles(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -473,7 +476,7 @@ func (e *Engine) makePlan(ctx context.Context, base, work *model.State, user str
pl.diff = diff.String() pl.diff = diff.String()
// Which zones named must reload, and which serials to verify. // 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{} changedPaths := map[string]bool{}
for _, f := range pl.changed { for _, f := range pl.changed {
changedPaths[f.Path] = true changedPaths[f.Path] = true
@@ -529,7 +532,7 @@ func (e *Engine) stagingCheck(ctx context.Context, pl *plan) error {
} }
defer os.RemoveAll(dir) defer os.RemoveAll(dir)
sp := render.Paths{ConfigDir: filepath.Join(dir, "config"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")} 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 { for _, d := range sout.Dirs {
if err := os.MkdirAll(d, 0o750); err != nil { if err := os.MkdirAll(d, 0o750); err != nil {
return err 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 { if err != nil {
return nil // named-checkconf couldn't run; an apply reports that 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 var ps []validate.Problem
for _, m := range res.Messages { for _, m := range res.Messages {
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone} 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) return fmt.Errorf("%s: key without a block", s.Pos)
} }
name := model.NormalizeKeyName(s.Arg(0)) 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 := "", "" alg, secret := "", ""
if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil { if a := namedconf.FindOne(b.Stmts, "algorithm"); a != nil {
alg = strings.ToLower(a.Arg(0)) alg = strings.ToLower(a.Arg(0))
@@ -433,7 +437,7 @@ func (im *importer) view(s *namedconf.Stmt) error {
switch kw := st.Keyword(); kw { switch kw := st.Keyword(); kw {
case "match-clients": case "match-clients":
if mb := st.FirstBlock(); mb != nil { if mb := st.FirstBlock(); mb != nil {
v.MatchClients = nonNil(elementStrings(mb)) v.MatchClients = withoutViewKeys(nonNil(elementStrings(mb)))
} }
case "match-destinations": case "match-destinations":
if mb := st.FirstBlock(); mb != nil { if mb := st.FirstBlock(); mb != nil {
@@ -807,3 +811,23 @@ func allowsNone(o *namedconf.Stmt) bool {
} }
return true 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) 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 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, // NormalizeKeyName returns the canonical form of a key name: lowercase,
// without a trailing dot. // without a trailing dot.
func NormalizeKeyName(name string) string { 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: // IsLocal reports whether an address belongs to this host (default:
// its interface addresses and loopback). // its interface addresses and loopback).
IsLocal func(netip.Addr) bool 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 // 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). // its outcome as it happens (the web UI shows them while it waits).
// It is called from several goroutines at once. // 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 // Reported here rather than by exchange: the end carries the
// server's status. // 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) { go func(r Result, key *Key) {
defer wg.Done() defer wg.Done()
c.soa(ctx, zone, key, ref, &r) c.soa(ctx, zone, key, ref, &r)
c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs}) c.endEvent(Event{ID: id, Result: &r, RTTMs: r.RTTMs})
add(r) add(r)
}(r, t.Key) }(r, key)
} }
} }
wg.Wait() 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). // from jarld's configuration (e.g. for query statistics).
func Render(s *model.State, p Paths, x Extras) *Output { func Render(s *model.State, p Paths, x Extras) *Output {
out := &Output{Spans: map[string][]Span{}, ZoneFiles: map[int64]string{}} 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}) 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) conf, spans := renderConfig(s, p, x, out)
out.Files = append(out.Files, &File{Path: p.MainConfig(), Content: []byte(conf), Mode: 0o640, Kind: KindConfig}) 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(commentBlock("//", v.Comment))
} }
w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name))) w.write(fmt.Sprintf("view %s {\n", namedconf.QuoteString(v.Name)))
if len(v.MatchClients) > 0 { if mc := viewMatchClients(s, x, v); len(mc) > 0 {
writeAML(w, "match-clients", v.MatchClients) writeAML(w, "match-clients", mc)
} }
if len(v.MatchDestinations) > 0 { if len(v.MatchDestinations) > 0 {
writeAML(w, "match-destinations", v.MatchDestinations) 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 // Stats, when set, makes named publish what Jarl's statistics
// collector reads. // collector reads.
Stats *StatsHooks 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. // 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/dnsname"
"jarl/internal/engine" "jarl/internal/engine"
"jarl/internal/model" "jarl/internal/model"
"jarl/internal/render"
"jarl/internal/rr" "jarl/internal/rr"
) )
@@ -92,6 +93,31 @@ func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Princip
return nil 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 { func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any {
return func(st *model.State, id int64) any { return func(st *model.State, id int64) any {
if v := st.Views[id]; v != nil { 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) 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() 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 != "" { if v := r.URL.Query().Get("resolver"); v != "" {
// An address only, always port 53: this is a DNS resolver. // An address only, always port 53: this is a DNS resolver.
a, err := netip.ParseAddr(v) 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", s.authed(s.createView))
m.HandleFunc("POST "+p+"/views/order", s.authed(s.reorderViews)) 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}", 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("PATCH "+p+"/views/{id}", s.authed(s.updateView))
m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView)) m.HandleFunc("DELETE "+p+"/views/{id}", s.authed(s.deleteView))
m.HandleFunc("PUT "+p+"/views/{id}/color", s.authed(s.setViewColor)) 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") c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil { } 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) 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] minLen, ok := TSIGAlgorithms[k.Algorithm]
if !ok { if !ok {
+4
View File
@@ -126,6 +126,10 @@ func State(s *model.State, opts Options) []Problem {
for _, k := range s.Keys { for _, k := range s.Keys {
c.keyNames[k.Name] = true 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 { for _, k := range opts.ExternalKeys {
c.keyNames[model.NormalizeKeyName(k)] = true c.keyNames[model.NormalizeKeyName(k)] = true
} }
+7
View File
@@ -297,6 +297,13 @@ contains "token.revoke"
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
run "view create external" 0 $J view create external --match-clients any run "view create external" 0 $J view create external --match-clients any
run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1" run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1"
# View keys: a query signed with a view's key lands in that view, from
# any address (this host is in homelab, so unsigned it gets internal).
run "view key" 0 sh -c "$J view key external --named-conf > $E/external.key"
expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test A
expect_dig "signed with the external view's key" "203.0.113.1" -k $E/external.key ns1.adbraun.test A
run "view key shows a dig command" 0 $J view key external
contains "dig -y hmac-sha256:jarl-view-external:"
# named-checkconf's own warnings stay on the dashboard after the apply. # named-checkconf's own warnings stay on the dashboard after the apply.
run "option set recursion no (external)" 0 $J option set recursion no --view external run "option set recursion no (external)" 0 $J option set recursion no --view external
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external