Linked zones: shared records kept in step across views
Zones of the same name in different views can be linked. Shared records
are kept in step: a change in one view reaches the others in the same
changeset, as explicit operations, so the diff, permission checks,
history and undo cover every view. Records local to a view override the
shared records of that name and type there; removing the override brings
the shared ones back, and sharing a view's version makes it every view's.
Each zone keeps its own options, serial and file.
Linking existing zones changes no answer: what is the same everywhere
becomes shared, the rest stays local. Unlinking keeps every record, all
local. ACME challenges are written once per link, and --view all changes
a link once. jarl zone link/unlink, zone create --link-with, record
--local/--shared, LINK column, web UI controls, POST /zones/{zone}/link
and /unlink, validation checks linked_*, store migration 11, docs and
e2e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6313fa51f8
commit
057433c198
31 files changed
+1727
-53
No files matched your search
@@ -40,6 +40,20 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
### Added
|
||||
|
||||
- Linked zones ([docs/views.md](docs/views.md#linked-zones)): zones of the
|
||||
same name in different views can be linked, and Jarl keeps their shared
|
||||
records in step: a change in one view reaches the others in the same
|
||||
changeset (diff, permissions, history and undo cover every view). Each
|
||||
view can still have records of its own (`--local`, "Only here"), which
|
||||
override the shared records of that name and type there, and keeps its
|
||||
own zone options. Linking existing zones changes no answer: what is the
|
||||
same everywhere becomes shared, the rest stays local, and a view's
|
||||
version can be shared later (`--shared`, "Share"). `jarl zone link` and
|
||||
`unlink`, `zone create --link-with VIEW`, `record add|set|update
|
||||
--local|--shared`, a LINK column in `record list`, "Keep them linked"
|
||||
when copying a zone in the web UI, `POST /zones/{zone}/link` and
|
||||
`/unlink`. ACME challenges are written once per link, and `--view all`
|
||||
changes a link once.
|
||||
- TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
|
||||
obtains certificates from Let's Encrypt or any ACME CA (account set up
|
||||
in jarl.yaml under `acme.account`) and renews them itself, from the CA's
|
||||
|
||||
@@ -97,6 +97,9 @@ error: www.adbraun.lol has a CNAME, so it can't have any other records (found A)
|
||||
serves the domain, decided at every renewal. Use them for the web UI
|
||||
(HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
|
||||
rereads them on renewal), or bring your own.
|
||||
* **Linked zones for split DNS**: the same zone in several views, with the
|
||||
shared records kept in step by Jarl and a few records of each view's own
|
||||
(an internal address for a public name, say).
|
||||
* **Adopts an existing server**: imports named.conf and zone files
|
||||
(comments included), then switches named over with an automatic
|
||||
fallback.
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
|
||||
}
|
||||
refs := make([]acme.Zone, len(zones))
|
||||
for i, z := range zones {
|
||||
refs[i] = acme.Zone{Name: z.Name, View: z.View}
|
||||
refs[i] = acme.Zone{Name: z.Name, View: z.View, Link: z.LinkGroup}
|
||||
}
|
||||
out := acme.Zones(name, refs, view)
|
||||
if len(out) == 0 {
|
||||
|
||||
+35
-9
@@ -12,35 +12,61 @@ import (
|
||||
// zoneViews expands a --view value for a record command: "" or one view
|
||||
// as it is, "a,b" to those views, and "all" to every view that has the
|
||||
// zone.
|
||||
// localFanOut is set by commands making local changes (--local), which
|
||||
// go to every view named even when the zones are linked.
|
||||
var localFanOut bool
|
||||
|
||||
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
|
||||
if view != "all" && !strings.Contains(view, ",") {
|
||||
return []string{view}, nil
|
||||
}
|
||||
if view != "all" {
|
||||
var out []string
|
||||
for _, v := range strings.Split(view, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
var zones []api.Zone
|
||||
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
want := strings.ToLower(strings.TrimSuffix(zone, "."))
|
||||
link := map[string]int64{}
|
||||
var out []string
|
||||
for _, z := range zones {
|
||||
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
|
||||
link[z.View] = z.LinkGroup
|
||||
if view == "all" {
|
||||
out = append(out, z.View)
|
||||
}
|
||||
}
|
||||
}
|
||||
if view != "all" {
|
||||
for _, v := range strings.Split(view, ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("zone %s is in no view", zone)
|
||||
}
|
||||
// Linked zones (docs/views.md#linked-zones) get a shared change once:
|
||||
// Jarl copies it to the others. A local change goes to each.
|
||||
if localFanOut {
|
||||
return out, nil
|
||||
}
|
||||
var dedup []string
|
||||
first := map[int64]string{}
|
||||
for _, v := range out {
|
||||
g := link[v]
|
||||
if g != 0 {
|
||||
if f, ok := first[g]; ok {
|
||||
if !flagQuiet {
|
||||
fmt.Fprintf(os.Stderr, "view %s: linked with %s, so it gets the change from there\n", v, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
first[g] = v
|
||||
}
|
||||
dedup = append(dedup, v)
|
||||
}
|
||||
return dedup, nil
|
||||
}
|
||||
|
||||
// perView runs a record command once per view. With several views, the
|
||||
// changes are staged into one changeset and applied together, so either
|
||||
|
||||
+113
-5
@@ -93,6 +93,9 @@ func zoneCmd() *cobra.Command {
|
||||
if z.InView != "" {
|
||||
fmt.Fprintf(w, "In view:\t%s\n", z.InView)
|
||||
}
|
||||
if len(z.LinkedWith) > 0 {
|
||||
fmt.Fprintf(w, "Linked with:\t%s (shared records are kept in step; 'jarl record list' shows which are local)\n", strings.Join(z.LinkedWith, ", "))
|
||||
}
|
||||
if z.ZoneFile != "" {
|
||||
fmt.Fprintf(w, "File:\t%s\n", z.ZoneFile)
|
||||
}
|
||||
@@ -105,7 +108,7 @@ func zoneCmd() *cobra.Command {
|
||||
show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
|
||||
|
||||
var ns, records, primaries, forwarders, opts []string
|
||||
var contact, comment, cloneFrom, file, inView, forward string
|
||||
var contact, comment, cloneFrom, file, inView, forward, linkWith string
|
||||
var ttl uint32
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
@@ -120,7 +123,8 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
jarl zone create 10.0.8.0/22 # 8-11.0.10.in-addr.arpa
|
||||
jarl zone create partner.example --type secondary --primary 192.0.2.53
|
||||
jarl zone create consul --type forward --forwarder "127.0.0.1 port 8600"
|
||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal`,
|
||||
jarl zone create example.com --view external --clone-from example.com --clone-view internal
|
||||
jarl zone create example.com --view vps --link-with external # linked: shared records kept in step`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
@@ -149,6 +153,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
if inView != "" {
|
||||
body["in_view"] = inView
|
||||
}
|
||||
if linkWith != "" {
|
||||
body["link_with"] = linkWith
|
||||
}
|
||||
var recs []map[string]any
|
||||
for _, r := range records {
|
||||
f := strings.Fields(r)
|
||||
@@ -219,6 +226,7 @@ and a network between octet boundaries, like a /22, gets one zone per /24
|
||||
create.Flags().StringVar(&cloneView, "clone-view", "", "view of --clone-from")
|
||||
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)")
|
||||
create.Flags().StringVar(&inView, "in-view", "", "for in-view zones: the view to share the zone from")
|
||||
create.Flags().StringVar(&linkWith, "link-with", "", "link with the zone of the same name in this view: copy its shared records and keep them in step (docs/views.md#linked-zones)")
|
||||
create.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
|
||||
var refresh, retry, expire, minimum, serial uint32
|
||||
@@ -416,7 +424,7 @@ equal to the file: records not in the file are deleted.`,
|
||||
importCmd.Flags().BoolVar(&replace, "replace", false, "delete records that are not in the file")
|
||||
importCmd.Flags().BoolVar(&useSOA, "use-soa", false, "also take SOA timers and $TTL from the file")
|
||||
|
||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd())
|
||||
cmd.AddCommand(list, show, create, update, del, toggle(true), toggle(false), fileCmd, importCmd, zoneSerialsCmd(), zoneDelegationCmd(), zoneDNSSECCmd(), zoneRegistrarCmd(), zoneLinkCmd(true), zoneLinkCmd(false))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -456,8 +464,25 @@ const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\"
|
||||
func recordCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||
var view, name, typ, search, comment, ttlArg string
|
||||
var ptr, missingOK, yes, onlyDisabled, wide bool
|
||||
var ptr, missingOK, yes, onlyDisabled, wide, local, shared bool
|
||||
var id int64
|
||||
// linkFlags adds --local and --shared (linked zones) to a command.
|
||||
linkFlags := func(c *cobra.Command, what string) {
|
||||
c.Flags().BoolVar(&local, "local", false, "linked zones: "+what+" in this view only (overriding the shared records of this name and type here)")
|
||||
c.Flags().BoolVar(&shared, "shared", false, "linked zones: "+what+" in every linked view (this version becomes every view's)")
|
||||
}
|
||||
linkBody := func(body map[string]any) error {
|
||||
switch {
|
||||
case local && shared:
|
||||
return fmt.Errorf("give --local or --shared, not both")
|
||||
case local:
|
||||
body["local"] = true
|
||||
localFanOut = true
|
||||
case shared:
|
||||
body["local"] = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
list := &cobra.Command{
|
||||
Use: "list ZONE",
|
||||
@@ -479,8 +504,15 @@ func recordCmd() *cobra.Command {
|
||||
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
|
||||
return err
|
||||
}
|
||||
linked := false
|
||||
for _, r := range recs {
|
||||
linked = linked || r.Linked != ""
|
||||
}
|
||||
return output(recs, func(w io.Writer) {
|
||||
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
|
||||
if linked {
|
||||
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
|
||||
}
|
||||
for _, r := range recs {
|
||||
ttl := fmt.Sprint(r.EffectiveTTL)
|
||||
if r.TTL == nil {
|
||||
@@ -490,10 +522,18 @@ func recordCmd() *cobra.Command {
|
||||
if r.Disabled {
|
||||
comment = strings.TrimSpace("[disabled] " + comment)
|
||||
}
|
||||
if linked {
|
||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Linked, r.Data, comment})
|
||||
continue
|
||||
}
|
||||
rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
|
||||
}
|
||||
if !wide {
|
||||
fitColumn(rows, 4)
|
||||
col := 4
|
||||
if linked {
|
||||
col = 5
|
||||
}
|
||||
fitColumn(rows, col)
|
||||
}
|
||||
for _, row := range rows {
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
@@ -535,6 +575,9 @@ and splitting are automatic).
|
||||
if ptr {
|
||||
body["ptr"] = true
|
||||
}
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
views, err := zoneViews(c, args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -549,6 +592,7 @@ and splitting are automatic).
|
||||
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
|
||||
add.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||
linkFlags(add, "add it")
|
||||
|
||||
var data string
|
||||
update := &cobra.Command{
|
||||
@@ -582,6 +626,9 @@ and splitting are automatic).
|
||||
if ptr {
|
||||
body["ptr"] = true
|
||||
}
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return fmt.Errorf("nothing to change (see --help)")
|
||||
}
|
||||
@@ -598,6 +645,8 @@ and splitting are automatic).
|
||||
update.Flags().StringVar(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
|
||||
update.Flags().StringVar(&comment, "comment", "", "new comment")
|
||||
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)")
|
||||
update.Flags().BoolVar(&local, "local", false, "linked zones: make this name and type local to the record's view (an override)")
|
||||
update.Flags().BoolVar(&shared, "shared", false, "linked zones: share this name and type with the linked views; this view's version becomes every view's")
|
||||
|
||||
del := &cobra.Command{
|
||||
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
|
||||
@@ -678,6 +727,9 @@ spaces.
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if err := linkBody(body); err != nil {
|
||||
return err
|
||||
}
|
||||
views, err := zoneViews(c, args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -691,6 +743,7 @@ spaces.
|
||||
set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
|
||||
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
|
||||
set.Flags().StringVar(&comment, "comment", "", "comment for new records")
|
||||
linkFlags(set, "set it")
|
||||
|
||||
toggle := func(disable bool) *cobra.Command {
|
||||
verb := "enable"
|
||||
@@ -829,3 +882,58 @@ func fitColumn(rows [][]string, col int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// zoneLinkCmd is 'zone link' and 'zone unlink' (docs/views.md#linked-zones).
|
||||
func zoneLinkCmd(link bool) *cobra.Command {
|
||||
var view string
|
||||
var with []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "link ZONE --view VIEW [--with VIEW...]",
|
||||
Short: "Link zones of the same name in several views: shared records are kept in step",
|
||||
Long: `Links a zone with the zones of the same name in other views (--with, or
|
||||
every view that has one). Jarl then keeps their shared records in step: a
|
||||
change to one reaches the others in the same changeset. Each view can
|
||||
still have records of its own (record add/set --local), which override
|
||||
the shared records of that name and type there, and every zone keeps its
|
||||
own options, so views can still differ in who they answer and how.
|
||||
|
||||
Linking never changes what named answers: a name and type with the same
|
||||
records in every view becomes shared, and one that differs stays local to
|
||||
each view. Share one later from the view whose version is right:
|
||||
jarl record update ID --shared.`,
|
||||
Example: ` jarl zone link adbraun.lol --view external --with vps
|
||||
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||
jarl zone unlink adbraun.lol --view vps`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
verb := "unlink"
|
||||
var body any
|
||||
if link {
|
||||
verb = "link"
|
||||
body = map[string]any{"with": with}
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/"+verb, qv, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&view, "view", "", "view of the zone")
|
||||
if link {
|
||||
cmd.Flags().StringSliceVar(&with, "with", nil, "views to link with (default: every view with a zone of that name)")
|
||||
return cmd
|
||||
}
|
||||
cmd.Use = "unlink ZONE --view VIEW"
|
||||
cmd.Short = "Take a zone out of its link: its records stay, all local"
|
||||
cmd.Long = `Takes a zone out of its link. Its records stay as they are, all local to
|
||||
its view, and the other zones of the link keep theirs. A link left with one
|
||||
zone is dissolved.`
|
||||
cmd.Example = " jarl zone unlink adbraun.lol --view vps"
|
||||
return cmd
|
||||
}
|
||||
+15
@@ -342,6 +342,21 @@ registrar_error`.
|
||||
|
||||
### Records
|
||||
|
||||
In a linked zone ([views.md](views.md#linked-zones)), records carry
|
||||
`shared_id` and `linked` (`shared` or `local`). Creating, updating
|
||||
(`PATCH /records/{id}`) and setting (`PUT .../rrsets/...`) take `"local":
|
||||
true` (this view only, an override) or `false` (shared; on an update or a
|
||||
set it makes this view's version every view's). Zones carry `link_group`
|
||||
and `linked_with` (the other views).
|
||||
|
||||
| Method | Path | Body |
|
||||
|--------|------|------|
|
||||
| POST | `/zones/{zone}/link` | `{"with": ["external"]}` (default: every view with that zone). Names and types that are the same everywhere become shared, the rest stay local |
|
||||
| POST | `/zones/{zone}/unlink` | Takes the zone out of its link; its records stay, all local |
|
||||
|
||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||
that view's zone of the same name.
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|--------|------|--------------|
|
||||
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
|
||||
|
||||
+12
@@ -211,6 +211,18 @@ router, answers for the rest of the /24. IPv4 networks smaller than a /24
|
||||
are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
|
||||
`zone create` sets any zone option in the same step.
|
||||
|
||||
Linked zones ([views.md](views.md#linked-zones)) keep their shared
|
||||
records in step across views:
|
||||
|
||||
```sh
|
||||
jarl zone link adbraun.lol --view internal --with external
|
||||
jarl zone create adbraun.lol --view vps --link-with external
|
||||
jarl zone unlink adbraun.lol --view vps
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||
jarl record update 1234 --local | --shared
|
||||
```
|
||||
|
||||
When a zone exists in several views, add `--view NAME` to commands that
|
||||
name it.
|
||||
|
||||
|
||||
@@ -104,6 +104,20 @@ applied.
|
||||
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys |
|
||||
| `serial_not_higher` | error | An explicit serial on a signed zone must be above the one named has loaded (automatic serials are raised by themselves) |
|
||||
|
||||
## Linked zones
|
||||
|
||||
See [views.md](views.md#linked-zones). Jarl keeps linked zones in step
|
||||
itself, so these only show when something got past it (a change staged
|
||||
before the zones were linked, or the database edited by hand).
|
||||
|
||||
| Code | Severity | Check |
|
||||
|------|----------|-------|
|
||||
| `linked_zone_type` | error | Only primary zones can be linked |
|
||||
| `linked_zone_name` | error | Linked zones have the same name |
|
||||
| `linked_mixed` | error | A name and type in a zone is either shared or local, not both |
|
||||
| `linked_out_of_sync` | error | Every view that doesn't override a shared name and type has the same records for it. Link the zones again (`jarl zone link`) to work out what is shared |
|
||||
| `linked_zone_alone` | info | A zone linked with no other zone |
|
||||
|
||||
## Blocking
|
||||
|
||||
From [blocking.md](blocking.md).
|
||||
|
||||
+81
-9
@@ -88,19 +88,27 @@ and [exposure.md](exposure.md) to set up response rate limiting.
|
||||
|
||||
### 4. Give the external view its zones
|
||||
|
||||
**Different data per view (split DNS).** Copy the zone, then change what
|
||||
should differ:
|
||||
**Mostly the same, a few differences (split DNS).** Create the external
|
||||
copy *linked* ([below](#linked-zones)): Jarl keeps the shared records the
|
||||
same in both views, and each view can have records of its own:
|
||||
|
||||
```sh
|
||||
jarl zone create adbraun.lol --view external --link-with internal
|
||||
jarl record set adbraun.lol www A 203.0.113.20 --view external --local # public address, external only
|
||||
jarl record delete adbraun.lol nas-internal A --view external # gone from both: it was shared
|
||||
```
|
||||
|
||||
Delete records you don't want outside before you link, or make them local
|
||||
to the internal view first (`jarl record update ID --local`): a shared
|
||||
record is in both views.
|
||||
|
||||
**Two separate copies.** `--clone-from` copies the zone once; from then on
|
||||
the two are separate zones, and a record added to one isn't in the other:
|
||||
|
||||
```sh
|
||||
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal
|
||||
jarl record list adbraun.lol --view external
|
||||
jarl record update <id> --data 203.0.113.20 # public address instead of 10.10.0.20
|
||||
jarl record delete adbraun.lol nas-internal A --view external
|
||||
```
|
||||
|
||||
From then on the two copies are separate zones. A record added to one isn't
|
||||
in the other, and the serials are separate.
|
||||
|
||||
**The same data in both views.** Use an `in-view` zone. The external view
|
||||
then serves the internal view's zone, so there is one copy to maintain:
|
||||
|
||||
@@ -160,6 +168,67 @@ 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; }`).
|
||||
|
||||
## Linked zones
|
||||
|
||||
Most split-horizon zones are the same in every view apart from a few
|
||||
names. Linking zones of the same name in different views lets Jarl keep
|
||||
the shared records in step, while each view can still have records of its
|
||||
own and its own zone options.
|
||||
|
||||
* **Shared records** are in every linked view. Adding, changing,
|
||||
disabling or deleting one in any view does the same in the others, in
|
||||
the same changeset: the diff shows every view, and history, undo and
|
||||
`jarl check` treat them together.
|
||||
* **Local records** are in one view only (`--local`, or **Only here** in
|
||||
the web UI). A local record overrides the shared records of the same name
|
||||
and type in that view: `www A 10.0.10.5` local to internal, while the
|
||||
external view keeps the shared `www A 203.0.113.20`. Delete the local
|
||||
records and the view gets the shared ones back.
|
||||
* A new record joins the set of the same name and type: local if that set
|
||||
is local in its view, shared otherwise.
|
||||
* **Sharing a version** (`jarl record update ID --shared`, **Share** in the
|
||||
web UI) makes that view's records of that name and type the shared
|
||||
version: the other views' records of that name and type are replaced.
|
||||
* Zones stay separate in named, so each keeps its own serial, options
|
||||
(allow-transfer, DNSSEC signing, ...) and file, and the views keep their
|
||||
own `match-clients`, recursion and order.
|
||||
|
||||
```sh
|
||||
jarl zone link adbraun.lol --view internal --with external # link two existing zones
|
||||
jarl zone create adbraun.lol --view vps --link-with external # a new zone, linked from the start
|
||||
jarl record list adbraun.lol --view internal # LINK column: shared or local
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local
|
||||
jarl record update 1234 --local # this name and type: only this view
|
||||
jarl record update 1234 --shared # this view's version, everywhere
|
||||
jarl zone unlink adbraun.lol --view vps # records stay, all local
|
||||
```
|
||||
|
||||
**Linking never changes what named answers.** A name and type with the
|
||||
same records in every view becomes shared; one that differs stays local to
|
||||
each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||
keeps every record, all local; a link left with one zone is dissolved, as
|
||||
when the other zone is deleted.
|
||||
|
||||
**Permissions** are checked in every view a change reaches. A token with
|
||||
a grant for one view only (`views=internal`) can change that view's local
|
||||
records and make overrides, but not a shared record, since that would
|
||||
change the other views too: Jarl says so and suggests a local record. A
|
||||
grant without a view (the usual `zones=adbraun.lol`) covers every view.
|
||||
|
||||
**Importing a zone file** into a linked zone (`jarl zone import`) adds
|
||||
the records like any others: shared, unless the name and type is local
|
||||
there. With `--replace`, shared records missing from the file are deleted
|
||||
in every linked view.
|
||||
|
||||
**ACME challenges** are written once per link, and so reach every linked
|
||||
view; `jarl acme`, lego's httpreq and certificates Jarl obtains all do
|
||||
this.
|
||||
|
||||
**Compared with BIND's `in-view`**, where one view serves another's zone
|
||||
as it is: linked zones can differ per view, keep their own options, and
|
||||
don't depend on the view order (`in-view` can only use a view defined
|
||||
before it).
|
||||
|
||||
## Living with views
|
||||
|
||||
* Commands that name a zone that exists in several views need `--view`
|
||||
@@ -167,7 +236,10 @@ options (`allow-transfer { key jarl-view-external; }`).
|
||||
guess.
|
||||
* `jarl acme` writes challenge records into **every** view that has the
|
||||
zone, so Let's Encrypt (external) and internal clients see the same
|
||||
thing. Use `--view` to limit it.
|
||||
thing (once per link, for linked zones). Use `--view` to limit it.
|
||||
* `--view all` (or a list) on record commands changes each linked zone
|
||||
once: the change reaches the others through the link. With `--local` it
|
||||
goes to each view named.
|
||||
* `jarl view order internal external` changes the match order, and
|
||||
`jarl view list` shows it.
|
||||
* Deleting a view is refused while it still has zones, unless you pass
|
||||
|
||||
+17
-1
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
|
||||
type Zone struct {
|
||||
Name string // as Jarl stores it, e.g. "adbraun.lol."
|
||||
View string // "" when views are not used
|
||||
// Link is the zone's link group (0: not linked). A record written
|
||||
// into one zone of a link reaches the others (docs/views.md#linked-zones),
|
||||
// so only one of them is written to.
|
||||
Link int64
|
||||
}
|
||||
|
||||
// Zones returns the zones that hold name: the longest matching zone, in
|
||||
@@ -46,7 +50,19 @@ func Zones(name string, zones []Zone, view string) []Zone {
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
return out
|
||||
// One zone per link: Jarl writes the shared record into the others.
|
||||
var dedup []Zone
|
||||
links := map[int64]bool{}
|
||||
for _, z := range out {
|
||||
if z.Link != 0 {
|
||||
if links[z.Link] {
|
||||
continue
|
||||
}
|
||||
links[z.Link] = true
|
||||
}
|
||||
dedup = append(dedup, z)
|
||||
}
|
||||
return dedup
|
||||
}
|
||||
|
||||
// Requests returns the changes that set (or, with set false, remove) the
|
||||
|
||||
@@ -29,6 +29,9 @@ type Zone struct {
|
||||
// Registrar names the registrar Jarl can manage the domain at
|
||||
// ("porkbun"), when one is configured for it.
|
||||
Registrar string `json:"registrar,omitempty"`
|
||||
// LinkedWith are the views of the zones this one is linked with
|
||||
// (docs/views.md#linked-zones).
|
||||
LinkedWith []string `json:"linked_with,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change or delete.
|
||||
Pending string `json:"pending,omitempty"`
|
||||
}
|
||||
@@ -40,6 +43,8 @@ type Record struct {
|
||||
View string `json:"view,omitempty"`
|
||||
RelativeName string `json:"relative_name"`
|
||||
EffectiveTTL uint32 `json:"effective_ttl"`
|
||||
// Linked is "shared" or "local" for a record of a linked zone ("" otherwise).
|
||||
Linked string `json:"linked,omitempty"`
|
||||
// Text is the decoded value of TXT and SPF records.
|
||||
Text *string `json:"text,omitempty"`
|
||||
// Pending is set when listing with a changeset: add, change, delete,
|
||||
|
||||
@@ -139,7 +139,7 @@ func primaryZones(st *model.State) []acme.Zone {
|
||||
var out []acme.Zone
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z)})
|
||||
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z), Link: z.LinkGroup})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
n := to.Zones[k]
|
||||
s := n.SOA
|
||||
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
|
||||
if n.Type == model.ZonePrimary {
|
||||
in.SOA = &SOAInput{MName: &s.MName, RName: &s.RName, Serial: &s.Serial, Refresh: &s.Refresh, Retry: &s.Retry, Expire: &s.Expire, Minimum: &s.Minimum}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func DiffOps(from, to *model.State) []model.Op {
|
||||
}
|
||||
for _, k := range sortedKeys(to.Records) {
|
||||
n := to.Records[k]
|
||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled}
|
||||
in := RecordInput{ZoneID: n.ZoneID, Name: &n.Name, Type: &n.Type, Data: &n.Data, Comment: &n.Comment, Disabled: &n.Disabled, SharedID: model.Ptr(n.SharedID)}
|
||||
if n.TTL != nil {
|
||||
in.TTL = model.Some(*n.TTL)
|
||||
} else {
|
||||
|
||||
+157
-9
@@ -131,7 +131,24 @@ func dateSerial(now time.Time) uint32 {
|
||||
}
|
||||
|
||||
func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
ops, err := x.expandOne(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch req.Op {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
|
||||
// Changes to linked zones reach the other zones of the link.
|
||||
return x.linkSync(ops)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
func (x *expander) expandOne(req Request) ([]model.Op, error) {
|
||||
switch req.Op {
|
||||
case OpZoneLink:
|
||||
return x.zoneLink(req)
|
||||
case OpZoneUnlink:
|
||||
return x.zoneUnlink(req)
|
||||
case OpZoneCreate:
|
||||
return x.zoneCreate(req)
|
||||
case OpZoneUpdate, OpZoneDelete:
|
||||
@@ -152,7 +169,24 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "enable zone "+zoneDesc(x.st, z))}, nil
|
||||
}
|
||||
return []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}, nil
|
||||
ops := []model.Op{x.versioned(req, z.Version, "zone "+verb+" "+zoneDesc(x.st, z))}
|
||||
if req.Op == OpZoneDelete {
|
||||
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||
ops = append(ops, x.unlinkOps(rest[0], "unlink zone "+zoneDesc(x.st, rest[0])+": the zone it was linked with is deleted")...)
|
||||
}
|
||||
} else {
|
||||
var in ZoneInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.LinkGroup != nil && *in.LinkGroup != z.LinkGroup {
|
||||
return nil, errf(KindBadRequest, "link zones with zone.link and zone.unlink (jarl zone link / unlink)")
|
||||
}
|
||||
if in.LinkWith != "" {
|
||||
return nil, errf(KindBadRequest, "link_with is for new zones; link existing ones with zone.link (jarl zone link)")
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
case OpRecordCreate:
|
||||
return x.recordCreate(req)
|
||||
case OpRecordUpdate:
|
||||
@@ -355,7 +389,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
records := in.Records
|
||||
cloneFrom := in.CloneFrom
|
||||
zoneOpts := in.Options
|
||||
in.Nameservers, in.Records, in.CloneFrom, in.Options = nil, nil, 0, nil
|
||||
linkWith := in.LinkWith
|
||||
in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil
|
||||
if linkWith != "" {
|
||||
if cloneFrom != 0 {
|
||||
return nil, errf(KindBadRequest, "give clone_from or link_with, not both")
|
||||
}
|
||||
v := x.st.ViewByName(linkWith)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", linkWith)
|
||||
}
|
||||
o := x.st.FindZone(name, v.ID)
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name))
|
||||
}
|
||||
if o.Type != model.ZonePrimary || zt != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked")
|
||||
}
|
||||
cloneFrom = o.ID
|
||||
}
|
||||
|
||||
set := x.st.Settings
|
||||
var src *model.Zone
|
||||
@@ -480,9 +532,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var linkOps []model.Op
|
||||
if src != nil && linkWith != "" {
|
||||
// The new zone joins src's link, or starts one with src: then
|
||||
// all of src's records become shared.
|
||||
group := src.LinkGroup
|
||||
if group == 0 {
|
||||
group = src.ID
|
||||
linkOps = x.shareAllOps(src, group)
|
||||
}
|
||||
in.LinkGroup = &group
|
||||
}
|
||||
if src != nil {
|
||||
for _, r := range x.st.ZoneRecords(src.ID) {
|
||||
if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 {
|
||||
continue // local to src's view
|
||||
}
|
||||
ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)}
|
||||
if linkWith != "" {
|
||||
id := r.SharedID
|
||||
if id == 0 {
|
||||
id = r.ID
|
||||
}
|
||||
ri.SharedID = &id
|
||||
}
|
||||
if r.Disabled {
|
||||
ri.Disabled = model.Ptr(true)
|
||||
}
|
||||
@@ -512,10 +585,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
|
||||
summary += " in view " + v.Name
|
||||
}
|
||||
}
|
||||
if src != nil {
|
||||
if src != nil && linkWith != "" {
|
||||
summary += " (linked with " + zoneDesc(x.st, src) + ")"
|
||||
} else if src != nil {
|
||||
summary += " (copy of " + zoneDesc(x.st, src) + ")"
|
||||
}
|
||||
ops := []model.Op{newOp(OpZoneCreate, ids[0], 0, in, summary)}
|
||||
ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary))
|
||||
for i, zo := range zoneOpts {
|
||||
oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)}
|
||||
if zo.Comment != "" {
|
||||
@@ -666,7 +741,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data))}
|
||||
summary := fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(in.TTL), p.Type, p.Data)
|
||||
in.SharedID = nil
|
||||
if z.LinkGroup != 0 {
|
||||
local, err := x.localFor(z, owner, p.Type, in.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if local {
|
||||
in.SharedID = model.Ptr(int64(0))
|
||||
summary += " (local to view " + x.st.ViewName(z) + ")"
|
||||
} else {
|
||||
if x.st.HasLocal(z.ID, owner, p.Type) {
|
||||
return nil, errf(KindConflict, "%s %s is local in view %s; share the set first (jarl record update ID --shared) or add this one as local", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||
}
|
||||
in.SharedID = &ids[0]
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
}
|
||||
in.Local = nil
|
||||
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
|
||||
if ptr {
|
||||
more, err := x.ptrCreate(z, owner, p)
|
||||
if err != nil {
|
||||
@@ -690,6 +785,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
ptr := in.PTR
|
||||
in.PTR = false
|
||||
in.Zone, in.View = "", ""
|
||||
if in.SharedID != nil {
|
||||
return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared")
|
||||
}
|
||||
var pre []model.Op
|
||||
if in.Local != nil {
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
}
|
||||
if want := *in.Local; want != (r.SharedID == 0) {
|
||||
// The whole set of this name and type changes sides.
|
||||
for _, o := range x.st.RRset(z.ID, r.Name, r.Type) {
|
||||
id := int64(0)
|
||||
what := fmt.Sprintf("make %s %s local to view %s", o.Name, o.Type, x.st.ViewName(z))
|
||||
if !want {
|
||||
id = o.ID
|
||||
what = fmt.Sprintf("share %s %s %s with the linked views", o.Name, o.Type, o.Data)
|
||||
}
|
||||
pre = append(pre, newOp(OpRecordUpdate, o.ID, o.Version, RecordInput{SharedID: model.Ptr(id)}, what))
|
||||
}
|
||||
if !want {
|
||||
// Sharing a version makes it every view's.
|
||||
pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...)
|
||||
}
|
||||
}
|
||||
in.Local = nil
|
||||
if string(mustJSON(in)) == "{}" {
|
||||
return pre, nil
|
||||
}
|
||||
}
|
||||
name, typ, data := r.Name, r.Type, r.Data
|
||||
if in.Name != nil {
|
||||
name = *in.Name
|
||||
@@ -751,11 +875,17 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
|
||||
case len(changes) > 0:
|
||||
summary += ": " + strings.Join(changes, ", ")
|
||||
}
|
||||
if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) {
|
||||
return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z))
|
||||
}
|
||||
base := req.BaseVersion
|
||||
if base == 0 {
|
||||
base = r.Version
|
||||
}
|
||||
ops := []model.Op{newOp(OpRecordUpdate, r.ID, base, in, summary)}
|
||||
if len(pre) > 0 {
|
||||
base = 0 // the record was just updated by pre
|
||||
}
|
||||
ops := append(pre, newOp(OpRecordUpdate, r.ID, base, in, summary))
|
||||
disabled := r.Disabled
|
||||
if in.Disabled != nil {
|
||||
disabled = *in.Disabled
|
||||
@@ -953,17 +1083,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
|
||||
canon = append(canon, p.Data)
|
||||
}
|
||||
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon
|
||||
where := ""
|
||||
var pre []model.Op
|
||||
if z.LinkGroup != 0 {
|
||||
local, err := x.localFor(z, owner, typ, in.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Shared = model.Ptr(!local)
|
||||
if local {
|
||||
where = " (local to view " + x.st.ViewName(z) + ")"
|
||||
} else if in.Local != nil {
|
||||
// Explicitly shared: this version becomes every view's.
|
||||
pre = x.dropOverrides(z, owner, typ)
|
||||
}
|
||||
} else if in.Local != nil && *in.Local {
|
||||
return nil, errf(KindBadRequest, "zone %s isn't linked with other views, so its records are local anyway", zoneDesc(x.st, z))
|
||||
}
|
||||
in.Local = nil
|
||||
if len(canon) > 0 {
|
||||
in.NewIDs, err = x.ids(len(canon))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "))
|
||||
summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where)
|
||||
if len(canon) == 0 {
|
||||
summary = fmt.Sprintf("delete all %s records of %s", typ, owner)
|
||||
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
|
||||
}
|
||||
return []model.Op{newOp(OpRRSetReplace, 0, 0, in, summary)}, nil
|
||||
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
|
||||
}
|
||||
|
||||
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
|
||||
|
||||
@@ -55,6 +55,10 @@ const (
|
||||
// back, become view.update or settings.update operations.
|
||||
OpExposureAck = "exposure.ack"
|
||||
OpExposureUnack = "exposure.unack"
|
||||
// Linked zones (docs/views.md#linked-zones): requests that expand
|
||||
// into zone and record updates.
|
||||
OpZoneLink = "zone.link"
|
||||
OpZoneUnlink = "zone.unlink"
|
||||
)
|
||||
|
||||
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
|
||||
@@ -131,6 +135,13 @@ type ZoneInput struct {
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// Disabled keeps the zone in Jarl but out of named's configuration.
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
// LinkGroup is the zone's link group (0: not linked). Set by zone.link
|
||||
// and zone.unlink; requests use those.
|
||||
LinkGroup *int64 `json:"link_group,omitempty"`
|
||||
// LinkWith (create only) links the new zone with the zone of the same
|
||||
// name in this view, copying its shared records (or all of its
|
||||
// records, when it isn't linked yet).
|
||||
LinkWith string `json:"link_with,omitempty"`
|
||||
|
||||
// Create only: name servers for the apex NS records (default: the
|
||||
// configured default name servers), additional records, and a zone to
|
||||
@@ -167,6 +178,25 @@ type RecordInput struct {
|
||||
PTR bool `json:"ptr,omitempty"`
|
||||
// MissingOK makes a delete-by-match succeed when nothing matches.
|
||||
MissingOK bool `json:"missing_ok,omitempty"`
|
||||
// Local (requests, linked zones): true keeps the record in this view
|
||||
// only (an override: the whole name and type becomes local here);
|
||||
// false shares it with the linked zones. Unset: like the records of
|
||||
// the same name and type, shared when there are none.
|
||||
Local *bool `json:"local,omitempty"`
|
||||
// SharedID (stored operations) ties the copies of a shared record
|
||||
// together; 0 makes it local.
|
||||
SharedID *int64 `json:"shared_id,omitempty"`
|
||||
}
|
||||
|
||||
// LinkInput links zones of the same name in several views, or (unlink)
|
||||
// takes one out of its link.
|
||||
type LinkInput struct {
|
||||
ZoneID int64 `json:"zone_id,omitempty"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
// With are the views whose zone of the same name to link with (link
|
||||
// only; default: every view that has one).
|
||||
With []string `json:"with,omitempty"`
|
||||
}
|
||||
|
||||
// RRSetInput replaces all records of one name and type.
|
||||
@@ -181,6 +211,11 @@ type RRSetInput struct {
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
// NewIDs are allocated for records that need to be created.
|
||||
NewIDs []int64 `json:"new_ids,omitempty"`
|
||||
// Local (requests): like RecordInput.Local, for the whole set.
|
||||
Local *bool `json:"local,omitempty"`
|
||||
// Shared (stored operations, linked zones): the records are shared
|
||||
// (true) or local (false).
|
||||
Shared *bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// ViewInput creates or updates a view.
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package engine
|
||||
|
||||
// Linked zones (docs/views.md#linked-zones). Zones of the same name in
|
||||
// different views can be linked: their shared records are kept in step,
|
||||
// and each zone can override a name and type with records local to its
|
||||
// view. Every zone keeps its own copy of each shared record (with the
|
||||
// same SharedID), so the rest of Jarl sees ordinary zones.
|
||||
//
|
||||
// Keeping them in step happens when requests are expanded: the request's
|
||||
// operations are applied to a scratch copy, and linkSync adds explicit
|
||||
// operations for the other zones of the link. So a change to a shared
|
||||
// record is one changeset with one operation per zone: the diff, the
|
||||
// permission checks, history and undo all see every zone.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
type rrKey struct {
|
||||
zone int64
|
||||
name, typ string
|
||||
}
|
||||
|
||||
// linkSync returns ops followed by the operations that bring the other
|
||||
// zones of each affected link in step.
|
||||
func (x *expander) linkSync(ops []model.Op) ([]model.Op, error) {
|
||||
if !x.st.AnyLinked() {
|
||||
return ops, nil
|
||||
}
|
||||
work := x.st.Clone()
|
||||
c := &applyCtx{p: x.p, now: x.now}
|
||||
for _, op := range ops {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys := affectedKeys(x.st, work, ops)
|
||||
var extra []model.Op
|
||||
for _, k := range keys {
|
||||
z := work.Zones[k.zone]
|
||||
if z == nil || z.LinkGroup == 0 {
|
||||
continue
|
||||
}
|
||||
more, err := x.syncKey(work, z, k.name, k.typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, op := range more {
|
||||
if err := applyOp(work, op, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
extra = append(extra, more...)
|
||||
}
|
||||
return append(ops, extra...), nil
|
||||
}
|
||||
|
||||
// affectedKeys lists the (zone, name, type) sets that ops touch, before
|
||||
// and after.
|
||||
func affectedKeys(pre, post *model.State, ops []model.Op) []rrKey {
|
||||
var out []rrKey
|
||||
seen := map[rrKey]bool{}
|
||||
add := func(zone int64, name, typ string) {
|
||||
k := rrKey{zone, name, typ}
|
||||
if zone != 0 && name != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
fromRecord := func(id int64) {
|
||||
for _, st := range []*model.State{pre, post} {
|
||||
if r := st.Records[id]; r != nil {
|
||||
add(r.ZoneID, r.Name, r.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, op := range ops {
|
||||
switch op.Kind {
|
||||
case OpRecordCreate, OpRecordUpdate, OpRecordDelete:
|
||||
if op.ID != 0 {
|
||||
fromRecord(op.ID)
|
||||
}
|
||||
if op.Kind == OpRecordDelete && op.ID == 0 {
|
||||
var in RecordInput
|
||||
if decode(op.Data, &in) == nil && in.Name != nil && in.Type != nil {
|
||||
add(in.ZoneID, *in.Name, *in.Type)
|
||||
}
|
||||
}
|
||||
case OpRRSetReplace:
|
||||
var in RRSetInput
|
||||
if decode(op.Data, &in) == nil {
|
||||
add(in.ZoneID, in.Name, in.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// syncKey brings the other zones of z's link in step with z for one name
|
||||
// and type, after z was changed:
|
||||
//
|
||||
// - z overrides it (local records): z's shared copies go; the others
|
||||
// keep theirs.
|
||||
// - z's override was just removed: z gets the shared records back from
|
||||
// a zone that has them.
|
||||
// - otherwise z's shared records are the truth: every zone that doesn't
|
||||
// override the name and type gets the same (created, updated or
|
||||
// deleted to match).
|
||||
func (x *expander) syncKey(st *model.State, z *model.Zone, name, typ string) ([]model.Op, error) {
|
||||
var ops []model.Op
|
||||
where := func(o *model.Zone) string { return zoneDesc(st, o) }
|
||||
if st.HasLocal(z.ID, name, typ) {
|
||||
for _, r := range st.SharedRecords(z.ID, name, typ) {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil,
|
||||
fmt.Sprintf("linked: %s %s is local in %s now, so its shared %s goes there", name, typ, where(z), r.Data)))
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
truth := st.SharedRecords(z.ID, name, typ)
|
||||
members := st.LinkedWith(z)
|
||||
if len(truth) == 0 && x.st.HasLocal(z.ID, name, typ) {
|
||||
// The override was removed: back to the shared records.
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue
|
||||
}
|
||||
src := st.SharedRecords(o.ID, name, typ)
|
||||
if len(src) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, r := range src {
|
||||
op, err := x.copyRecord(st, z, r, "linked: "+name+" "+typ+" is shared again in "+where(z)+": add "+r.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
break
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
for _, o := range members {
|
||||
if st.HasLocal(o.ID, name, typ) {
|
||||
continue // o overrides this name and type
|
||||
}
|
||||
copies := map[int64]*model.Record{}
|
||||
for _, r := range st.SharedRecords(o.ID, name, typ) {
|
||||
copies[r.SharedID] = r
|
||||
}
|
||||
var more []model.Op
|
||||
for _, t := range truth {
|
||||
cp := copies[t.SharedID]
|
||||
delete(copies, t.SharedID)
|
||||
if cp == nil {
|
||||
op, err := x.copyRecord(st, o, t, fmt.Sprintf("linked: add %s %s %s in %s", name, typ, t.Data, where(o)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
more = append(more, op)
|
||||
continue
|
||||
}
|
||||
in, changes := recordDiff(cp, t)
|
||||
if len(changes) > 0 {
|
||||
more = append(more, newOp(OpRecordUpdate, cp.ID, cp.Version, in,
|
||||
fmt.Sprintf("linked: change %s %s %s in %s: %s", name, typ, cp.Data, where(o), strings.Join(changes, ", "))))
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(copies) {
|
||||
cp := copies[id]
|
||||
more = append(more, newOp(OpRecordDelete, cp.ID, cp.Version, nil, fmt.Sprintf("linked: delete %s %s %s in %s", name, typ, cp.Data, where(o))))
|
||||
}
|
||||
if len(more) > 0 && !x.p.Can(recordAction(st, o, name, typ, true)) {
|
||||
return nil, errf(KindForbidden, "%s %s is shared with %s, which you may not change; to change it in %s only, make it local to that view (local: true, or --local)",
|
||||
dnsname.Display(name), typ, where(o), where(z))
|
||||
}
|
||||
ops = append(ops, more...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// copyRecord returns an operation that creates a copy of shared record r
|
||||
// in zone z.
|
||||
func (x *expander) copyRecord(st *model.State, z *model.Zone, r *model.Record, summary string) (model.Op, error) {
|
||||
if !x.p.Can(recordAction(st, z, r.Name, r.Type, true)) {
|
||||
return model.Op{}, errf(KindForbidden, "%s %s is shared with %s, which you may not change", dnsname.Display(r.Name), r.Type, zoneDesc(st, z))
|
||||
}
|
||||
ids, err := x.ids(1)
|
||||
if err != nil {
|
||||
return model.Op{}, err
|
||||
}
|
||||
return newOp(OpRecordCreate, ids[0], 0, recordCopyInput(z.ID, r), summary), nil
|
||||
}
|
||||
|
||||
func recordCopyInput(zoneID int64, r *model.Record) RecordInput {
|
||||
in := RecordInput{ZoneID: zoneID, Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data),
|
||||
Comment: model.Ptr(r.Comment), Disabled: model.Ptr(r.Disabled), SharedID: model.Ptr(r.SharedID)}
|
||||
if r.TTL != nil {
|
||||
in.TTL = model.Some(*r.TTL)
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// recordDiff returns the update that makes cp like t, and what changes.
|
||||
func recordDiff(cp, t *model.Record) (RecordInput, []string) {
|
||||
var in RecordInput
|
||||
var changes []string
|
||||
if cp.Data != t.Data {
|
||||
in.Data = model.Ptr(t.Data)
|
||||
changes = append(changes, "data "+t.Data)
|
||||
}
|
||||
if !sameTTL(cp.TTL, t.TTL) {
|
||||
if t.TTL != nil {
|
||||
in.TTL = model.Some(*t.TTL)
|
||||
changes = append(changes, fmt.Sprintf("ttl %d", *t.TTL))
|
||||
} else {
|
||||
in.TTL = model.Opt[uint32]{Set: true, Null: true}
|
||||
changes = append(changes, "ttl default")
|
||||
}
|
||||
}
|
||||
if cp.Comment != t.Comment {
|
||||
in.Comment = model.Ptr(t.Comment)
|
||||
changes = append(changes, "comment")
|
||||
}
|
||||
if cp.Disabled != t.Disabled {
|
||||
in.Disabled = model.Ptr(t.Disabled)
|
||||
changes = append(changes, enableWord(t.Disabled)+"d")
|
||||
}
|
||||
return in, changes
|
||||
}
|
||||
|
||||
// linkTarget resolves the zone a link request names.
|
||||
func (x *expander) linkTarget(in LinkInput) (*model.Zone, error) {
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, z), z.Type)
|
||||
}
|
||||
return z, nil
|
||||
}
|
||||
|
||||
// zoneLink links zones of the same name in several views. It never
|
||||
// changes what named answers: a name and type with the same records in
|
||||
// every zone becomes shared; anything that differs stays local to each
|
||||
// view (and can be shared later, from the view whose version is right).
|
||||
func (x *expander) zoneLink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.linkTarget(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := []*model.Zone{z}
|
||||
add := func(o *model.Zone) {
|
||||
if !slices.ContainsFunc(members, func(m *model.Zone) bool { return m.ID == o.ID }) {
|
||||
members = append(members, o)
|
||||
}
|
||||
}
|
||||
if len(in.With) == 0 {
|
||||
for _, o := range x.st.ZonesNamed(z.Name) {
|
||||
if o.Type == model.ZonePrimary && o.ID != z.ID {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, vn := range in.With {
|
||||
v := x.st.ViewByName(vn)
|
||||
if v == nil {
|
||||
return nil, errf(KindNotFound, "view %q does not exist", vn)
|
||||
}
|
||||
o := x.st.FindZone(z.Name, v.ID)
|
||||
if o == nil {
|
||||
return nil, errf(KindNotFound, "view %s has no zone %s (create it linked: jarl zone create %s --view %s --link-with %s)", v.Name, dnsname.Display(z.Name), dnsname.Display(z.Name), v.Name, x.st.ViewName(z))
|
||||
}
|
||||
if o.Type != model.ZonePrimary {
|
||||
return nil, errf(KindBadRequest, "only primary zones can be linked (%s is %s)", zoneDesc(x.st, o), o.Type)
|
||||
}
|
||||
add(o)
|
||||
}
|
||||
// Zones already linked with any of them join too.
|
||||
for _, m := range slices.Clone(members) {
|
||||
for _, o := range x.st.LinkMembers(m.LinkGroup) {
|
||||
add(o)
|
||||
}
|
||||
}
|
||||
if len(members) < 2 {
|
||||
return nil, errf(KindBadRequest, "zone %s exists in no other view to link it with", dnsname.Display(z.Name))
|
||||
}
|
||||
x.st.SortByView(members)
|
||||
group := members[0].ID
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != 0 {
|
||||
group = m.LinkGroup
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// What is the same everywhere?
|
||||
type key struct{ name, typ string }
|
||||
sig := func(rs []*model.Record) string {
|
||||
var parts []string
|
||||
for _, r := range rs {
|
||||
ttl := "-"
|
||||
if r.TTL != nil {
|
||||
ttl = fmt.Sprint(*r.TTL)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s|%s|%v", strings.ToLower(r.Data), ttl, r.Disabled))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
keys := map[key]bool{}
|
||||
var order []key
|
||||
for _, m := range members {
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
if !keys[k] {
|
||||
keys[k] = true
|
||||
order = append(order, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
var ops []model.Op
|
||||
var local []string
|
||||
shared := 0
|
||||
for _, k := range order {
|
||||
first := x.st.RRset(members[0].ID, k.name, k.typ)
|
||||
same := len(first) > 0
|
||||
for _, m := range members[1:] {
|
||||
if rs := x.st.RRset(m.ID, k.name, k.typ); len(rs) == 0 || sig(rs) != sig(first) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
shared++
|
||||
} else {
|
||||
var in []string
|
||||
for _, m := range members {
|
||||
if len(x.st.RRset(m.ID, k.name, k.typ)) > 0 {
|
||||
in = append(in, x.st.ViewName(m))
|
||||
}
|
||||
}
|
||||
local = append(local, fmt.Sprintf("%s %s (%s)", dnsname.Display(k.name), k.typ, strings.Join(in, ", ")))
|
||||
}
|
||||
// Pair the copies: record i of the first zone's set (by data) with
|
||||
// the same data in the others.
|
||||
byData := func(rs []*model.Record) []*model.Record {
|
||||
out := slices.Clone(rs)
|
||||
slices.SortStableFunc(out, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
|
||||
return out
|
||||
}
|
||||
ref := byData(first)
|
||||
for _, m := range members {
|
||||
rs := byData(x.st.RRset(m.ID, k.name, k.typ))
|
||||
for i, r := range rs {
|
||||
want := int64(0)
|
||||
var comment *string
|
||||
if same {
|
||||
want = ref[i].ID
|
||||
if r.Comment != ref[i].Comment && ref[i].Comment != "" {
|
||||
comment = model.Ptr(ref[i].Comment)
|
||||
}
|
||||
}
|
||||
if r.SharedID == want && comment == nil {
|
||||
continue
|
||||
}
|
||||
in := RecordInput{SharedID: model.Ptr(want), Comment: comment}
|
||||
what := "local to view " + x.st.ViewName(m)
|
||||
if same {
|
||||
what = "shared"
|
||||
}
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("%s %s %s: %s", r.Name, r.Type, r.Data, what)))
|
||||
}
|
||||
}
|
||||
}
|
||||
var views []string
|
||||
for _, m := range members {
|
||||
views = append(views, x.st.ViewName(m))
|
||||
}
|
||||
var zoneOps []model.Op
|
||||
for _, m := range members {
|
||||
if m.LinkGroup != group {
|
||||
zoneOps = append(zoneOps, newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(group)},
|
||||
fmt.Sprintf("link zone %s in views %s", zoneDesc(x.st, m), strings.Join(views, ", "))))
|
||||
}
|
||||
}
|
||||
if len(local) > 0 {
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names differ between the views and stay local to each: %s. Share one from the view whose version is right (jarl record update ID --shared, or Share in the web UI).", len(local), strings.Join(local, "; ")))
|
||||
}
|
||||
x.warnings = append(x.warnings, fmt.Sprintf("%d names and types are shared between views %s", shared, strings.Join(views, ", ")))
|
||||
return append(zoneOps, ops...), nil
|
||||
}
|
||||
|
||||
// zoneUnlink takes a zone out of its link: its records stay as they are,
|
||||
// all local. A link left with one zone is dissolved.
|
||||
func (x *expander) zoneUnlink(req Request) ([]model.Op, error) {
|
||||
var in LinkInput
|
||||
if err := decode(req.Data, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
z, err := x.resolveZone(in.ZoneID, in.Zone, in.View)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if z.LinkGroup == 0 {
|
||||
return nil, errf(KindConflict, "zone %s isn't linked", zoneDesc(x.st, z))
|
||||
}
|
||||
out := []*model.Zone{z}
|
||||
if rest := x.st.LinkedWith(z); len(rest) == 1 {
|
||||
out = append(out, rest[0])
|
||||
}
|
||||
var ops []model.Op
|
||||
for _, m := range out {
|
||||
ops = append(ops, x.unlinkOps(m, "unlink zone "+zoneDesc(x.st, m))...)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// unlinkOps returns the operations that take zone m out of its link.
|
||||
func (x *expander) unlinkOps(m *model.Zone, summary string) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, m.ID, m.Version, ZoneInput{LinkGroup: model.Ptr(int64(0))}, summary)}
|
||||
for _, r := range x.st.ZoneRecords(m.ID) {
|
||||
if r.SharedID != 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("%s %s %s: no longer shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// localFor works out whether a new record (or set) in zone z is local:
|
||||
// as asked, or like the records of the same name and type already there.
|
||||
func (x *expander) localFor(z *model.Zone, name, typ string, want *bool) (bool, error) {
|
||||
has := x.st.HasLocal(z.ID, name, typ)
|
||||
if want == nil {
|
||||
return has, nil
|
||||
}
|
||||
return *want, nil
|
||||
}
|
||||
|
||||
// shareAllOps returns the operations that start a link with zone z: it
|
||||
// joins group and all its records become shared.
|
||||
func (x *expander) shareAllOps(z *model.Zone, group int64) []model.Op {
|
||||
ops := []model.Op{newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{LinkGroup: model.Ptr(group)}, "link zone "+zoneDesc(x.st, z))}
|
||||
for _, r := range x.st.ZoneRecords(z.ID) {
|
||||
if r.SharedID == 0 {
|
||||
ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(r.ID)}, fmt.Sprintf("%s %s %s: shared", r.Name, r.Type, r.Data)))
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
|
||||
// dropOverrides returns the operations that delete the other linked
|
||||
// zones' local records of a name and type, so the shared version reaches
|
||||
// them (sharing a version makes it every view's).
|
||||
func (x *expander) dropOverrides(z *model.Zone, name, typ string) []model.Op {
|
||||
var ops []model.Op
|
||||
for _, o := range x.st.LinkedWith(z) {
|
||||
for _, r := range x.st.RRset(o.ID, name, typ) {
|
||||
if r.SharedID == 0 {
|
||||
ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("linked: %s %s %s in %s is replaced by the shared version", r.Name, r.Type, r.Data, zoneDesc(x.st, o))))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ops
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/validate"
|
||||
)
|
||||
|
||||
// linkedEnv has views internal and external and zone example.com in
|
||||
// internal with a few records.
|
||||
func linkedEnv(t *testing.T) *testEnv {
|
||||
env := newEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Nameservers: &[]string{"ns1.example.net."}}))
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"10.0.0.0/8"}}),
|
||||
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"any"}}))
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("internal")}))
|
||||
env.submit(env.admin,
|
||||
recIn("internal", "www", "A", "192.0.2.10"),
|
||||
recIn("internal", "mail", "A", "192.0.2.25"),
|
||||
recIn("internal", "@", "MX", "10 mail"))
|
||||
return env
|
||||
}
|
||||
|
||||
func recIn(view, name, typ, data string) Request {
|
||||
return NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: view, Name: &name, Type: &typ, Data: &data})
|
||||
}
|
||||
|
||||
// view returns a view's records of example.com as "name type data S|L",
|
||||
// S for shared, L for local.
|
||||
func (env *testEnv) view(name string) []string {
|
||||
env.t.Helper()
|
||||
s := env.state()
|
||||
v := s.ViewByName(name)
|
||||
z := s.FindZone("example.com.", v.ID)
|
||||
var out []string
|
||||
for _, r := range s.ZoneRecords(z.ID) {
|
||||
if r.Type == "NS" {
|
||||
continue
|
||||
}
|
||||
mark := "L"
|
||||
if r.SharedID != 0 {
|
||||
mark = "S"
|
||||
}
|
||||
if r.Disabled {
|
||||
mark += " disabled"
|
||||
}
|
||||
out = append(out, strings.TrimSuffix(r.Name, ".example.com.")+" "+r.Type+" "+r.Data+" "+mark)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (env *testEnv) recordID(view, name, typ string) int64 {
|
||||
env.t.Helper()
|
||||
s := env.state()
|
||||
z := s.FindZone("example.com.", s.ViewByName(view).ID)
|
||||
for _, r := range s.ZoneRecords(z.ID) {
|
||||
if r.Name == name+".example.com." && r.Type == typ {
|
||||
return r.ID
|
||||
}
|
||||
}
|
||||
env.t.Fatalf("no %s %s in %s", name, typ, view)
|
||||
return 0
|
||||
}
|
||||
|
||||
func want(t *testing.T, what string, got []string, w ...string) {
|
||||
t.Helper()
|
||||
if !slices.Equal(got, w) {
|
||||
t.Errorf("%s:\n got %q\n want %q", what, got, w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedZones(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
// A zone created linked copies everything; both are linked and shared.
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
all := []string{"mail A 192.0.2.25 S", "www A 192.0.2.10 S", "example.com. MX 10 mail.example.com. S"}
|
||||
slices.Sort(all)
|
||||
got := env.view("external")
|
||||
slices.Sort(got)
|
||||
want(t, "linked copy", got, all...)
|
||||
s := env.state()
|
||||
if zs := s.ZonesNamed("example.com."); zs[0].LinkGroup == 0 || zs[0].LinkGroup != zs[1].LinkGroup {
|
||||
t.Fatalf("not linked: %+v %+v", zs[0], zs[1])
|
||||
}
|
||||
|
||||
// Adding, changing, disabling and deleting a shared record reaches the other view.
|
||||
env.submit(env.admin, recIn("external", "ftp", "CNAME", "www"))
|
||||
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S") {
|
||||
t.Errorf("shared add didn't reach internal: %q", env.view("internal"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("192.0.2.26")}))
|
||||
if !slices.Contains(env.view("external"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("shared change didn't reach external: %q", env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordDisable, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||
if !slices.Contains(env.view("internal"), "ftp CNAME www.example.com. S disabled") {
|
||||
t.Errorf("disable didn't reach internal: %q", env.view("internal"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("ftp"), Type: model.Ptr("CNAME")}))
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
for _, r := range env.view(v) {
|
||||
if strings.HasPrefix(r, "ftp ") {
|
||||
t.Errorf("delete by match left %s in %s", r, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local override: www in internal points elsewhere; external keeps
|
||||
// the shared one.
|
||||
res := env.submit(env.admin, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.10"), Local: model.Ptr(true)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.0.0.10 L") || slices.Contains(env.view("internal"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override: internal %q", env.view("internal"))
|
||||
}
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override changed external: %q", env.view("external"))
|
||||
}
|
||||
_ = res
|
||||
// A new record of an overridden set joins it as local.
|
||||
env.submit(env.admin, recIn("internal", "www", "A", "10.0.0.11"))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.0.0.11 L") || len(env.view("external")) != 3 {
|
||||
t.Errorf("join override: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Changing the shared www in external doesn't touch internal's override.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"192.0.2.11"}}))
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.11 S") || slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("shared set with an override elsewhere: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Removing the override brings the shared records back.
|
||||
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "www", Type: "A", Data: []string{}}))
|
||||
if !slices.Contains(env.view("internal"), "www A 192.0.2.11 S") {
|
||||
t.Errorf("override removed: internal %q", env.view("internal"))
|
||||
}
|
||||
|
||||
// Make a shared set local, then share another view's version.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(true)}))
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.25")}))
|
||||
if !slices.Contains(env.view("external"), "mail A 198.51.100.25 L") || !slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("local edit: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("external", "mail", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("internal"), "mail A 198.51.100.25 S") || slices.Contains(env.view("internal"), "mail A 192.0.2.26 S") {
|
||||
t.Errorf("share external's version: internal %q", env.view("internal"))
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation: %+v", probs)
|
||||
}
|
||||
|
||||
// Undoing a changeset undoes it in both views.
|
||||
r := env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "mail", "A"), RecordInput{Data: model.Ptr("198.51.100.99")}))
|
||||
cs, err := env.e.Revert(env.ctx, r.ChangesetID, env.admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := env.e.Apply(env.ctx, cs.ID, env.admin, ApplyOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
if !slices.Contains(env.view(v), "mail A 198.51.100.25 S") {
|
||||
t.Errorf("revert in %s: %q", v, env.view(v))
|
||||
}
|
||||
}
|
||||
|
||||
// Unlinking keeps every record, all local.
|
||||
env.submit(env.admin, NewRequest(OpZoneUnlink, 0, LinkInput{Zone: "example.com", View: "external"}))
|
||||
for _, z := range env.state().ZonesNamed("example.com.") {
|
||||
if z.LinkGroup != 0 {
|
||||
t.Errorf("still linked: %s", zoneDesc(env.state(), z))
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
for _, r := range env.view(v) {
|
||||
if strings.HasSuffix(r, " S") {
|
||||
t.Errorf("still shared after unlink in %s: %s", v, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
env.submit(env.admin, recIn("internal", "solo", "A", "10.0.0.5"))
|
||||
if slices.Contains(env.view("external"), "solo A 10.0.0.5 L") {
|
||||
t.Error("an unlinked zone was changed")
|
||||
}
|
||||
|
||||
// Linking again works out what is shared: same in both, or not.
|
||||
env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "internal"}))
|
||||
in, ex := env.view("internal"), env.view("external")
|
||||
if !slices.Contains(in, "mail A 198.51.100.25 S") || !slices.Contains(ex, "mail A 198.51.100.25 S") {
|
||||
t.Errorf("relink: same records not shared: internal %q external %q", in, ex)
|
||||
}
|
||||
if !slices.Contains(in, "solo A 10.0.0.5 L") {
|
||||
t.Errorf("relink: internal-only record should be local: %q", in)
|
||||
}
|
||||
if probs := env.e.Validate(env.state()); hasCode(probs, "linked_out_of_sync") || hasCode(probs, "linked_mixed") {
|
||||
t.Errorf("validation after relink: %+v", probs)
|
||||
}
|
||||
|
||||
// Deleting one zone of two dissolves the link.
|
||||
z := env.state().FindZone("example.com.", env.state().ViewByName("external").ID)
|
||||
env.submit(env.admin, NewRequest(OpZoneDelete, z.ID, nil))
|
||||
if zs := env.state().ZonesNamed("example.com."); len(zs) != 1 || zs[0].LinkGroup != 0 {
|
||||
t.Errorf("after deleting external: %+v", zs)
|
||||
}
|
||||
for _, r := range env.view("internal") {
|
||||
if strings.HasSuffix(r, " S") {
|
||||
t.Errorf("shared record left in an unlinked zone: %s", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasCode(probs []validate.Problem, code string) bool {
|
||||
for _, p := range probs {
|
||||
if p.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestLinkedPermissions(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
u := &model.User{Username: "lan", Grants: []model.Grant{{Resource: auth.ResRecords, Access: "write", Views: []string{"internal"}}, {Resource: auth.ResZones, Access: "read"}}}
|
||||
if err := env.st.CreateUser(env.ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lan := &auth.Principal{User: u, Method: "token"}
|
||||
// A shared change would reach external, which this user may not change.
|
||||
e := env.submitErr(lan, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Data: model.Ptr("10.9.9.9")}))
|
||||
if e.Kind != KindForbidden || !strings.Contains(e.Message, "shared with example.com [external]") || !strings.Contains(e.Message, "local") {
|
||||
t.Errorf("shared change: %v", e)
|
||||
}
|
||||
// A local override is theirs to make.
|
||||
env.submit(lan, NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("www"), Type: model.Ptr("A"), Data: model.Ptr("10.9.9.9"), Local: model.Ptr(true)}))
|
||||
if !slices.Contains(env.view("internal"), "www A 10.9.9.9 L") || !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("override by a view-restricted user: internal %q external %q", env.view("internal"), env.view("external"))
|
||||
}
|
||||
// Records of a zone that isn't linked can't be "local".
|
||||
e = env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.org"), View: model.Ptr("internal"), LinkWith: "external"}))
|
||||
if e.Kind != KindNotFound {
|
||||
t.Errorf("link_with a view without the zone: %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkExisting(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
// external was made by hand: the same apart from www.
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external")}))
|
||||
env.submit(env.admin,
|
||||
recIn("external", "www", "A", "203.0.113.10"),
|
||||
recIn("external", "mail", "A", "192.0.2.25"),
|
||||
recIn("external", "@", "MX", "10 mail"))
|
||||
before := []string{}
|
||||
for _, v := range []string{"internal", "external"} {
|
||||
before = append(before, strings.Join(env.view(v), ";"))
|
||||
}
|
||||
res := env.submit(env.admin, NewRequest(OpZoneLink, 0, LinkInput{Zone: "example.com", View: "external", With: []string{"internal"}}))
|
||||
in, ex := env.view("internal"), env.view("external")
|
||||
if !slices.Contains(in, "www A 192.0.2.10 L") || !slices.Contains(ex, "www A 203.0.113.10 L") {
|
||||
t.Errorf("different www should stay local: internal %q external %q", in, ex)
|
||||
}
|
||||
if !slices.Contains(in, "mail A 192.0.2.25 S") || !slices.Contains(ex, "mail A 192.0.2.25 S") {
|
||||
t.Errorf("same mail should be shared: internal %q external %q", in, ex)
|
||||
}
|
||||
if !strings.Contains(strings.Join(res.Warnings, " "), "www.example.com A (internal, external)") {
|
||||
t.Errorf("warnings: %q", res.Warnings)
|
||||
}
|
||||
// Linking never changes what named answers: the same data as before.
|
||||
for i, v := range []string{"internal", "external"} {
|
||||
var data []string
|
||||
for _, r := range env.view(v) {
|
||||
data = append(data, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||
}
|
||||
var was []string
|
||||
for _, r := range strings.Split(before[i], ";") {
|
||||
was = append(was, strings.TrimSuffix(strings.TrimSuffix(r, " S"), " L"))
|
||||
}
|
||||
want(t, "answers in "+v, data, was...)
|
||||
}
|
||||
// Sharing internal's www replaces external's.
|
||||
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "www", "A"), RecordInput{Local: model.Ptr(false)}))
|
||||
if !slices.Contains(env.view("external"), "www A 192.0.2.10 S") {
|
||||
t.Errorf("shared from internal: external %q", env.view("external"))
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
|
||||
if in.Comment != nil {
|
||||
z.Comment = *in.Comment
|
||||
}
|
||||
if in.LinkGroup != nil {
|
||||
z.LinkGroup = *in.LinkGroup
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Disabled != nil {
|
||||
r.Disabled = *in.Disabled
|
||||
}
|
||||
if in.SharedID != nil {
|
||||
r.SharedID = *in.SharedID
|
||||
}
|
||||
for _, w := range p.Warnings {
|
||||
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w)
|
||||
}
|
||||
@@ -526,6 +532,9 @@ func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Disabled != nil {
|
||||
r.Disabled = *in.Disabled
|
||||
}
|
||||
if in.SharedID != nil {
|
||||
r.SharedID = *in.SharedID
|
||||
}
|
||||
r.Touch(c.user(), c.now)
|
||||
return nil
|
||||
}
|
||||
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
||||
k := strings.ToLower(r.Data)
|
||||
if seen[k] && !keep[k] {
|
||||
keep[k] = true
|
||||
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled {
|
||||
shared := r.SharedID
|
||||
if in.Shared != nil {
|
||||
shared = 0
|
||||
if *in.Shared {
|
||||
shared = r.SharedID
|
||||
if shared == 0 {
|
||||
shared = r.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ttlKnown && !sameTTL(r.TTL, ttl)) || r.Disabled || shared != r.SharedID {
|
||||
if ttlKnown {
|
||||
r.TTL = copyTTL(ttl)
|
||||
}
|
||||
r.Disabled = false
|
||||
r.Disabled, r.SharedID = false, shared
|
||||
r.Touch(c.user(), c.now)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.Disabled {
|
||||
// Kept, but shared or local like the rest of the set.
|
||||
if in.Shared != nil && (*in.Shared) != (r.SharedID != 0) {
|
||||
r.SharedID = 0
|
||||
if *in.Shared {
|
||||
r.SharedID = r.ID
|
||||
}
|
||||
r.Touch(c.user(), c.now)
|
||||
}
|
||||
continue
|
||||
}
|
||||
delete(st.Records, id)
|
||||
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
|
||||
if in.Comment != nil {
|
||||
r.Comment = *in.Comment
|
||||
}
|
||||
if in.Shared != nil && *in.Shared {
|
||||
r.SharedID = r.ID
|
||||
}
|
||||
r.Touch(c.user(), c.now)
|
||||
st.Records[r.ID] = r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Linked zones (docs/views.md#linked-zones): zones of the same name in
|
||||
// different views whose shared records Jarl keeps in step. Each zone
|
||||
// keeps its own copy of every shared record (with the same SharedID), so
|
||||
// named, validation and rendering see ordinary zones. A record with
|
||||
// SharedID 0 in a linked zone is local to its view: an override of the
|
||||
// shared records of the same name and type there.
|
||||
|
||||
// LinkMembers returns the zones of a link group in view order.
|
||||
func (s *State) LinkMembers(group int64) []*Zone {
|
||||
if group == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []*Zone
|
||||
for _, z := range s.Zones {
|
||||
if z.LinkGroup == group {
|
||||
out = append(out, z)
|
||||
}
|
||||
}
|
||||
s.SortByView(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LinkedWith returns the other zones z is linked with, in view order.
|
||||
func (s *State) LinkedWith(z *Zone) []*Zone {
|
||||
var out []*Zone
|
||||
for _, o := range s.LinkMembers(z.LinkGroup) {
|
||||
if o.ID != z.ID {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AnyLinked reports whether any zone is linked.
|
||||
func (s *State) AnyLinked() bool {
|
||||
for _, z := range s.Zones {
|
||||
if z.LinkGroup != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SortByView sorts zones in view order.
|
||||
func (s *State) SortByView(zs []*Zone) {
|
||||
pos := func(z *Zone) int {
|
||||
if v := s.Views[z.ViewID]; v != nil {
|
||||
return v.Position
|
||||
}
|
||||
return -1
|
||||
}
|
||||
sort.Slice(zs, func(i, j int) bool {
|
||||
if a, b := pos(zs[i]), pos(zs[j]); a != b {
|
||||
return a < b
|
||||
}
|
||||
return zs[i].ID < zs[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
// RRset returns the records of one name and type in a zone, by ID.
|
||||
func (s *State) RRset(zoneID int64, name, typ string) []*Record {
|
||||
var out []*Record
|
||||
for _, r := range s.Records {
|
||||
if r.ZoneID == zoneID && r.Name == name && r.Type == typ {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b *Record) int { return int(a.ID - b.ID) })
|
||||
return out
|
||||
}
|
||||
|
||||
// HasLocal reports whether a linked zone overrides the shared records of
|
||||
// one name and type with records of its own.
|
||||
func (s *State) HasLocal(zoneID int64, name, typ string) bool {
|
||||
for _, r := range s.Records {
|
||||
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SharedRecords returns a zone's shared records of one name and type,
|
||||
// by SharedID.
|
||||
func (s *State) SharedRecords(zoneID int64, name, typ string) []*Record {
|
||||
var out []*Record
|
||||
for _, r := range s.Records {
|
||||
if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.SharedID != 0 {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b *Record) int { return int(a.SharedID - b.SharedID) })
|
||||
return out
|
||||
}
|
||||
@@ -166,6 +166,10 @@ type Zone struct {
|
||||
// Disabled zones stay in Jarl, records and all, but named doesn't
|
||||
// load them: their zone statement is left out of named.conf.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// LinkGroup links zones of the same name in different views: Jarl
|
||||
// keeps their shared records in step (docs/views.md#linked-zones). 0
|
||||
// for a zone that isn't linked; linked zones share the ID.
|
||||
LinkGroup int64 `json:"link_group,omitempty"`
|
||||
Meta
|
||||
}
|
||||
|
||||
@@ -195,6 +199,10 @@ type Record struct {
|
||||
// Disabled records stay in Jarl but are left out of the zone (the
|
||||
// zone file lists them as comments).
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
// SharedID ties the copies of a shared record in linked zones
|
||||
// together (the same in every copy). 0 for a record of a zone that
|
||||
// isn't linked, and for a record local to its view in one that is.
|
||||
SharedID int64 `json:"shared_id,omitempty"`
|
||||
Meta
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
|
||||
for _, z := range st.SortedZones() {
|
||||
vn := st.ViewName(z)
|
||||
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) {
|
||||
out = append(out, acme.Zone{Name: z.Name, View: vn})
|
||||
out = append(out, acme.Zone{Name: z.Name, View: vn, Link: z.LinkGroup})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -157,6 +157,37 @@ func (s *Server) deleteZone(w http.ResponseWriter, r *http.Request, p *auth.Prin
|
||||
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
||||
}
|
||||
|
||||
// zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone
|
||||
// with the zones of the same name in those views (default: every view
|
||||
// that has one). zoneUnlink: POST /zones/{zone}/unlink.
|
||||
func (s *Server) zoneLink(link bool) handler {
|
||||
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in := engine.LinkInput{ZoneID: z.ID}
|
||||
op := engine.OpZoneUnlink
|
||||
if link {
|
||||
op = engine.OpZoneLink
|
||||
var body struct {
|
||||
With []string `json:"with"`
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.With = body.With
|
||||
}
|
||||
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
@@ -462,11 +493,12 @@ func (s *Server) putRRSet(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
||||
Data []string `json:"data"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
Local *bool `json:"local,omitempty"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
||||
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment, Local: in.Local})
|
||||
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
||||
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
||||
z := st.Zones[zid]
|
||||
|
||||
@@ -22,6 +22,9 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
|
||||
out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
|
||||
}
|
||||
out.Registrar = s.registrarFor(z)
|
||||
for _, o := range st.LinkedWith(z) {
|
||||
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -30,6 +33,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
|
||||
out := api.Record{Record: *r}
|
||||
if z != nil {
|
||||
out.Zone, out.View, out.RelativeName, out.EffectiveTTL = z.Name, st.ViewName(z), dnsname.Relative(r.Name, z.Name), r.EffectiveTTL(z)
|
||||
if z.LinkGroup != 0 {
|
||||
out.Linked = "local"
|
||||
if r.SharedID != 0 {
|
||||
out.Linked = "shared"
|
||||
}
|
||||
}
|
||||
if rr.IsTextType(r.Type) {
|
||||
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
|
||||
if t, ok := rr.TextValue(p.RR); ok {
|
||||
|
||||
@@ -111,6 +111,8 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS))
|
||||
m.HandleFunc("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/link", s.authed(s.zoneLink(true)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/unlink", s.authed(s.zoneLink(false)))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
|
||||
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
|
||||
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
|
||||
|
||||
@@ -95,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by FROM zones`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
z := &model.Zone{}
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||
rows, err = e.QueryContext(ctx, `SELECT id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by FROM records`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
|
||||
r := &model.Record{}
|
||||
var ttl sql.NullInt64
|
||||
var ca, ua string
|
||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.ZoneID, &r.Name, &r.Type, &ttl, &r.Data, &r.Comment, &r.Disabled, &r.SharedID, &r.Version, &ca, &ua, &r.CreatedBy, &r.UpdatedBy); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -288,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
return err
|
||||
}
|
||||
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
|
||||
return err
|
||||
@@ -299,8 +299,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
|
||||
if r.TTL != nil {
|
||||
ttl = int64(*r.TTL)
|
||||
}
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO records (id, zone_id, name, type, ttl, data, comment, disabled, shared_id, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.ZoneID, r.Name, r.Type, ttl, r.Data, r.Comment, r.Disabled, r.SharedID, r.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy)
|
||||
return err
|
||||
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
|
||||
return err
|
||||
|
||||
@@ -361,6 +361,11 @@ CREATE TABLE certificates (
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`,
|
||||
// 11: linked zones (docs/views.md#linked-zones).
|
||||
`
|
||||
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// checkLinked checks linked zones (docs/views.md#linked-zones): what can
|
||||
// be linked, and that their shared records are in step. Jarl keeps them
|
||||
// in step itself; they can only drift when a change was staged before the
|
||||
// zones were linked (or by editing the database).
|
||||
func (c *checker) checkLinked() {
|
||||
groups := map[int64][]*model.Zone{}
|
||||
for _, z := range c.s.SortedZones() {
|
||||
if z.LinkGroup != 0 {
|
||||
groups[z.LinkGroup] = append(groups[z.LinkGroup], z)
|
||||
}
|
||||
}
|
||||
for _, g := range sortedGroupIDs(groups) {
|
||||
zs := c.s.LinkMembers(g)
|
||||
label := func(z *model.Zone) string {
|
||||
if v := c.s.ViewName(z); v != "" {
|
||||
return dnsname.Display(z.Name) + " [" + v + "]"
|
||||
}
|
||||
return dnsname.Display(z.Name)
|
||||
}
|
||||
bad := false
|
||||
for _, z := range zs {
|
||||
switch {
|
||||
case z.Type != model.ZonePrimary:
|
||||
c.errf("linked_zone_type", "zone", z.ID, "zone %s is linked, but only primary zones can be: unlink it", label(z))
|
||||
bad = true
|
||||
case z.Name != zs[0].Name:
|
||||
c.errf("linked_zone_name", "zone", z.ID, "zone %s is linked with %s; only zones of the same name can be linked", label(z), label(zs[0]))
|
||||
bad = true
|
||||
}
|
||||
}
|
||||
if len(zs) == 1 {
|
||||
c.infof("linked_zone_alone", "zone", zs[0].ID, "zone %s is linked with no other zone; unlink it (jarl zone unlink)", label(zs[0]))
|
||||
continue
|
||||
}
|
||||
if bad {
|
||||
continue
|
||||
}
|
||||
// Shared and local records of one name and type can't mix in a zone.
|
||||
type key struct{ name, typ string }
|
||||
keys := map[key]bool{}
|
||||
var order []key
|
||||
for _, z := range zs {
|
||||
for _, r := range c.s.ZoneRecords(z.ID) {
|
||||
k := key{r.Name, r.Type}
|
||||
if !keys[k] {
|
||||
keys[k] = true
|
||||
order = append(order, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range order {
|
||||
var truth []*model.Record
|
||||
var truthZone *model.Zone
|
||||
for _, z := range zs {
|
||||
rs := c.s.RRset(z.ID, k.name, k.typ)
|
||||
local, shared := 0, 0
|
||||
for _, r := range rs {
|
||||
if r.SharedID == 0 {
|
||||
local++
|
||||
} else {
|
||||
shared++
|
||||
}
|
||||
}
|
||||
if local > 0 && shared > 0 {
|
||||
c.errf("linked_mixed", "zone", z.ID, "%s %s in zone %s has both shared and local records; make the set one or the other", dnsname.Display(k.name), k.typ, label(z)).Zone = z.Name
|
||||
continue
|
||||
}
|
||||
if local > 0 {
|
||||
continue // an override
|
||||
}
|
||||
got := c.s.SharedRecords(z.ID, k.name, k.typ)
|
||||
if truthZone == nil {
|
||||
truth, truthZone = got, z
|
||||
continue
|
||||
}
|
||||
if diff := sharedDiff(truth, got); diff != "" {
|
||||
p := c.errf("linked_out_of_sync", "zone", z.ID, "linked zones %s and %s differ at %s %s (%s). Jarl keeps them in step, so a change was probably staged before they were linked: link them again (jarl zone link) to work out what is shared", label(truthZone), label(z), dnsname.Display(k.name), k.typ, diff)
|
||||
p.Zone = z.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedGroupIDs(m map[int64][]*model.Zone) []int64 {
|
||||
var out []int64
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
for i := range out {
|
||||
for j := i + 1; j < len(out); j++ {
|
||||
if out[j] < out[i] {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sharedDiff says how two zones' copies of a shared set differ ("" when
|
||||
// they don't).
|
||||
func sharedDiff(a, b []*model.Record) string {
|
||||
idx := map[int64]*model.Record{}
|
||||
for _, r := range b {
|
||||
idx[r.SharedID] = r
|
||||
}
|
||||
var out []string
|
||||
for _, r := range a {
|
||||
o := idx[r.SharedID]
|
||||
delete(idx, r.SharedID)
|
||||
switch {
|
||||
case o == nil:
|
||||
out = append(out, "missing "+r.Data)
|
||||
case o.Data != r.Data:
|
||||
out = append(out, r.Data+" vs "+o.Data)
|
||||
case !sameTTLPtr(o.TTL, r.TTL):
|
||||
out = append(out, "TTL of "+r.Data)
|
||||
case o.Disabled != r.Disabled:
|
||||
out = append(out, "disabled "+r.Data)
|
||||
}
|
||||
}
|
||||
for _, r := range idx {
|
||||
out = append(out, "extra "+r.Data)
|
||||
}
|
||||
if len(out) > 3 {
|
||||
out = append(out[:3], fmt.Sprintf("and %d more", len(out)-3))
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func sameTTLPtr(a, b *uint32) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
@@ -151,6 +151,7 @@ func State(s *model.State, opts Options) []Problem {
|
||||
c.checkExposure()
|
||||
c.checkDNSSEC()
|
||||
c.checkBlocking()
|
||||
c.checkLinked()
|
||||
c.applyAcks()
|
||||
sortProblems(c.problems)
|
||||
return c.problems
|
||||
|
||||
@@ -34,12 +34,14 @@ export async function render(el, params, query) {
|
||||
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
|
||||
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
|
||||
zone.dnssec_policy ? [h("a", { href: "#/zones/" + id + "/dnssec" }, badge(zone.dnssec_policy === "insecure" ? "DNSSEC: turning off" : "DNSSEC", zone.dnssec_policy === "insecure" ? "warn" : "ok")), " "] : null,
|
||||
zone.linked_with ? [badge("linked with " + zone.linked_with.join(", "), "accent"), " "] : null,
|
||||
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
|
||||
h("div", { class: "actions" },
|
||||
zone.disabled
|
||||
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone")
|
||||
: h("button", { disabled: !!zone.dnssec_policy, title: zone.dnssec_policy ? "A signed zone can't be disabled: turn signing off first" : "Stop serving the zone, keeping it in Jarl", onclick: () => toggleZone(zone) }, "Disable zone"),
|
||||
" ",
|
||||
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
|
||||
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
|
||||
const banner = zone.disabled ? h("div", { class: "msg warn" },
|
||||
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
|
||||
@@ -68,6 +70,51 @@ export async function render(el, params, query) {
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
// ---- linked zones (docs/views.md#linked-zones)
|
||||
|
||||
// linkButton links the zone with the same zone in other views, or takes
|
||||
// it out of its link.
|
||||
function linkButton(zone) {
|
||||
if (zone.linked_with) {
|
||||
return h("button", { title: "Stop keeping this zone in step with " + zone.linked_with.join(", ") + "; its records stay, all local", onclick: async () => {
|
||||
if (!await confirmDialog("Unlink zone", "Take " + zone.display_name + " in view " + zone.view + " out of its link with " + zone.linked_with.join(", ") + "? Its records stay as they are, all local to this view; changes no longer reach the other views.", "Unlink")) return;
|
||||
try {
|
||||
toast(describeResult(await change("POST", "/zones/" + zone.id + "/unlink")));
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
} }, "Unlink");
|
||||
}
|
||||
return h("button", { title: "Keep this zone's records in step with the same zone in other views", onclick: () => linkDialog(zone) }, "Link…");
|
||||
}
|
||||
|
||||
async function linkDialog(zone) {
|
||||
const zones = await get("/zones");
|
||||
const others = zones.filter((z) => z.name === zone.name && z.id !== zone.id && z.type === "primary" && z.view);
|
||||
if (!others.length) {
|
||||
await modal("Link zone", h("p", null, "No other view has a zone " + zone.display_name + ". Create it there linked: New zone, the same name, \"Copy records from\" this one with \"Keep them linked\" ticked."));
|
||||
return;
|
||||
}
|
||||
const boxes = others.map((z) => { const c = checkbox("view " + z.view, true); c.view = z.view; return c; });
|
||||
const err = h("div");
|
||||
await modal("Link " + zone.display_name, h("div", null,
|
||||
h("p", null, "Linked zones keep their shared records in step: a change in one view reaches the others in the same change. Each view can still have records of its own, which override the shared ones of that name and type there, and keeps its own options."),
|
||||
h("p", { class: "muted small" }, "Linking changes no answer: records that are the same in every view become shared; anything that differs stays local to each view. Share one later from the view whose version is right."),
|
||||
field("Link view " + zone.view + " with", h("div", null, boxes)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Link", kind: "primary", onclick: async () => {
|
||||
const w = boxes.filter((b) => b.input.checked).map((b) => b.view);
|
||||
if (!w.length) { mount(err, h("div", { class: "msg err" }, "Pick at least one view.")); return false; }
|
||||
try {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/link", { with: w });
|
||||
toast(describeResult(r));
|
||||
(r.warnings || []).forEach((m) => toast(m, "warn", 12000));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- name servers: serials on the other servers, and the delegation
|
||||
|
||||
const nsStatus = {
|
||||
@@ -321,6 +368,8 @@ async function recordsTab(el, zone, query) {
|
||||
const fData = input({ class: "mono", placeholder: typeInfo.A.example });
|
||||
const fComment = input({ placeholder: "optional", style: { width: "160px" } });
|
||||
const fPTR = checkbox("PTR", false, { title: "Also add the matching PTR record in the reverse zone" });
|
||||
const linked = !!zone.linked_with;
|
||||
const fLocal = checkbox("Only here", false, { title: "Add it to view " + zone.view + " only, overriding the shared records of this name and type here (otherwise it reaches " + (zone.linked_with || []).join(", ") + " too)" });
|
||||
const hint = h("div", { class: "hint" });
|
||||
const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
|
||||
const suggest = h("div", { class: "aml-suggest" });
|
||||
@@ -426,6 +475,7 @@ async function recordsTab(el, zone, query) {
|
||||
if (fTTL.value !== "") body.ttl = Number(fTTL.value);
|
||||
if (fComment.value.trim()) body.comment = fComment.value.trim();
|
||||
if (fPTR.input.checked) body.ptr = true;
|
||||
if (linked && fLocal.input.checked) body.local = true;
|
||||
try {
|
||||
const r = await change("POST", "/zones/" + zone.id + "/records", body);
|
||||
toast(describeResult(r));
|
||||
@@ -452,6 +502,7 @@ async function recordsTab(el, zone, query) {
|
||||
h("div", { class: "f grow" }, h("label", null, "Data"), fData),
|
||||
h("div", { class: "f" }, h("label", null, "Comment"), fComment),
|
||||
h("div", { class: "f" }, h("label", null, " "), fPTR),
|
||||
linked ? h("div", { class: "f" }, h("label", null, " "), fLocal) : null,
|
||||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
|
||||
|
||||
// Filters and table
|
||||
@@ -479,8 +530,14 @@ async function recordsTab(el, zone, query) {
|
||||
{ label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" },
|
||||
{ label: "Type", render: (r) => h("strong", null, r.type) },
|
||||
{ label: "Data", render: (r) => r.data, tdClass: "data" },
|
||||
linked ? { label: "Views", render: (r) => r.linked === "local"
|
||||
? badge("only " + zone.view, "accent")
|
||||
: h("span", { class: "muted small", title: "Shared: the same in " + [zone.view, ...zone.linked_with].join(", ") + " (unless a view overrides it)" }, "shared") } : null,
|
||||
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
|
||||
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
|
||||
linked ? [r.linked === "local"
|
||||
? h("button", { class: "small reveal", title: "Make this view's " + r.relative_name + " " + r.type + " the shared version: it replaces the other views' (" + zone.linked_with.join(", ") + ")", onclick: () => setLocal(r, false) }, "Share")
|
||||
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
|
||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||
@@ -488,7 +545,7 @@ async function recordsTab(el, zone, query) {
|
||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||
: h("button", { class: "small toggle", title: "Leave the record out of the zone, but keep it here", onclick: () => toggle(r) }, "Disable"), " ",
|
||||
h("button", { class: "small danger", title: "Delete", onclick: () => del(r) }, "Delete")), tdClass: "actions-cell" },
|
||||
], rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
|
||||
].filter(Boolean), rows, { empty: current.length ? "No records match." : "No records yet.", rowClass: (r, i) => [
|
||||
r.pending ? "pending-" + r.pending : "",
|
||||
r.disabled ? "disabled" : "",
|
||||
// A record followed by more of the same name: a faint line, so the
|
||||
@@ -523,6 +580,14 @@ async function recordsTab(el, zone, query) {
|
||||
await redraw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const setLocal = async (r, local) => {
|
||||
if (!local && !(await confirmDialog("Share " + r.relative_name + " " + r.type, "View " + zone.view + "'s " + r.relative_name + " " + r.type + " records become the shared version, in " + zone.linked_with.join(", ") + " too: the records there of that name and type are replaced.", "Share"))) return;
|
||||
try {
|
||||
const res = await change("PATCH", "/records/" + r.id, { local });
|
||||
toast(describeResult(res));
|
||||
await redraw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const edit = (r) => { editing = r; draw(); };
|
||||
const mountEdit = () => {
|
||||
const r = editing;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList } from "../ui.js";
|
||||
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
|
||||
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { checkDomain, reverseZoneFor } from "../names.js";
|
||||
@@ -72,12 +72,17 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
const file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
|
||||
const inView = select(views.map((v) => [v.name, v.name]), "");
|
||||
const cloneSel = select([["", "– don't copy –"]], "");
|
||||
const linkBox = checkbox("Keep them linked: shared records stay in step, each view can add its own", true);
|
||||
const cloneWrap = h("div", null, cloneSel, linkBox);
|
||||
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
|
||||
cloneSel.addEventListener("change", syncLink);
|
||||
const comment = input();
|
||||
const updateClones = () => {
|
||||
// Records can only be copied into a single zone.
|
||||
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
|
||||
mount(cloneSel, h("option", { value: "" }, "– don't copy –"),
|
||||
zones.filter((z) => z.type === "primary" && z.name === n).map((z) => h("option", { value: z.id }, z.display_name + " [" + (z.view || "no view") + "]")));
|
||||
syncLink();
|
||||
};
|
||||
// check validates the name as typed and returns the zone name (or null);
|
||||
// a network that needs several reverse zones gives a list of names.
|
||||
@@ -152,7 +157,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
field("Name server addresses", nsAddr, "Optional, for name servers inside this zone: 'ns1 192.0.2.1' per line (glue)."),
|
||||
field("SOA contact", contact, "Email address of the zone's administrator."),
|
||||
field("Default TTL", ttl, "Seconds, used by records without their own TTL."),
|
||||
field("Copy records from", cloneSel, "For split-horizon setups: start with the records of this zone in another view.")],
|
||||
field("Copy records from", cloneWrap, "For split-horizon setups: start with the records of this zone in another view. Linked, Jarl keeps the shared records the same in both (docs/views.md#linked-zones).")],
|
||||
secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
|
||||
stub: [field("Primary servers", primaries, "One per line.")],
|
||||
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")],
|
||||
@@ -196,7 +201,12 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
|
||||
data.nameservers = lines(ns.value);
|
||||
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
|
||||
data.default_ttl = Number(ttl.value) || undefined;
|
||||
if (cloneSel.value) { data.clone_from = Number(cloneSel.value); delete data.nameservers; }
|
||||
if (cloneSel.value) {
|
||||
const src = zones.find((z) => z.id === Number(cloneSel.value));
|
||||
if (linkBox.input.checked && src && src.view) data.link_with = src.view;
|
||||
else data.clone_from = Number(cloneSel.value);
|
||||
delete data.nameservers;
|
||||
}
|
||||
data.records = lines(nsAddr.value).map((l) => {
|
||||
const [n, a] = l.split(/\s+/);
|
||||
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
|
||||
|
||||
@@ -388,6 +388,40 @@ expect_dig "unsigned from here: the internal view" "127.0.0.1" ns1.adbraun.test
|
||||
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:"
|
||||
# Linked zones (docs/views.md#linked-zones): the same zone in both views,
|
||||
# shared records kept in step, local ones per view.
|
||||
run "zone link: what is the same is shared" 0 $J zone link adbraun.test --view internal --with external
|
||||
contains "differ between the views and stay local"
|
||||
run "zone show names the link" 0 $J zone show adbraun.test --view external
|
||||
contains "Linked with: *internal"
|
||||
run "record list shows shared and local" 0 $J record list adbraun.test --view external
|
||||
contains "ns1 *[0-9]*\\* *A *local *203.0.113.1"
|
||||
contains "@ *[0-9]*\\* *NS *shared"
|
||||
run "a shared add in internal" 0 $J record add adbraun.test linked A 198.51.100.7 --view internal
|
||||
expect_dig "reaches the external view" "198.51.100.7" -k $E/external.key linked.adbraun.test A
|
||||
expect_dig "and is in the internal view" "198.51.100.7" linked.adbraun.test A
|
||||
run "a local override in internal" 0 $J record add adbraun.test linked A 10.1.1.1 --view internal --local
|
||||
expect_dig "internal answers its own" "10.1.1.1" linked.adbraun.test A
|
||||
expect_dig "external keeps the shared one" "198.51.100.7" -k $E/external.key linked.adbraun.test A
|
||||
LID=$($J record list adbraun.test --view internal --name linked -o id)
|
||||
run "share internal's version" 0 $J record update $LID --shared
|
||||
expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test A
|
||||
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
||||
contains "linked with internal"
|
||||
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
||||
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||
run "unlinked: no link column" 0 $J record list adbraun.test --view external
|
||||
not_contains "shared"
|
||||
run "zone create --link-with: a linked copy" 0 $J zone create linkcopy.test --view internal --ns ns1.adbraun.test.
|
||||
run "a record in it" 0 $J record add linkcopy.test www A 192.0.2.80 --view internal
|
||||
run "copy it into external, linked" 0 $J zone create linkcopy.test --view external --link-with internal
|
||||
expect_dig "the copy answers" "192.0.2.80" -k $E/external.key www.linkcopy.test A
|
||||
run "delete the copy" 0 $J zone delete linkcopy.test --view external -y
|
||||
run "the other is unlinked again" 0 $J zone show linkcopy.test --view internal
|
||||
not_contains "Linked with"
|
||||
run "delete the other" 0 $J zone delete linkcopy.test --view internal -y
|
||||
# Internet exposure (docs/exposure.md): external lets in anyone, internal
|
||||
# only homelab.
|
||||
run "exposure" 0 $J exposure
|
||||
|
||||
Reference in new issue
Block a user