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:
1 parent
c696d02513
commit
384f6b3060
26 files changed
+463
-16
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user