diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f09309..6e24c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to Jarl are documented here. The format follows ### 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 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, diff --git a/cmd/jarl/config_cmds.go b/cmd/jarl/config_cmds.go index 7e33c55..be26387 100644 --- a/cmd/jarl/config_cmds.go +++ b/cmd/jarl/config_cmds.go @@ -201,7 +201,45 @@ func viewCmd() *cobra.Command { 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 } diff --git a/cmd/jarld/admin.go b/cmd/jarld/admin.go index 65628aa..c0412a0 100644 --- a/cmd/jarld/admin.go +++ b/cmd/jarld/admin.go @@ -302,7 +302,11 @@ func renderCmd() *cobra.Command { return err } 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 { if err := os.MkdirAll(filepath.Dir(f.Path), 0o750); err != nil { return err diff --git a/docs/api.md b/docs/api.md index 83de411..18ef2b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | | POST | `/views/order` | `{"ids": [3, 1, 2]}` | | 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 requires every zone to be in a view as soon as one view exists. diff --git a/docs/cli.md b/docs/cli.md index d8d4e48..9ed9896 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -368,6 +368,7 @@ jarl view order internal external jarl view update external --match-clients "!homelab,any" jarl view delete old [--cascade] 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 update homelab --add 10.20.0.0/16 --remove 10.10.0.0/16 diff --git a/docs/validation.md b/docs/validation.md index 350fa02..30e676a 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -71,7 +71,7 @@ showing them. | `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_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_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) | diff --git a/docs/views.md b/docs/views.md index bc79f5c..c086482 100644 --- a/docs/views.md +++ b/docs/views.md @@ -116,7 +116,44 @@ dig @ example.com # from outside: status R jarl check ``` -On the server, `dig -b ` picks which view you test. +On the server, `dig -b ` 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 @@ -130,7 +167,9 @@ On the server, `dig -b ` picks which view you test. `jarl view list` shows it. * Deleting a view is refused while it still has zones, unless you pass `--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 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` diff --git a/internal/engine/blocklists.go b/internal/engine/blocklists.go index 49684bd..8bc7193 100644 --- a/internal/engine/blocklists.go +++ b/internal/engine/blocklists.go @@ -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) } diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 9e9cc1b..93dbf37 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -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 diff --git a/internal/engine/namedwarn.go b/internal/engine/namedwarn.go index 5a9159a..41b0c05 100644 --- a/internal/engine/namedwarn.go +++ b/internal/engine/namedwarn.go @@ -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} diff --git a/internal/engine/viewkeys.go b/internal/engine/viewkeys.go new file mode 100644 index 0000000..2d8bc52 --- /dev/null +++ b/internal/engine/viewkeys.go @@ -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)} +} diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 08a63ec..f183010 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -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 +} diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index c9de0a3..6929f78 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -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) + } + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 207ac8b..9cba0af 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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 { diff --git a/internal/nscheck/localkey_test.go b/internal/nscheck/localkey_test.go new file mode 100644 index 0000000..42906e7 --- /dev/null +++ b/internal/nscheck/localkey_test.go @@ -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]) + } +} diff --git a/internal/nscheck/nscheck.go b/internal/nscheck/nscheck.go index 7a9b5f3..60c8ecc 100644 --- a/internal/nscheck/nscheck.go +++ b/internal/nscheck/nscheck.go @@ -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() diff --git a/internal/render/render.go b/internal/render/render.go index ccbd199..49dc5ea 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -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) diff --git a/internal/render/stats.go b/internal/render/stats.go index 8da6c44..985fc60 100644 --- a/internal/render/stats.go +++ b/internal/render/stats.go @@ -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. diff --git a/internal/render/viewkeys.go b/internal/render/viewkeys.go new file mode 100644 index 0000000..d1726b7 --- /dev/null +++ b/internal/render/viewkeys.go @@ -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...) +} diff --git a/internal/render/viewkeys_test.go b/internal/render/viewkeys_test.go new file mode 100644 index 0000000..a973192 --- /dev/null +++ b/internal/render/viewkeys_test.go @@ -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) + } +} diff --git a/internal/server/h_config.go b/internal/server/h_config.go index 514bd46..08ef534 100644 --- a/internal/server/h_config.go +++ b/internal/server/h_config.go @@ -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 { diff --git a/internal/server/h_nscheck.go b/internal/server/h_nscheck.go index 792c57d..eecf99a 100644 --- a/internal/server/h_nscheck.go +++ b/internal/server/h_nscheck.go @@ -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) diff --git a/internal/server/routes.go b/internal/server/routes.go index b11c752..b1c7e49 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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)) diff --git a/internal/validate/config.go b/internal/validate/config.go index c8ba22f..2084c41 100644 --- a/internal/validate/config.go +++ b/internal/validate/config.go @@ -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 { diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 2c5c945..7f18b86 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -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 } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index a0f1730..47806fc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 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" +# 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. 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