Compare commits

..
Author SHA1 Message Date
adamandClaude Opus 5.5 ebd3a3dfbe Release 0.4.1-rc.3
release / release (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:27:21 +01:00
adamandClaude Opus 5.5 fd0cbb47a9 Web UI: linked views link to their zones; pick views to link one per row, none ticked
The "linked with" badge links each view's zone (the API's zones carry links: id and view). The Link dialog lists the views one per row, with record count and any link they already have; nothing is ticked, Link waits for a choice and names every zone that will join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:27:00 +01:00
adamandClaude Opus 5.5 057433c198 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>
2026-10-12 15:53:58 +01:00
33 changed files with 1776 additions and 59 deletions

No files matched your search

+14
View File
@@ -40,6 +40,20 @@ All notable changes to Jarl are documented here. The format follows
### Added ### 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 - TLS certificates ([docs/certificates.md](docs/certificates.md)): Jarl
obtains certificates from Let's Encrypt or any ACME CA (account set up 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 in jarl.yaml under `acme.account`) and renews them itself, from the CA's
+6 -3
View File
@@ -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 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 (HTTPS without a restart) and for named's DNS over TLS and HTTPS (named
rereads them on renewal), or bring your own. 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 * **Adopts an existing server**: imports named.conf and zone files
(comments included), then switches named over with an automatic (comments included), then switches named over with an automatic
fallback. fallback.
@@ -194,10 +197,10 @@ review step.
## Status ## Status
Version 0.4.0 is the current release, and 0.4.1 is in testing Version 0.4.0 is the current release, and 0.4.1 is in testing
(0.4.1-rc.2): it adds Prometheus metrics, address ranges in ACLs and (0.4.1-rc.3): it adds Prometheus metrics, address ranges in ACLs and
other address match lists, safer changeset handling, internet exposure other address match lists, safer changeset handling, internet exposure
checks with response rate limiting, and TLS certificates Jarl obtains and checks with response rate limiting, TLS certificates Jarl obtains and
renews itself. 0.4.0 added blocking (blocklists and renews itself, and linked zones for split DNS. 0.4.0 added blocking (blocklists and
your own rules, like Pi-hole, done by named, with different blocking for your own rules, like Pi-hole, done by named, with different blocking for
different devices), disabling zones, server statistics and view keys, on different devices), disabling zones, server statistics and view keys, on
top of 0.3.0's disabled records, script-friendly CLI output and client top of 0.3.0's disabled records, script-friendly CLI output and client
+1 -1
View File
@@ -1 +1 @@
0.4.1-rc.2 0.4.1-rc.3
+1 -1
View File
@@ -83,7 +83,7 @@ func acmeZones(c *client.Client, name, view string) ([]acme.Zone, error) {
} }
refs := make([]acme.Zone, len(zones)) refs := make([]acme.Zone, len(zones))
for i, z := range 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) out := acme.Zones(name, refs, view)
if len(out) == 0 { if len(out) == 0 {
+37 -11
View File
@@ -12,34 +12,60 @@ import (
// zoneViews expands a --view value for a record command: "" or one view // 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 // as it is, "a,b" to those views, and "all" to every view that has the
// zone. // 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) { func zoneViews(c *client.Client, zone, view string) ([]string, error) {
if view != "all" && !strings.Contains(view, ",") { if view != "all" && !strings.Contains(view, ",") {
return []string{view}, nil 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 var zones []api.Zone
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil { if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
return nil, err return nil, err
} }
want := strings.ToLower(strings.TrimSuffix(zone, ".")) want := strings.ToLower(strings.TrimSuffix(zone, "."))
link := map[string]int64{}
var out []string var out []string
for _, z := range zones { for _, z := range zones {
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" { if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
out = append(out, 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 { if len(out) == 0 {
return nil, fmt.Errorf("zone %s is in no view", zone) return nil, fmt.Errorf("zone %s is in no view", zone)
} }
return out, nil // 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 // perView runs a record command once per view. With several views, the
+113 -5
View File
@@ -93,6 +93,9 @@ func zoneCmd() *cobra.Command {
if z.InView != "" { if z.InView != "" {
fmt.Fprintf(w, "In view:\t%s\n", 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 != "" { if z.ZoneFile != "" {
fmt.Fprintf(w, "File:\t%s\n", 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)") show.Flags().StringVar(&view, "view", "", "view of the zone (when the name exists in several views)")
var ns, records, primaries, forwarders, opts []string 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 var ttl uint32
create := &cobra.Command{ create := &cobra.Command{
Use: "create NAME", 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 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 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 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), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient() c, err := newClient()
@@ -149,6 +153,9 @@ and a network between octet boundaries, like a /22, gets one zone per /24
if inView != "" { if inView != "" {
body["in_view"] = inView body["in_view"] = inView
} }
if linkWith != "" {
body["link_with"] = linkWith
}
var recs []map[string]any var recs []map[string]any
for _, r := range records { for _, r := range records {
f := strings.Fields(r) 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(&cloneView, "clone-view", "", "view of --clone-from")
create.Flags().StringVar(&file, "file", "", "zone file (hint and redirect zones)") 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(&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") create.Flags().StringVar(&comment, "comment", "", "comment")
var refresh, retry, expire, minimum, serial uint32 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(&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") 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 return cmd
} }
@@ -456,8 +464,25 @@ const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\"
func recordCmd() *cobra.Command { func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"} cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string 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 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{ list := &cobra.Command{
Use: "list ZONE", Use: "list ZONE",
@@ -479,8 +504,15 @@ func recordCmd() *cobra.Command {
if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil { if err := c.Get(ctx(), path+"/records", qv, &recs); err != nil {
return err return err
} }
linked := false
for _, r := range recs {
linked = linked || r.Linked != ""
}
return output(recs, func(w io.Writer) { return output(recs, func(w io.Writer) {
rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}} rows := [][]string{{"ID", "NAME", "TTL", "TYPE", "DATA", "COMMENT"}}
if linked {
rows[0] = []string{"ID", "NAME", "TTL", "TYPE", "LINK", "DATA", "COMMENT"}
}
for _, r := range recs { for _, r := range recs {
ttl := fmt.Sprint(r.EffectiveTTL) ttl := fmt.Sprint(r.EffectiveTTL)
if r.TTL == nil { if r.TTL == nil {
@@ -490,10 +522,18 @@ func recordCmd() *cobra.Command {
if r.Disabled { if r.Disabled {
comment = strings.TrimSpace("[disabled] " + comment) 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}) rows = append(rows, []string{fmt.Sprint(r.ID), r.RelativeName, ttl, r.Type, r.Data, comment})
} }
if !wide { if !wide {
fitColumn(rows, 4) col := 4
if linked {
col = 5
}
fitColumn(rows, col)
} }
for _, row := range rows { for _, row := range rows {
fmt.Fprintln(w, strings.Join(row, "\t")) fmt.Fprintln(w, strings.Join(row, "\t"))
@@ -535,6 +575,9 @@ and splitting are automatic).
if ptr { if ptr {
body["ptr"] = true body["ptr"] = true
} }
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view) views, err := zoneViews(c, args[0], view)
if err != nil { if err != nil {
return err 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(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
add.Flags().StringVar(&comment, "comment", "", "comment") add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)") add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
linkFlags(add, "add it")
var data string var data string
update := &cobra.Command{ update := &cobra.Command{
@@ -582,6 +626,9 @@ and splitting are automatic).
if ptr { if ptr {
body["ptr"] = true body["ptr"] = true
} }
if err := linkBody(body); err != nil {
return err
}
if len(body) == 0 { if len(body) == 0 {
return fmt.Errorf("nothing to change (see --help)") 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(&ttlArg, "ttl", "", `new TTL, or "default" for the zone default`)
update.Flags().StringVar(&comment, "comment", "", "new comment") update.Flags().StringVar(&comment, "comment", "", "new comment")
update.Flags().BoolVar(&ptr, "ptr", false, "move the PTR record along (A/AAAA)") 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{ del := &cobra.Command{
Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID", Use: "delete ZONE NAME TYPE [DATA...] | delete --id ID",
@@ -678,6 +727,9 @@ spaces.
if comment != "" { if comment != "" {
body["comment"] = comment body["comment"] = comment
} }
if err := linkBody(body); err != nil {
return err
}
views, err := zoneViews(c, args[0], view) views, err := zoneViews(c, args[0], view)
if err != nil { if err != nil {
return err return err
@@ -691,6 +743,7 @@ spaces.
set.Flags().StringVar(&view, "view", "", viewsFlagHelp) set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset") set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records") set.Flags().StringVar(&comment, "comment", "", "comment for new records")
linkFlags(set, "set it")
toggle := func(disable bool) *cobra.Command { toggle := func(disable bool) *cobra.Command {
verb := "enable" 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
View File
@@ -342,6 +342,21 @@ registrar_error`.
### Records ### 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 | | Method | Path | Body / query |
|--------|------|--------------| |--------|------|--------------|
| GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) | | GET | `/zones/{zone}/records` | `?name=`, `?type=`, `?q=`, `?disabled=1` (only disabled records) or `0` (only enabled ones) |
+12
View File
@@ -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 are deleted. Try it with `--dry-run` first. `--option "NAME VALUE"` on
`zone create` sets any zone option in the same step. `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 When a zone exists in several views, add `--view NAME` to commands that
name it. name it.
+14
View File
@@ -104,6 +104,20 @@ applied.
| `dnssec_old_keys` | info | Signing a zone whose key directory still has old keys | | `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) | | `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 ## Blocking
From [blocking.md](blocking.md). From [blocking.md](blocking.md).
+81 -9
View File
@@ -88,19 +88,27 @@ and [exposure.md](exposure.md) to set up response rate limiting.
### 4. Give the external view its zones ### 4. Give the external view its zones
**Different data per view (split DNS).** Copy the zone, then change what **Mostly the same, a few differences (split DNS).** Create the external
should differ: 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 ```sh
jarl zone create adbraun.lol --view external --clone-from adbraun.lol --clone-view internal 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 **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: 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 `jarl-view-` are reserved, and the view keys can be used in your own
options (`allow-transfer { key jarl-view-external; }`). 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 ## Living with views
* Commands that name a zone that exists in several views need `--view` * Commands that name a zone that exists in several views need `--view`
@@ -167,7 +236,10 @@ options (`allow-transfer { key jarl-view-external; }`).
guess. guess.
* `jarl acme` writes challenge records into **every** view that has the * `jarl acme` writes challenge records into **every** view that has the
zone, so Let's Encrypt (external) and internal clients see the same 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 order internal external` changes the match order, and
`jarl view list` shows it. `jarl view list` shows it.
* Deleting a view is refused while it still has zones, unless you pass * Deleting a view is refused while it still has zones, unless you pass
+17 -1
View File
@@ -21,6 +21,10 @@ const Comment = "ACME DNS-01 challenge"
type Zone struct { type Zone struct {
Name string // as Jarl stores it, e.g. "adbraun.lol." Name string // as Jarl stores it, e.g. "adbraun.lol."
View string // "" when views are not used 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 // 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) 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 // Requests returns the changes that set (or, with set false, remove) the
+13
View File
@@ -29,6 +29,11 @@ type Zone struct {
// Registrar names the registrar Jarl can manage the domain at // Registrar names the registrar Jarl can manage the domain at
// ("porkbun"), when one is configured for it. // ("porkbun"), when one is configured for it.
Registrar string `json:"registrar,omitempty"` 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"`
// Links are the zones this one is linked with, with their views.
Links []ZoneLink `json:"links,omitempty"`
// Pending is set when listing with a changeset: add, change or delete. // Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"` Pending string `json:"pending,omitempty"`
} }
@@ -40,6 +45,8 @@ type Record struct {
View string `json:"view,omitempty"` View string `json:"view,omitempty"`
RelativeName string `json:"relative_name"` RelativeName string `json:"relative_name"`
EffectiveTTL uint32 `json:"effective_ttl"` 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 is the decoded value of TXT and SPF records.
Text *string `json:"text,omitempty"` Text *string `json:"text,omitempty"`
// Pending is set when listing with a changeset: add, change, delete, // Pending is set when listing with a changeset: add, change, delete,
@@ -562,3 +569,9 @@ type BlockRule struct {
AutoViews bool `json:"auto_views,omitempty"` AutoViews bool `json:"auto_views,omitempty"`
Pending string `json:"pending,omitempty"` Pending string `json:"pending,omitempty"`
} }
// ZoneLink is a zone a zone is linked with.
type ZoneLink struct {
ID int64 `json:"id"`
View string `json:"view"`
}
+1 -1
View File
@@ -139,7 +139,7 @@ func primaryZones(st *model.State) []acme.Zone {
var out []acme.Zone var out []acme.Zone
for _, z := range st.SortedZones() { for _, z := range st.SortedZones() {
if z.Type == model.ZonePrimary && !z.Disabled { 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 return out
+2 -2
View File
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k] n := to.Zones[k]
s := n.SOA s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File, 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 { 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} 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) { for _, k := range sortedKeys(to.Records) {
n := to.Records[k] 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 { if n.TTL != nil {
in.TTL = model.Some(*n.TTL) in.TTL = model.Some(*n.TTL)
} else { } else {
+157 -9
View File
@@ -131,7 +131,24 @@ func dateSerial(now time.Time) uint32 {
} }
func (x *expander) expand(req Request) ([]model.Op, error) { func (x *expander) expand(req Request) ([]model.Op, error) {
ops, err := x.expandOne(req)
if err != nil {
return nil, err
}
switch req.Op { 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: case OpZoneCreate:
return x.zoneCreate(req) return x.zoneCreate(req)
case OpZoneUpdate, OpZoneDelete: 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, "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: case OpRecordCreate:
return x.recordCreate(req) return x.recordCreate(req)
case OpRecordUpdate: case OpRecordUpdate:
@@ -355,7 +389,25 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
records := in.Records records := in.Records
cloneFrom := in.CloneFrom cloneFrom := in.CloneFrom
zoneOpts := in.Options 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 set := x.st.Settings
var src *model.Zone var src *model.Zone
@@ -480,9 +532,30 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, err 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 { if src != nil {
for _, r := range x.st.ZoneRecords(src.ID) { 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)} 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 { if r.Disabled {
ri.Disabled = model.Ptr(true) ri.Disabled = model.Ptr(true)
} }
@@ -512,10 +585,12 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
summary += " in view " + v.Name 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) + ")" 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 { 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)} 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 != "" { if zo.Comment != "" {
@@ -666,7 +741,27 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
if err != nil { if err != nil {
return nil, err 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 { if ptr {
more, err := x.ptrCreate(z, owner, p) more, err := x.ptrCreate(z, owner, p)
if err != nil { if err != nil {
@@ -690,6 +785,35 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
ptr := in.PTR ptr := in.PTR
in.PTR = false in.PTR = false
in.Zone, in.View = "", "" 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 name, typ, data := r.Name, r.Type, r.Data
if in.Name != nil { if in.Name != nil {
name = *in.Name name = *in.Name
@@ -751,11 +875,17 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
case len(changes) > 0: case len(changes) > 0:
summary += ": " + strings.Join(changes, ", ") 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 base := req.BaseVersion
if base == 0 { if base == 0 {
base = r.Version 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 disabled := r.Disabled
if in.Disabled != nil { if in.Disabled != nil {
disabled = *in.Disabled disabled = *in.Disabled
@@ -953,17 +1083,35 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
canon = append(canon, p.Data) canon = append(canon, p.Data)
} }
in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon 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 { if len(canon) > 0 {
in.NewIDs, err = x.ids(len(canon)) in.NewIDs, err = x.ids(len(canon))
if err != nil { if err != nil {
return nil, err 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 { 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) { func (x *expander) viewCreate(req Request) ([]model.Op, error) {
+35
View File
@@ -55,6 +55,10 @@ const (
// back, become view.update or settings.update operations. // back, become view.update or settings.update operations.
OpExposureAck = "exposure.ack" OpExposureAck = "exposure.ack"
OpExposureUnack = "exposure.unack" 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 // AckInput acknowledges (or un-acknowledges) an exposure finding of a view
@@ -131,6 +135,13 @@ type ZoneInput struct {
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
// Disabled keeps the zone in Jarl but out of named's configuration. // Disabled keeps the zone in Jarl but out of named's configuration.
Disabled *bool `json:"disabled,omitempty"` 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 // Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to // configured default name servers), additional records, and a zone to
@@ -167,6 +178,25 @@ type RecordInput struct {
PTR bool `json:"ptr,omitempty"` PTR bool `json:"ptr,omitempty"`
// MissingOK makes a delete-by-match succeed when nothing matches. // MissingOK makes a delete-by-match succeed when nothing matches.
MissingOK bool `json:"missing_ok,omitempty"` 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. // RRSetInput replaces all records of one name and type.
@@ -181,6 +211,11 @@ type RRSetInput struct {
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
// NewIDs are allocated for records that need to be created. // NewIDs are allocated for records that need to be created.
NewIDs []int64 `json:"new_ids,omitempty"` 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. // ViewInput creates or updates a view.
+476
View File
@@ -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
}
+287
View File
@@ -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
View File
@@ -265,6 +265,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.Comment != nil { if in.Comment != nil {
z.Comment = *in.Comment z.Comment = *in.Comment
} }
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
return nil return nil
} }
@@ -447,6 +450,9 @@ func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error {
if in.Disabled != nil { if in.Disabled != nil {
r.Disabled = *in.Disabled r.Disabled = *in.Disabled
} }
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
for _, w := range p.Warnings { for _, w := range p.Warnings {
c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) 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 { if in.Disabled != nil {
r.Disabled = *in.Disabled r.Disabled = *in.Disabled
} }
if in.SharedID != nil {
r.SharedID = *in.SharedID
}
r.Touch(c.user(), c.now) r.Touch(c.user(), c.now)
return nil return nil
} }
@@ -662,16 +671,34 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
k := strings.ToLower(r.Data) k := strings.ToLower(r.Data)
if seen[k] && !keep[k] { if seen[k] && !keep[k] {
keep[k] = true 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 { if ttlKnown {
r.TTL = copyTTL(ttl) r.TTL = copyTTL(ttl)
} }
r.Disabled = false r.Disabled, r.SharedID = false, shared
r.Touch(c.user(), c.now) r.Touch(c.user(), c.now)
} }
continue continue
} }
if r.Disabled { 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 continue
} }
delete(st.Records, id) delete(st.Records, id)
@@ -692,6 +719,9 @@ func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error {
if in.Comment != nil { if in.Comment != nil {
r.Comment = *in.Comment r.Comment = *in.Comment
} }
if in.Shared != nil && *in.Shared {
r.SharedID = r.ID
}
r.Touch(c.user(), c.now) r.Touch(c.user(), c.now)
st.Records[r.ID] = r st.Records[r.ID] = r
} }
+101
View File
@@ -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
}
+8
View File
@@ -166,6 +166,10 @@ type Zone struct {
// Disabled zones stay in Jarl, records and all, but named doesn't // Disabled zones stay in Jarl, records and all, but named doesn't
// load them: their zone statement is left out of named.conf. // load them: their zone statement is left out of named.conf.
Disabled bool `json:"disabled,omitempty"` 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 Meta
} }
@@ -195,6 +199,10 @@ type Record struct {
// Disabled records stay in Jarl but are left out of the zone (the // Disabled records stay in Jarl but are left out of the zone (the
// zone file lists them as comments). // zone file lists them as comments).
Disabled bool `json:"disabled,omitempty"` 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 Meta
} }
+1 -1
View File
@@ -158,7 +158,7 @@ func primaryZonesFor(st *model.State, p *auth.Principal) []acme.Zone {
for _, z := range st.SortedZones() { for _, z := range st.SortedZones() {
vn := st.ViewName(z) vn := st.ViewName(z)
if z.Type == model.ZonePrimary && !z.Disabled && p.CanSeeZone(z.Name, vn) { 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 return out
+33 -1
View File
@@ -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) 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 { func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r) st, err := s.state(r)
if err != nil { 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"` TTL model.Opt[uint32] `json:"ttl,omitzero"`
Data []string `json:"data"` Data []string `json:"data"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
Local *bool `json:"local,omitempty"`
} }
if err := decodeJSON(r, &in); err != nil { if err := decodeJSON(r, &in); err != nil {
return err 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")) 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 { return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
z := st.Zones[zid] z := st.Zones[zid]
+10
View File
@@ -22,6 +22,10 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
out.RName = dnsname.EmailFromMailbox(z.SOA.RName) out.RName = dnsname.EmailFromMailbox(z.SOA.RName)
} }
out.Registrar = s.registrarFor(z) out.Registrar = s.registrarFor(z)
for _, o := range st.LinkedWith(z) {
out.LinkedWith = append(out.LinkedWith, st.ViewName(o))
out.Links = append(out.Links, api.ZoneLink{ID: o.ID, View: st.ViewName(o)})
}
return out return out
} }
@@ -30,6 +34,12 @@ func presentRecord(st *model.State, r *model.Record) api.Record {
out := api.Record{Record: *r} out := api.Record{Record: *r}
if z != nil { 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) 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 rr.IsTextType(r.Type) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
if t, ok := rr.TextValue(p.RR); ok { if t, ok := rr.TextValue(p.RR); ok {
+2
View File
@@ -111,6 +111,8 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/zones/{zone}/registrar/ds/{keytag}", s.authed(s.registrarRemoveDS)) 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("PUT "+p+"/zones/{zone}/registrar/ns", s.authed(s.registrarSetNS))
m.HandleFunc("GET "+p+"/zones/{zone}/file", s.authed(s.zoneFile)) 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("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords)) m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord)) m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
+8 -8
View File
@@ -95,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
for rows.Next() { for rows.Next() {
z := &model.Zone{} z := &model.Zone{}
var ca, ua string 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() rows.Close()
return nil, err return nil, err
} }
@@ -113,7 +113,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -121,7 +121,7 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
r := &model.Record{} r := &model.Record{}
var ttl sql.NullInt64 var ttl sql.NullInt64
var ca, ua string 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() rows.Close()
return nil, err return nil, err
} }
@@ -288,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err return err
} }
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error { 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, 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.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy) 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 return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err return err
@@ -299,8 +299,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
if r.TTL != nil { if r.TTL != nil {
ttl = int64(*r.TTL) 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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`, _, 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.Version, ts(r.CreatedAt), ts(r.UpdatedAt), r.CreatedBy, r.UpdatedBy) 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 return err
}, func(id int64) error { return del(ctx, e, "records", id) }); err != nil { }, func(id int64) error { return del(ctx, e, "records", id) }); err != nil {
return err return err
+5
View File
@@ -361,6 +361,11 @@ CREATE TABLE certificates (
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '' 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;
`, `,
} }
+146
View File
@@ -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
}
+1
View File
@@ -151,6 +151,7 @@ func State(s *model.State, opts Options) []Problem {
c.checkExposure() c.checkExposure()
c.checkDNSSEC() c.checkDNSSEC()
c.checkBlocking() c.checkBlocking()
c.checkLinked()
c.applyAcks() c.applyAcks()
sortProblems(c.problems) sortProblems(c.problems)
return c.problems return c.problems
+95 -1
View File
@@ -34,12 +34,15 @@ export async function render(el, params, query) {
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null, zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : 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.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.links ? [h("span", { class: "badge accent" }, "linked with ",
zone.links.map((l, i) => [i ? ", " : "", h("a", { href: "#/zones/" + l.id + (params.tab ? "/" + params.tab : ""), title: "The zone in view " + l.view }, l.view)])), " "] : null,
zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)), zone.zone_file ? h("span", { class: "mono small" }, zone.zone_file) : null)),
h("div", { class: "actions" }, h("div", { class: "actions" },
zone.disabled zone.disabled
? h("button", { onclick: () => toggleZone(zone) }, "Enable zone") ? 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"), : 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"))); h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" }, 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; 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 +71,79 @@ export async function render(el, params, query) {
if (first) first.focus(); 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 picked = new Set();
const err = h("div");
let linkBtn = null;
const rows = others.map((z) => {
const box = h("input", { type: "checkbox", "aria-label": "Link with view " + z.view });
box.addEventListener("change", () => {
if (box.checked) picked.add(z.view); else picked.delete(z.view);
tr.classList.toggle("selected", box.checked);
if (linkBtn) {
linkBtn.disabled = picked.size === 0;
// Zones already linked with a picked one join too: name them.
const all = new Set(picked);
for (const o of others) if (picked.has(o.view)) (o.linked_with || []).forEach((v) => all.add(v));
all.delete(zone.view);
linkBtn.textContent = picked.size ? "Link with " + [...all].join(", ") : "Link";
}
});
const tr = h("tr", { class: "pick", onclick: (e) => { if (e.target !== box) { box.checked = !box.checked; box.dispatchEvent(new Event("change")); } } },
h("td", null, box),
h("td", null, h("strong", null, z.view)),
h("td", { class: "muted" }, z.record_count + " records"),
h("td", { class: "small" }, z.linked_with ? "already linked with " + z.linked_with.join(", ") + " (joins too)" : ""));
return tr;
});
const body = 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."),
h("h3", null, "Link view " + zone.view + " with"),
h("div", { class: "table-wrap" }, h("table", { class: "pick-list" }, h("tbody", null, rows))),
err);
const open = modal("Link " + zone.display_name, body, [
{ label: "Cancel", value: null },
{ label: "Link", kind: "primary", onclick: async () => {
if (!picked.size) return false;
try {
const r = await change("POST", "/zones/" + zone.id + "/link", { with: [...picked] });
toast(describeResult(r));
(r.warnings || []).forEach((m) => toast(m, "warn", 12000));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { wide: true });
// Nothing is ticked at first: Link waits for a choice.
linkBtn = [...document.querySelectorAll(".modal .buttons button.primary")].pop();
if (linkBtn) linkBtn.disabled = true;
await open;
}
// ---- name servers: serials on the other servers, and the delegation // ---- name servers: serials on the other servers, and the delegation
const nsStatus = { const nsStatus = {
@@ -321,6 +397,8 @@ async function recordsTab(el, zone, query) {
const fData = input({ class: "mono", placeholder: typeInfo.A.example }); const fData = input({ class: "mono", placeholder: typeInfo.A.example });
const fComment = input({ placeholder: "optional", style: { width: "160px" } }); 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 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 hint = h("div", { class: "hint" });
const willAdd = h("div", { class: "will-add", "aria-live": "polite" }); const willAdd = h("div", { class: "will-add", "aria-live": "polite" });
const suggest = h("div", { class: "aml-suggest" }); const suggest = h("div", { class: "aml-suggest" });
@@ -426,6 +504,7 @@ async function recordsTab(el, zone, query) {
if (fTTL.value !== "") body.ttl = Number(fTTL.value); if (fTTL.value !== "") body.ttl = Number(fTTL.value);
if (fComment.value.trim()) body.comment = fComment.value.trim(); if (fComment.value.trim()) body.comment = fComment.value.trim();
if (fPTR.input.checked) body.ptr = true; if (fPTR.input.checked) body.ptr = true;
if (linked && fLocal.input.checked) body.local = true;
try { try {
const r = await change("POST", "/zones/" + zone.id + "/records", body); const r = await change("POST", "/zones/" + zone.id + "/records", body);
toast(describeResult(r)); toast(describeResult(r));
@@ -452,6 +531,7 @@ async function recordsTab(el, zone, query) {
h("div", { class: "f grow" }, h("label", null, "Data"), fData), 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, "Comment"), fComment),
h("div", { class: "f" }, h("label", null, " "), fPTR), 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"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add record")));
// Filters and table // Filters and table
@@ -479,8 +559,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: "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: "Type", render: (r) => h("strong", null, r.type) },
{ label: "Data", render: (r) => r.data, tdClass: "data" }, { 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: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, { 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 !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 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"), " ", h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
@@ -488,7 +574,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: "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 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" }, 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.pending ? "pending-" + r.pending : "",
r.disabled ? "disabled" : "", r.disabled ? "disabled" : "",
// A record followed by more of the same name: a faint line, so the // A record followed by more of the same name: a faint line, so the
@@ -523,6 +609,14 @@ async function recordsTab(el, zone, query) {
await redraw(); await redraw();
} catch (e) { toast(e.message, "err", 8000); } } 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 edit = (r) => { editing = r; draw(); };
const mountEdit = () => { const mountEdit = () => {
const r = editing; const r = editing;
+13 -3
View File
@@ -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 { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js"; import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.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 file = input({ placeholder: "/usr/share/dns/root.hints", class: "mono" });
const inView = select(views.map((v) => [v.name, v.name]), ""); const inView = select(views.map((v) => [v.name, v.name]), "");
const cloneSel = select([["", "– don't copy –"]], ""); 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 comment = input();
const updateClones = () => { const updateClones = () => {
// Records can only be copied into a single zone. // Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + "."; const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
mount(cloneSel, h("option", { value: "" }, "– don't copy –"), 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") + "]"))); 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); // 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. // 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("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("SOA contact", contact, "Email address of the zone's administrator."),
field("Default TTL", ttl, "Seconds, used by records without their own TTL."), 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].")], secondary: [field("Primary servers", primaries, "One per line: address [port N] [key NAME].")],
stub: [field("Primary servers", primaries, "One per line.")], stub: [field("Primary servers", primaries, "One per line.")],
mirror: [field("Primary servers", primaries, "One per line (not needed for the root zone).")], 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); data.nameservers = lines(ns.value);
if (contact.value.trim()) data.soa = { rname: contact.value.trim() }; if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
data.default_ttl = Number(ttl.value) || undefined; 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) => { data.records = lines(nsAddr.value).map((l) => {
const [n, a] = l.split(/\s+/); const [n, a] = l.split(/\s+/);
return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a }; return { name: n, type: a && a.includes(":") ? "AAAA" : "A", data: a };
+5
View File
@@ -513,3 +513,8 @@ td textarea.code { min-width: min(72ch, 50vw); }
/* Certificates: the "check the names" result under the ACME fields. */ /* Certificates: the "check the names" result under the ACME fields. */
.check-names { margin: 6px 0 14px; } .check-names { margin: 6px 0 14px; }
.check-names .table-wrap { margin-top: 8px; } .check-names .table-wrap { margin-top: 8px; }
/* A list to pick rows from (linking zones): the whole row toggles. */
table.pick-list tr.pick { cursor: pointer; }
table.pick-list tr.pick td:first-child { width: 2em; }
table.pick-list tr.pick.selected { background: var(--accent-soft); }
+34
View File
@@ -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 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 run "view key shows a dig command" 0 $J view key external
contains "dig -y hmac-sha256:jarl-view-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 # Internet exposure (docs/exposure.md): external lets in anyone, internal
# only homelab. # only homelab.
run "exposure" 0 $J exposure run "exposure" 0 $J exposure