Reverse zones made easy: suggested, filled, in the right view, PTRs kept in step

Missing reverse zones are suggested for the private views' A and AAAA records (/24 for private IPv4, /32 for a public address, /48 for IPv6) and created filled with PTR records (jarl zone suggest-reverse, the Zones page). zone create --fill and zone fill-ptr add the PTRs a reverse zone lacks; a reverse zone created without a view goes to the private view using its addresses. Per zone, auto_ptr keeps the PTR records of A and AAAA records in step on every change (database version 12). Zone commands take a network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 21:23:59 +01:00
1 parent a3b054cc86
commit aa4b004a14
27 files changed
+1173 -38

No files matched your search

+5
View File
@@ -37,6 +37,11 @@ type Zone struct {
// OtherViews are the other views with a primary zone of this name
// (linked or not): what GET /zones/{zone}/compare compares.
OtherViews []string `json:"other_views,omitempty"`
// Prefix is the network a reverse zone answers for; PTRMissing how
// many of its view's A and AAAA addresses in it have no PTR record
// (POST /zones/{zone}/fill-ptr adds them).
Prefix string `json:"prefix,omitempty"`
PTRMissing int `json:"ptr_missing,omitempty"`
// Pending is set when listing with a changeset: add, change or delete.
Pending string `json:"pending,omitempty"`
}
+44
View File
@@ -469,3 +469,47 @@ func BestZone(name string, zones []string) string {
}
return best
}
// PrefixFromReverse returns the network a reverse zone answers for:
// 10.0.10.in-addr.arpa. is 10.0.10.0/24, a /32 zone a single address, and
// an ip6.arpa zone of 12 nibbles a /48. False for names that aren't whole
// octets or nibbles (classless delegations such as 0-63.1.0.10.in-addr.arpa.).
func PrefixFromReverse(zone string) (netip.Prefix, bool) {
zone = strings.ToLower(Fqdn(zone))
switch {
case strings.HasSuffix(zone, ".in-addr.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".in-addr.arpa."), ".")
if len(labels) > 4 {
return netip.Prefix{}, false
}
var b [4]byte
for i, l := range labels {
v, err := strconv.Atoi(l)
if err != nil || v < 0 || v > 255 || strconv.Itoa(v) != l {
return netip.Prefix{}, false
}
b[len(labels)-1-i] = byte(v)
}
return netip.PrefixFrom(netip.AddrFrom4(b), 8*len(labels)), true
case strings.HasSuffix(zone, ".ip6.arpa."):
labels := strings.Split(strings.TrimSuffix(zone, ".ip6.arpa."), ".")
if len(labels) > 32 {
return netip.Prefix{}, false
}
var b [16]byte
for i, l := range labels {
v := strings.IndexByte("0123456789abcdef", l[0])
if len(l) != 1 || v < 0 {
return netip.Prefix{}, false
}
n := len(labels) - 1 - i // nibble position from the left
if n%2 == 0 {
b[n/2] |= byte(v) << 4
} else {
b[n/2] |= byte(v)
}
}
return netip.PrefixFrom(netip.AddrFrom16(b), 4*len(labels)), true
}
return netip.Prefix{}, false
}
+32
View File
@@ -217,3 +217,35 @@ func TestReverseZones(t *testing.T) {
t.Errorf("ReverseZone /22: %v", err)
}
}
func TestPrefixFromReverse(t *testing.T) {
for zone, want := range map[string]string{
"10.0.10.in-addr.arpa.": "10.0.10.0/24",
"10.in-addr.arpa": "10.0.0.0/8",
"154.144.144.139.in-addr.arpa.": "139.144.144.154/32",
"d.3.f.7.3.1.0.8.2.0.a.2.ip6.arpa.": "2a02:8013:7f3d::/48",
"b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.": "fdab:abab:abab::/48",
"0-63.1.0.10.in-addr.arpa.": "",
"example.com.": "",
"256.10.in-addr.arpa.": "",
} {
p, ok := PrefixFromReverse(zone)
got := ""
if ok {
got = p.String()
}
if got != want {
t.Errorf("PrefixFromReverse(%q) = %q, want %q", zone, got, want)
}
}
// Round trip with ReverseZone.
for _, s := range []string{"192.168.1.0/24", "2a02:8013:7f3d::/48", "fd00::/8", "10.0.0.5/32"} {
z, err := ReverseZone(netip.MustParsePrefix(s))
if err != nil {
t.Fatal(err)
}
if p, ok := PrefixFromReverse(z); !ok || p.String() != s {
t.Errorf("%s -> %s -> %v", s, z, p)
}
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ func DiffOps(from, to *model.State) []model.Op {
n := to.Zones[k]
s := n.SOA
in := ZoneInput{Name: &n.Name, ViewID: &n.ViewID, Type: &n.Type, InView: &n.InView, File: &n.File,
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup)}
DefaultTTL: &n.DefaultTTL, SerialPolicy: &n.SerialPolicy, DNSSECPolicy: &n.DNSSECPolicy, Comment: &n.Comment, Disabled: &n.Disabled, LinkGroup: model.Ptr(n.LinkGroup), AutoPTR: model.Ptr(n.AutoPTR)}
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}
}
+1 -1
View File
@@ -285,7 +285,7 @@ func (e *Engine) expandAll(ctx context.Context, work *model.State, reqs []Reques
var warnings []string
c := &applyCtx{p: p, now: e.Now()}
for i, req := range reqs {
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now()}
x := &expander{ctx: ctx, st: work, alloc: e.store.AllocateIDs, p: p, now: e.Now(), exposures: e.Exposures}
more, err := x.expand(req)
if err != nil {
if ee, ok := AsError(err); ok && len(reqs) > 1 {
+136 -19
View File
@@ -28,6 +28,9 @@ type expander struct {
p *auth.Principal
now time.Time
warnings []string
// exposures classifies the views as public or private (nil in tests:
// every view private).
exposures func(*model.State) []validate.Exposure
}
func (x *expander) ids(n int) ([]int64, error) {
@@ -137,7 +140,7 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
return nil, err
}
switch req.Op {
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable:
case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable, OpZoneFillPTR:
// Changes to linked zones reach the other zones of the link.
return x.linkSync(ops)
}
@@ -152,6 +155,8 @@ func (x *expander) expandOne(req Request) ([]model.Op, error) {
return x.zoneUnlink(req)
case OpZoneCreate:
return x.zoneCreate(req)
case OpZoneFillPTR:
return x.zoneFillPTR(req)
case OpZoneUpdate, OpZoneDelete:
z := x.st.Zones[req.ID]
if z == nil {
@@ -376,6 +381,7 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
return nil, errf(KindBadRequest, "%v", err)
}
in.Type = &zt
viewGiven := in.View != nil || in.ViewID != nil
if in.View != nil {
if *in.View != "" {
v := x.st.ViewByName(*in.View)
@@ -386,6 +392,39 @@ func (x *expander) zoneCreate(req Request) ([]model.Op, error) {
}
in.View = nil
}
if !viewGiven && in.LinkWith == "" && in.CloneFrom == 0 && dnsname.IsReverse(name) {
// A reverse zone belongs in the private view whose records use
// its addresses (docs/reverse.md).
v, err := reverseViewFor(x.st, x.publicViews(), name)
if err != nil {
return nil, err
}
if v != nil {
in.ViewID = &v.ID
}
}
fill := in.Fill
in.Fill = false
if fill {
if zt != model.ZonePrimary {
return nil, errf(KindBadRequest, "fill only applies to primary zones")
}
if _, ok := dnsname.PrefixFromReverse(name); !ok {
return nil, errf(KindBadRequest, "fill only applies to reverse zones of whole octets or nibbles (like 10.0.10.0/24)")
}
vid := int64(0)
if in.ViewID != nil {
vid = *in.ViewID
}
fills := PTRFills(x.st, vid, name, nil)
if len(fills) == 0 {
x.warnings = append(x.warnings, fmt.Sprintf("no A or AAAA record of %s has an address in %s, so there was nothing to fill it with", viewOrServer(x.st, vid), dnsname.Display(name)))
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
for _, f := range fills {
in.Records = append(in.Records, RecordInput{Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)})
}
}
nameservers := in.Nameservers
records := in.Records
cloneFrom := in.CloneFrom
@@ -638,7 +677,11 @@ func addrOf(p *rr.Parsed) (netip.Addr, bool) {
}
// ptrOps returns operations creating the PTR record for an address record.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model.Op, error) {
//
// auto is set when the zone's auto_ptr asked rather than the request: a
// missing reverse zone isn't worth a warning then (most addresses have
// none), and an address whose PTR record names another host keeps it.
func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed, auto bool) ([]model.Op, error) {
addr, ok := addrOf(p)
if !ok {
return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records")
@@ -651,13 +694,13 @@ func (x *expander) ptrCreate(z *model.Zone, owner string, p *rr.Parsed) ([]model
continue
}
found = true
more, err := x.ptrCreateIn(rz, ptrName, owner)
more, err := x.ptrCreateIn(rz, ptrName, owner, auto)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
if !found {
if !found && !auto {
x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in this view, so no PTR record was added (create one: jarl zone create %s/%d)", addr, addr, addr.BitLen()))
}
return ops, nil
@@ -678,7 +721,8 @@ func ptrViews(st *model.State, z *model.Zone) []int64 {
// ptrCreateIn returns operations creating the PTR record ptrName -> owner
// in reverse zone rz (none when it is there; enabling it when disabled).
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.Op, error) {
func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) {
var other string
for _, r := range x.st.ZoneRecords(rz.ID) {
if r.Name == ptrName && r.Type == "PTR" {
if strings.EqualFold(r.Data, owner) {
@@ -688,9 +732,18 @@ func (x *expander) ptrCreateIn(rz *model.Zone, ptrName, owner string) ([]model.O
}
return nil, nil // already there
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, r.Data))
if !r.Disabled {
other = r.Data
}
}
}
if other != "" {
if auto {
// One name per address: the one it has stays.
return nil, nil
}
x.warnings = append(x.warnings, fmt.Sprintf("%s already has a PTR record (%s); adding another one", ptrName, other))
}
ids, err := x.ids(1)
if err != nil {
return nil, err
@@ -778,7 +831,7 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
return nil, err
}
x.parseWarnings(owner, p)
ptr := in.PTR
ptr, auto := in.PTR, !in.PTR && autoPTR(z, owner, p)
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type, in.Data = &owner, &p.Type, &p.Data
ids, err := x.ids(1)
@@ -807,8 +860,8 @@ func (x *expander) recordCreate(req Request) ([]model.Op, error) {
// local on a zone that isn't linked: its records are local anyway.
in.Local = nil
ops := []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}
if ptr {
more, err := x.ptrCreate(z, owner, p)
if ptr || auto && (in.Disabled == nil || !*in.Disabled) {
more, err := x.ptrCreate(z, owner, p, auto)
if err != nil {
return nil, err
}
@@ -939,13 +992,43 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if in.Disabled != nil {
disabled = *in.Disabled
}
moved := p.Data != r.Data || owner != r.Name
moved := p.Data != r.Data || owner != r.Name || p.Type != r.Type
if !ptr && z.AutoPTR {
// The zone keeps PTR records in step (docs/reverse.md): as if
// asked, for whichever of the old and new record is an address.
old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data)
oldAuto := err == nil && autoPTR(z, r.Name, old)
newAuto := autoPTR(z, owner, p)
switch {
case moved:
if oldAuto {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if newAuto && !disabled {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
case toggled && newAuto && disabled:
ops = append(ops, x.ptrSetDisabled(z, owner, p, true)...)
case toggled && newAuto:
// Enables the PTR record, or adds it when it is missing.
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
return ops, nil
}
if ptr && moved {
if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, old)...)
}
if !disabled {
more, err := x.ptrCreate(z, owner, p)
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
}
@@ -961,7 +1044,7 @@ func (x *expander) recordUpdate(req Request) ([]model.Op, error) {
if ptr && !moved && !toggled && !disabled {
// Nothing else changes: make sure the PTR record is there (the
// reverse zone may be newer than the record).
more, err := x.ptrCreate(z, owner, p)
more, err := x.ptrCreate(z, owner, p, false)
if err != nil {
return nil, err
}
@@ -988,11 +1071,9 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
base = r.Version
}
ops := []model.Op{newOp(OpRecordDelete, r.ID, base, nil, fmt.Sprintf("delete %s %s %s", r.Name, r.Type, r.Data))}
if in.PTR {
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
z := x.st.Zones[r.ZoneID]
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && (in.PTR || autoPTR(z, r.Name, p)) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
return ops, nil
}
@@ -1032,8 +1113,19 @@ func (x *expander) recordDelete(req Request) ([]model.Op, error) {
in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false
in.Name, in.Type = &owner, &typ
ops := []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}
if ptr && parsed != nil {
switch {
case ptr && parsed != nil:
ops = append(ops, x.ptrDelete(z, owner, parsed)...)
case z.AutoPTR:
// Every matching record's PTR record goes too.
for _, r := range x.st.RRset(z.ID, owner, typ) {
if parsed != nil && r.Data != parsed.Data {
continue
}
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil && autoPTR(z, r.Name, p) {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
return ops, nil
}
@@ -1169,7 +1261,32 @@ func (x *expander) rrsetReplace(req Request) ([]model.Op, error) {
if len(canon) == 0 {
summary = fmt.Sprintf("delete all %s records of %s%s", typ, owner, where)
}
return append(pre, newOp(OpRRSetReplace, 0, 0, in, summary)), nil
ops := append(pre, newOp(OpRRSetReplace, 0, 0, in, summary))
if z.AutoPTR && (typ == "A" || typ == "AAAA") && !strings.HasPrefix(owner, "*.") {
// PTR records follow the addresses that go and come.
old := map[string]bool{}
for _, r := range x.st.RRset(z.ID, owner, typ) {
old[r.Data] = true
if !slices.Contains(canon, r.Data) {
if p, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil {
ops = append(ops, x.ptrDelete(z, r.Name, p)...)
}
}
}
for _, d := range canon {
if old[d] {
continue
}
if _, p, err := parseRecord(z, owner, typ, d); err == nil {
more, err := x.ptrCreate(z, owner, p, true)
if err != nil {
return nil, err
}
ops = append(ops, more...)
}
}
}
return ops, nil
}
func (x *expander) viewCreate(req Request) ([]model.Op, error) {
+9
View File
@@ -59,6 +59,9 @@ const (
// into zone and record updates.
OpZoneLink = "zone.link"
OpZoneUnlink = "zone.unlink"
// Filling a reverse zone with the PTR records of its view's A and
// AAAA records (docs/reverse.md) becomes record.create operations.
OpZoneFillPTR = "zone.fill_ptr"
)
// AckInput acknowledges (or un-acknowledges) an exposure finding of a view
@@ -142,6 +145,12 @@ type ZoneInput struct {
// 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"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step (docs/reverse.md).
AutoPTR *bool `json:"auto_ptr,omitempty"`
// Fill (create only, reverse zones) adds a PTR record for every
// address of the view's A and AAAA records in the zone's range.
Fill bool `json:"fill,omitempty"`
// Create only: name servers for the apex NS records (default: the
// configured default name servers), additional records, and a zone to
+3
View File
@@ -268,6 +268,9 @@ func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool)
if in.LinkGroup != nil {
z.LinkGroup = *in.LinkGroup
}
if in.AutoPTR != nil {
z.AutoPTR = *in.AutoPTR
}
return nil
}
+357
View File
@@ -0,0 +1,357 @@
package engine
import (
"fmt"
"net/netip"
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/rr"
"jarl/internal/validate"
)
// Reverse zones made easy (docs/reverse.md): filling a reverse zone from
// the A and AAAA records of its view, choosing its view, and suggesting
// the reverse zones a view lacks.
// addrUse is an address a forward record gives a name.
type addrUse struct {
addr netip.Addr
name string // the owner name, absolute
apex bool
}
// forwardAddresses returns the addresses of the enabled A and AAAA
// records of a view's primary forward zones (wildcards aside: a PTR record
// can't name them).
func forwardAddresses(st *model.State, viewID int64) []addrUse {
var out []addrUse
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary || z.Disabled || dnsname.IsReverse(z.Name) {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
if r.Disabled || (r.Type != "A" && r.Type != "AAAA") || strings.HasPrefix(r.Name, "*.") {
continue
}
a, err := netip.ParseAddr(r.Data)
if err != nil {
continue
}
out = append(out, addrUse{addr: a.Unmap(), name: r.Name, apex: r.Name == z.Name})
}
}
return out
}
// PTRFill is a PTR record filling a reverse zone would add.
type PTRFill struct {
Address string `json:"address"`
Name string `json:"name"` // the PTR owner name
Target string `json:"target"` // the host name it gives
Others []string `json:"others,omitempty"` // other names with this address
}
// PTRFills returns the PTR records a reverse zone named zoneName in a view
// lacks for the addresses of the view's A and AAAA records: one per
// address, naming its mail or name server host when it is one, otherwise
// a host rather than the domain itself, then the shortest name.
// existing are the zone's records (none for a new zone).
func PTRFills(st *model.State, viewID int64, zoneName string, existing []*model.Record) []PTRFill {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok {
return nil
}
has := map[string]bool{}
for _, r := range existing {
if r.Type == "PTR" {
has[r.Name] = true
}
}
servers := serverNames(st, viewID)
byAddr := map[netip.Addr][]addrUse{}
var order []netip.Addr
for _, u := range forwardAddresses(st, viewID) {
if !prefix.Contains(u.addr) {
continue
}
if _, seen := byAddr[u.addr]; !seen {
order = append(order, u.addr)
}
if !slices.ContainsFunc(byAddr[u.addr], func(o addrUse) bool { return o.name == u.name }) {
byAddr[u.addr] = append(byAddr[u.addr], u)
}
}
slices.SortFunc(order, func(a, b netip.Addr) int { return a.Compare(b) })
var out []PTRFill
for _, a := range order {
owner := dnsname.ReverseName(a)
if has[owner] {
continue
}
uses := byAddr[a]
slices.SortStableFunc(uses, func(x, y addrUse) int {
rank := func(u addrUse) int {
switch {
case servers[u.name]:
return 0
case !u.apex:
return 1
}
return 2
}
if d := rank(x) - rank(y); d != 0 {
return d
}
if d := strings.Count(x.name, ".") - strings.Count(y.name, "."); d != 0 {
return d
}
return strings.Compare(x.name, y.name)
})
f := PTRFill{Address: a.String(), Name: owner, Target: uses[0].name}
for _, u := range uses[1:] {
f.Others = append(f.Others, u.name)
}
out = append(out, f)
}
return out
}
// serverNames are the hosts the view's MX and NS records name.
func serverNames(st *model.State, viewID int64) map[string]bool {
out := map[string]bool{}
for _, z := range st.ZonesInView(viewID) {
if z.Type != model.ZonePrimary {
continue
}
for _, r := range st.ZoneRecords(z.ID) {
switch r.Type {
case "MX":
if f := strings.Fields(r.Data); len(f) == 2 {
out[strings.ToLower(f[1])] = true
}
case "NS":
out[strings.ToLower(r.Data)] = true
}
}
}
return out
}
func fillWarnings(fills []PTRFill) []string {
var out []string
for _, f := range fills {
if len(f.Others) > 0 {
out = append(out, fmt.Sprintf("%s has several names: its PTR record gives %s (not %s); change it if you prefer another", f.Address, dnsname.Display(f.Target), strings.Join(displayAll(f.Others), ", ")))
}
}
return out
}
func displayAll(names []string) []string {
out := make([]string, len(names))
for i, n := range names {
out[i] = dnsname.Display(n)
}
return out
}
// zoneFillPTR expands zone.fill_ptr: the PTR records a reverse zone lacks.
func (x *expander) zoneFillPTR(req Request) ([]model.Op, error) {
var in LinkInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
id := in.ZoneID
if id == 0 {
id = req.ID
}
z := x.st.Zones[id]
if z == nil {
return nil, errf(KindNotFound, "zone %d does not exist", id)
}
if z.Type != model.ZonePrimary {
return nil, errf(KindBadRequest, "zone %s isn't a primary zone", zoneDesc(x.st, z))
}
if _, ok := dnsname.PrefixFromReverse(z.Name); !ok {
return nil, errf(KindBadRequest, "zone %s isn't a reverse zone of whole octets or nibbles (like 10.0.10.in-addr.arpa)", zoneDesc(x.st, z))
}
fills := PTRFills(x.st, z.ViewID, z.Name, x.st.ZoneRecords(z.ID))
if len(fills) == 0 {
return nil, errf(KindBadRequest, "nothing to add: every address of %s's A and AAAA records in %s has a PTR record", viewOrServer(x.st, z.ViewID), zoneDesc(x.st, z))
}
x.warnings = append(x.warnings, fillWarnings(fills)...)
ids, err := x.ids(len(fills))
if err != nil {
return nil, err
}
var ops []model.Op
for i, f := range fills {
ri := RecordInput{ZoneID: z.ID, Name: model.Ptr(f.Name), Type: model.Ptr("PTR"), Data: model.Ptr(f.Target)}
if z.LinkGroup != 0 {
ri.SharedID = model.Ptr(ids[i])
}
ops = append(ops, newOp(OpRecordCreate, ids[i], 0, ri, fmt.Sprintf("add %s IN PTR %s", f.Name, f.Target)))
}
return ops, nil
}
func viewOrServer(st *model.State, viewID int64) string {
if v := st.Views[viewID]; v != nil {
return "view " + v.Name
}
return "the server"
}
// publicViews returns which views the internet reaches.
func (x *expander) publicViews() map[int64]bool {
out := map[int64]bool{}
if x.exposures == nil {
return out
}
for _, e := range x.exposures(x.st) {
out[e.ViewID] = e.Public
}
return out
}
// reverseViewFor chooses the view of a new reverse zone given without one:
// the private view whose records use addresses in its range, or the only
// private view. "" with no error leaves the choice to the usual rules.
func reverseViewFor(st *model.State, public map[int64]bool, zoneName string) (*model.View, error) {
prefix, ok := dnsname.PrefixFromReverse(zoneName)
if !ok || len(st.Views) == 0 {
return nil, nil
}
var private, using []*model.View
for _, v := range st.OrderedViews() {
if public[v.ID] {
continue
}
private = append(private, v)
if slices.ContainsFunc(forwardAddresses(st, v.ID), func(u addrUse) bool { return prefix.Contains(u.addr) }) {
using = append(using, v)
}
}
switch {
case len(using) == 1:
return using[0], nil
case len(using) > 1:
var names []string
for _, v := range using {
names = append(names, v.Name)
}
return nil, errf(KindBadRequest, "views %s all have addresses in %s: give the view (or create the zone in each)", strings.Join(names, " and "), prefix)
case len(private) == 1:
return private[0], nil
}
return nil, nil
}
// ReverseSuggestion is a reverse zone a view lacks for addresses its
// records use.
type ReverseSuggestion struct {
ViewID int64 `json:"view_id"`
View string `json:"view,omitempty"`
Prefix string `json:"prefix"`
Zone string `json:"zone"`
Why string `json:"why"`
Fill int `json:"fill"` // PTR records creating it with fill adds
Samples []string `json:"samples"` // a few of them, "address name"
}
// ReverseSuggestions returns the reverse zones the private views (or the
// server, without views) lack for their A and AAAA records' addresses:
// the /24 of a private IPv4 address, a /32 for a public one (the /24
// belongs to someone else, and a zone for it here would hide the
// neighbours' names from your clients), the /48 of an IPv6 address.
// Public views get none: the internet asks the addresses' owners.
func ReverseSuggestions(st *model.State, exposures []validate.Exposure) []ReverseSuggestion {
public := map[int64]bool{}
for _, e := range exposures {
public[e.ViewID] = e.Public
}
viewIDs := []int64{0}
if len(st.Views) > 0 {
viewIDs = viewIDs[:0]
for _, v := range st.OrderedViews() {
if !public[v.ID] {
viewIDs = append(viewIDs, v.ID)
}
}
} else if public[0] {
return []ReverseSuggestion{}
}
out := []ReverseSuggestion{}
for _, vid := range viewIDs {
seen := map[netip.Prefix]bool{}
for _, u := range forwardAddresses(st, vid) {
a := u.addr
if a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() || a.IsMulticast() {
continue
}
if rz, _ := reverseZoneFor(st, vid, a); rz != nil {
continue
}
var p netip.Prefix
why := ""
switch {
case a.Is4() && (a.IsPrivate() || cgnat.Contains(a)):
p, why = netip.PrefixFrom(a, 24).Masked(), "private network"
case a.Is4():
p, why = netip.PrefixFrom(a, 32), "a public address: its own zone, since the rest of its /24 belongs to others"
default:
p, why = netip.PrefixFrom(a, 48).Masked(), "the /48 of your IPv6 addresses (one zone for all their subnets)"
if a.IsPrivate() {
why = "your ULA /48 (one zone for all its subnets)"
}
}
if seen[p] {
continue
}
seen[p] = true
name, err := dnsname.ReverseZone(p)
if err != nil {
continue
}
s := ReverseSuggestion{ViewID: vid, View: viewName(st, vid), Prefix: p.String(), Zone: strings.TrimSuffix(name, "."), Why: why, Samples: []string{}}
fills := PTRFills(st, vid, name, nil)
s.Fill = len(fills)
for i, f := range fills {
if i == 3 {
break
}
s.Samples = append(s.Samples, f.Address+" "+dnsname.Display(f.Target))
}
out = append(out, s)
}
}
slices.SortStableFunc(out, func(a, b ReverseSuggestion) int {
if a.View != b.View {
return strings.Compare(a.View, b.View)
}
return netip.MustParsePrefix(a.Prefix).Addr().Compare(netip.MustParsePrefix(b.Prefix).Addr())
})
return out
}
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
// autoPTR reports whether a record's PTR is kept in step by its zone's
// auto_ptr setting.
func autoPTR(z *model.Zone, owner string, p *rr.Parsed) bool {
if z == nil || !z.AutoPTR || strings.HasPrefix(owner, "*.") {
return false
}
_, ok := addrOf(p)
return ok
}
func viewName(st *model.State, id int64) string {
if v := st.Views[id]; v != nil {
return v.Name
}
return ""
}
+138
View File
@@ -0,0 +1,138 @@
package engine
import (
"slices"
"strings"
"testing"
"jarl/internal/model"
)
// ptrs returns a reverse zone's PTR records in a view as "owner target",
// with " disabled" when so.
func (env *testEnv) ptrs(view, zone string) []string {
env.t.Helper()
s := env.state()
z := s.FindZone(zone, s.ViewByName(view).ID)
if z == nil {
env.t.Fatalf("no zone %s in %s", zone, view)
}
var out []string
for _, r := range s.ZoneRecords(z.ID) {
if r.Type == "PTR" {
d := r.Name + " " + r.Data
if r.Disabled {
d += " disabled"
}
out = append(out, d)
}
}
return out
}
func TestReverseFillAndSuggest(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin,
recIn("internal", "@", "A", "192.0.2.25"),
recIn("internal", "nas", "A", "10.0.10.20"),
recIn("internal", "files", "A", "10.0.10.20"),
recIn("internal", "nas", "AAAA", "fdab:abab:abab:10::20"),
recIn("internal", "*.dev", "A", "10.0.10.99"),
recIn("internal", "lo", "A", "127.0.0.1"))
sugg := ReverseSuggestions(env.state(), env.e.Exposures(env.state()))
var got []string
for _, s := range sugg {
got = append(got, s.View+" "+s.Prefix+" "+s.Zone)
}
want(t, "suggestions", got,
"internal 10.0.10.0/24 10.0.10.in-addr.arpa",
"internal 192.0.2.10/32 10.2.0.192.in-addr.arpa",
"internal 192.0.2.25/32 25.2.0.192.in-addr.arpa",
"internal fdab:abab:abab::/48 b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa")
if sugg[0].Fill != 1 || sugg[0].Samples[0] != "10.0.10.20 files.example.com" {
t.Errorf("10.0.10.0/24: %+v", sugg[0])
}
// No view given: the private view whose records use the addresses.
res := env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), Fill: true}))
want(t, "10.0.10/24", env.ptrs("internal", "10.0.10.in-addr.arpa."), "20.10.0.10.in-addr.arpa. files.example.com.")
if !slices.ContainsFunc(res.Warnings, func(w string) bool { return strings.Contains(w, "10.0.10.20 has several names") }) {
t.Errorf("warnings %q", res.Warnings)
}
// The mail server's name beats the domain's own.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.25/32"), Fill: true}))
want(t, "192.0.2.25", env.ptrs("internal", "25.2.0.192.in-addr.arpa."), "25.2.0.192.in-addr.arpa. mail.example.com.")
// Filling an existing zone; then there is nothing left to add.
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("fdab:abab:abab::/48")}))
st := env.state()
z6 := st.FindZone("b.a.b.a.b.a.b.a.b.a.d.f.ip6.arpa.", st.ViewByName("internal").ID)
env.submit(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{}))
if p := env.ptrs("internal", z6.Name); len(p) != 1 || !strings.HasSuffix(p[0], " nas.example.com.") {
t.Errorf("ip6 fill: %q", p)
}
if e := env.submitErr(env.admin, NewRequest(OpZoneFillPTR, z6.ID, LinkInput{})); !strings.Contains(e.Message, "nothing to add") {
t.Errorf("second fill: %v", e)
}
got = got[:0]
for _, s := range ReverseSuggestions(env.state(), env.e.Exposures(env.state())) {
got = append(got, s.Prefix)
}
want(t, "suggestions left", got, "192.0.2.10/32")
// Addresses that two private views use: the view must be given.
env.submit(env.admin, NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("lab"), MatchClients: &[]string{"172.16.0.0/12"}}))
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("lab.test"), View: model.Ptr("lab")}),
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "lab.test", View: "lab", Name: model.Ptr("x"), Type: model.Ptr("A"), Data: model.Ptr("192.0.2.10")}))
if e := env.submitErr(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("192.0.2.10/32")})); !strings.Contains(e.Message, "views internal and lab all have addresses") {
t.Errorf("two views: %v", e)
}
}
func TestAutoPTR(t *testing.T) {
env := linkedEnv(t)
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("10.0.10.0/24"), View: model.Ptr("internal")}))
st := env.state()
zid := st.FindZone("example.com.", st.ViewByName("internal").ID).ID
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(true)}))
rz := "10.0.10.in-addr.arpa."
res := env.submit(env.admin, recIn("internal", "host", "A", "10.0.10.30"), recIn("internal", "pub", "A", "203.0.113.5"))
want(t, "added", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("no reverse zone for 203.0.113.5 isn't worth a warning: %q", res.Warnings)
}
// A second name for the address: its PTR stays.
res = env.submit(env.admin, recIn("internal", "alias", "A", "10.0.10.30"))
want(t, "second name", env.ptrs("internal", rz), "30.10.0.10.in-addr.arpa. host.example.com.")
if len(res.Warnings) > 0 {
t.Errorf("warnings %q", res.Warnings)
}
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "alias", "A"), nil))
// Moved, disabled, enabled, deleted.
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Data: model.Ptr("10.0.10.31")}))
want(t, "moved", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(true)}))
want(t, "disabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com. disabled")
env.submit(env.admin, NewRequest(OpRecordUpdate, env.recordID("internal", "host", "A"), RecordInput{Disabled: model.Ptr(false)}))
want(t, "enabled", env.ptrs("internal", rz), "31.10.0.10.in-addr.arpa. host.example.com.")
env.submit(env.admin, NewRequest(OpRecordDelete, env.recordID("internal", "host", "A"), nil))
want(t, "deleted", env.ptrs("internal", rz))
// A whole set replaced.
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.40", "10.0.10.41"}}))
want(t, "set", env.ptrs("internal", rz), "40.10.0.10.in-addr.arpa. multi.example.com.", "41.10.0.10.in-addr.arpa. multi.example.com.")
env.submit(env.admin, NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "internal", Name: "multi", Type: "A", Data: []string{"10.0.10.41"}}))
want(t, "set shrunk", env.ptrs("internal", rz), "41.10.0.10.in-addr.arpa. multi.example.com.")
// Deleting by name and type.
env.submit(env.admin, NewRequest(OpRecordDelete, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("multi"), Type: model.Ptr("A")}))
want(t, "deleted by match", env.ptrs("internal", rz))
// Off again: records come and go alone.
env.submit(env.admin, NewRequest(OpZoneUpdate, zid, ZoneInput{AutoPTR: model.Ptr(false)}))
env.submit(env.admin, recIn("internal", "quiet", "A", "10.0.10.50"))
want(t, "off", env.ptrs("internal", rz))
}
+4
View File
@@ -170,6 +170,10 @@ type Zone struct {
// 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"`
// AutoPTR keeps the PTR records of the zone's A and AAAA records in
// step with them, in the reverse zones of the zone's views, as if every
// change asked for it (docs/reverse.md).
AutoPTR bool `json:"auto_ptr,omitempty"`
Meta
}
+39
View File
@@ -0,0 +1,39 @@
package server
import (
"net/http"
"jarl/internal/auth"
"jarl/internal/engine"
)
// zoneFillPTR: POST /zones/{zone}/fill-ptr adds the PTR records a reverse
// zone lacks for its view's A and AAAA records (docs/reverse.md).
func (s *Server) zoneFillPTR(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
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpZoneFillPTR, z.ID, engine.LinkInput{ZoneID: z.ID})}, nil)
}
// reverseSuggestions: GET /reverse-zones/suggestions lists the reverse
// zones the private views lack for their records' addresses.
func (s *Server) reverseSuggestions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
st, err := s.state(r)
if err != nil {
return err
}
out := []engine.ReverseSuggestion{}
for _, sg := range engine.ReverseSuggestions(st, s.eng.Exposures(st)) {
if p.CanSeeZone(sg.Zone+".", sg.View) {
out = append(out, sg)
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
+6
View File
@@ -29,6 +29,12 @@ func (s *Server) presentZone(st *model.State, z *model.Zone, counts map[int64]re
}
}
}
if pf, ok := dnsname.PrefixFromReverse(z.Name); ok {
out.Prefix = pf.String()
if z.Type == model.ZonePrimary {
out.PTRMissing = len(engine.PTRFills(st, z.ViewID, z.Name, st.ZoneRecords(z.ID)))
}
}
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)})
+2
View File
@@ -114,6 +114,8 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/zones/{zone}/compare", s.authed(s.zoneCompare))
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}/fill-ptr", s.authed(s.zoneFillPTR))
m.HandleFunc("GET "+p+"/reverse-zones/suggestions", s.authed(s.reverseSuggestions))
m.HandleFunc("POST "+p+"/zones/{zone}/import", s.authed(s.importZoneFile))
m.HandleFunc("GET "+p+"/zones/{zone}/records", s.authed(s.listRecords))
m.HandleFunc("POST "+p+"/zones/{zone}/records", s.authed(s.createRecord))
+4 -4
View File
@@ -95,14 +95,14 @@ func loadState(ctx context.Context, e execer) (*model.State, error) {
return nil, err
}
rows, err = e.QueryContext(ctx, `SELECT id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, 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, auto_ptr, version, created_at, updated_at, created_by, updated_by FROM zones`)
if err != nil {
return nil, err
}
for rows.Next() {
z := &model.Zone{}
var ca, ua string
if err := rows.Scan(&z.ID, &z.Name, &z.ViewID, &z.Type, &z.InView, &z.File, &z.SOA.MName, &z.SOA.RName, &z.SOA.Serial, &z.SOA.Refresh, &z.SOA.Retry, &z.SOA.Expire, &z.SOA.Minimum, &z.DefaultTTL, &z.SerialPolicy, &z.DNSSECPolicy, &z.Comment, &z.Disabled, &z.LinkGroup, &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.AutoPTR, &z.Version, &ca, &ua, &z.CreatedBy, &z.UpdatedBy); err != nil {
rows.Close()
return nil, err
}
@@ -288,8 +288,8 @@ func saveState(ctx context.Context, e execer, old, new *model.State) error {
return err
}
if err := diffMap(old.Zones, new.Zones, func(z *model.Zone) error {
_, err := e.ExecContext(ctx, `INSERT OR REPLACE INTO zones (id, name, view_id, type, in_view, file, soa_mname, soa_rname, soa_serial, soa_refresh, soa_retry, soa_expire, soa_minimum, default_ttl, serial_policy, dnssec_policy, comment, disabled, link_group, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
_, 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, auto_ptr, version, created_at, updated_at, created_by, updated_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
z.ID, z.Name, z.ViewID, z.Type, z.InView, z.File, z.SOA.MName, z.SOA.RName, z.SOA.Serial, z.SOA.Refresh, z.SOA.Retry, z.SOA.Expire, z.SOA.Minimum, z.DefaultTTL, z.SerialPolicy, z.DNSSECPolicy, z.Comment, z.Disabled, z.LinkGroup, z.AutoPTR, z.Version, ts(z.CreatedAt), ts(z.UpdatedAt), z.CreatedBy, z.UpdatedBy)
return err
}, func(id int64) error { return del(ctx, e, "zones", id) }); err != nil {
return err
+4
View File
@@ -366,6 +366,10 @@ CREATE TABLE certificates (
`
ALTER TABLE zones ADD COLUMN link_group INTEGER NOT NULL DEFAULT 0;
ALTER TABLE records ADD COLUMN shared_id INTEGER NOT NULL DEFAULT 0;
`,
// 12: PTR records kept in step per zone (docs/reverse.md).
`
ALTER TABLE zones ADD COLUMN auto_ptr INTEGER NOT NULL DEFAULT 0;
`,
}
+27 -6
View File
@@ -1,5 +1,5 @@
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList } from "../ui.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery } from "../api.js";
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
import { rerender } from "../app.js";
import { optionsEditor } from "./options.js";
import { emailOf, pendingBadge } from "./zones.js";
@@ -34,6 +34,8 @@ export async function render(el, params, query) {
zone.pending ? [pendingBadge(zone.pending), " "] : null,
zone.view ? ["view ", viewLink(view, zone.view, "#/zones/" + id + (params.tab ? "/" + params.tab : "")), " · "] : null,
zone.type === "primary" ? "serial " + zone.soa.serial + " · " : null,
zone.prefix ? h("span", { class: "mono" }, zone.prefix + " · ") : null,
zone.auto_ptr ? [h("a", { href: "#/zones/" + id + "/settings", title: "Adding, changing, disabling or deleting an A or AAAA record does the same to its PTR record" }, badge("PTRs kept in step", "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,
@@ -46,7 +48,10 @@ export async function render(el, params, query) {
zone.type === "primary" && zone.view ? [linkButton(zone), " "] : null,
h("button", { class: "danger", onclick: () => deleteZone(zone) }, "Delete zone")));
const banner = zone.disabled ? h("div", { class: "msg warn" },
h("strong", null, "This zone is disabled: "), "named doesn't load it. It is kept here, with its records, and can still be edited; enabling it serves it as it is then.") : null;
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.")
: zone.ptr_missing && can("zones", true) ? h("div", { class: "msg info" },
plural(zone.ptr_missing, "address", "addresses") + " in " + zone.prefix + " that " + (zone.view ? "view " + zone.view + "'s" : "your") + " A and AAAA records use " + (zone.ptr_missing === 1 ? "has" : "have") + " no PTR record here. ",
h("button", { class: "small", onclick: () => fillPTR(zone) }, "Add " + (zone.ptr_missing === 1 ? "it" : "them"))) : null;
const tabBar = h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/zones/" + id + "/" + k, class: k === tab ? "active" : "" }, label)));
// The header and tabs show at once. Below them, switching tabs keeps the
// previous tab's content (faded) until the new one is loaded, rather
@@ -442,7 +447,7 @@ async function recordsTab(el, zone, query) {
const t = typeInfo[fType.value] || {};
fData.placeholder = reverse && fType.value === "PTR" ? "nas.adbraun.lol" : t.example || t.format || "";
mount(hint, h("strong", null, fType.value + ": "), t.description || "", t.format ? [" · format: ", h("code", null, t.format)] : null);
fPTR.classList.toggle("hidden", !(fType.value === "A" || fType.value === "AAAA"));
fPTR.classList.toggle("hidden", zone.auto_ptr || !(fType.value === "A" || fType.value === "AAAA"));
};
fType.addEventListener("change", () => { showHint(); preview(); });
showHint();
@@ -622,7 +627,9 @@ async function recordsTab(el, zone, query) {
const del = async (r) => {
const isAddr = r.type === "A" || r.type === "AAAA";
let ptr = false;
if (isAddr) {
if (isAddr && zone.auto_ptr) {
if (!(await confirmDialog("Delete record", display(r.name) + " " + r.type + " " + r.data + ", and its PTR record (this zone keeps them in step)", "Delete", "danger"))) return;
} else if (isAddr) {
const c = checkbox("Also delete the matching PTR record", true);
const ok = await modal("Delete record", h("div", null, h("p", { class: "mono" }, display(r.name) + " " + r.type + " " + r.data), c), [{ label: "Cancel", value: false }, { label: "Delete", kind: "danger", value: true }]);
if (ok !== true) return;
@@ -693,7 +700,7 @@ async function recordsTab(el, zone, query) {
h("div", { class: "f" }, h("label", null, "TTL"), eTTL),
h("div", { class: "f grow" }, h("label", null, "Data"), eData),
h("div", { class: "f" }, h("label", null, "Comment"), eComment),
(r.type === "A" || r.type === "AAAA") ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
(r.type === "A" || r.type === "AAAA") && !zone.auto_ptr ? h("div", { class: "f" }, h("label", null, " "), ePTR) : null,
h("div", { class: "f" }, h("label", null, " "), h("span", null, h("button", { class: "primary", onclick: save }, "Save"), " ", h("button", { onclick: () => { editing = null; draw(); } }, "Cancel")))),
h("div", { class: "hint" }, "Created ", time(r.created_at), r.created_by ? " by " + r.created_by : "", "; changed ", time(r.updated_at), r.updated_by ? " by " + r.updated_by : "", "."),
eErr);
@@ -733,6 +740,7 @@ async function settingsTab(el, zone) {
const view = select([["", "(no view)"], ...views.map((v) => [String(v.id), v.name])], String(zone.view_id || ""));
const file = input({ value: zone.file || "", class: "mono" });
const comment = textarea(zone.comment, { rows: 2 });
const autoPTR = checkbox("Keep PTR records in step with the A and AAAA records", !!zone.auto_ptr);
if (zone.type === "primary") {
rows.push(
field("Primary name server", mname, "SOA MNAME."),
@@ -743,6 +751,8 @@ async function settingsTab(el, zone) {
field("Negative TTL", minimum, "SOA minimum: how long resolvers cache 'does not exist' answers (at most 3 hours in BIND)."),
field("Default TTL", ttl, "TTL for records that don't set their own."),
field("Serial policy", policy, "How the serial (now " + s.serial + ") is bumped when the zone changes."));
if (!zone.prefix && !/\.(in-addr|ip6)\.arpa\.$/.test(zone.name)) rows.push(field("Reverse DNS", autoPTR,
"Adding, changing, disabling or deleting an address record does the same to its PTR record, in the reverse zones of " + (zone.view ? "view " + zone.view + (zone.linked_with ? " and the linked views" : "") : "the server") + ". An address keeps the name it has; one without a reverse zone is left alone (Zones lists the reverse zones you're missing)."));
}
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) rows.push(field("File", file, "Leave empty for a file managed by Jarl's cache directory (secondary zones)."));
if (views.length) rows.push(field("View", view, "Moving a zone to another view changes which clients see it."));
@@ -754,6 +764,7 @@ async function settingsTab(el, zone) {
body.soa = { mname: mname.value.trim(), rname: rname.value.trim(), refresh: +refresh.value, retry: +retry.value, expire: +expire.value, minimum: +minimum.value };
body.default_ttl = +ttl.value;
body.serial_policy = policy.value;
if (!!zone.auto_ptr !== autoPTR.input.checked) body.auto_ptr = autoPTR.input.checked;
}
if (["secondary", "mirror", "stub", "hint", "redirect"].includes(zone.type)) body.file = file.value.trim();
if (views.length) body.view_id = view.value ? Number(view.value) : 0;
@@ -824,3 +835,13 @@ async function toggleZone(zone) {
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
// fillPTR adds the PTR records a reverse zone lacks (docs/reverse.md).
async function fillPTR(zone) {
try {
const r = await change("POST", "/zones/" + zone.id + "/fill-ptr");
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
+40 -4
View File
@@ -1,10 +1,11 @@
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox } from "../ui.js";
import { h, mount, table, modal, field, input, select, textarea, lines, toast, errorBox, display, badge, viewLink, lastList, checkbox, plural } from "../ui.js";
import { get, change, changeMany, describeResult, can, draftQuery } from "../api.js";
import { rerender } from "../app.js";
import { checkDomain, reverseZoneFor } from "../names.js";
export async function render(el, params, query) {
const [zones, views, settings] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings")]);
const [zones, views, settings, suggestions] = await Promise.all([get("/zones", draftQuery()), get("/views"), get("/settings"),
get("/reverse-zones/suggestions", draftQuery()).catch(() => [])]);
const filter = input({ type: "search", placeholder: "Filter zones…", value: query.get("q") || "" });
const viewSel = select([["", "All views"], ...views.map((v) => [v.name, v.name])], query.get("view") || "");
const typeSel = select([["", "All types"], "primary", "secondary", "forward", "stub", "static-stub", "mirror", "hint", "redirect", "in-view"], query.get("type") || "");
@@ -23,7 +24,7 @@ export async function render(el, params, query) {
lastList.zones = hash;
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
mount(list, table([
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.prefix ? h("span", { class: "muted small" }, " " + z.prefix) : null, z.ptr_missing ? [" ", badge(z.ptr_missing + " PTR missing", "warn")] : null, dnssecLock(z), z.disabled ? [" ", badge("disabled", "off")] : null, z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
@@ -39,7 +40,8 @@ export async function render(el, params, query) {
h("div", { class: "grow" }, h("h1", null, "Zones"), h("div", { class: "muted" }, zones.length + " zones")),
h("div", { class: "actions" }, filter, views.length ? viewSel : null, typeSel,
can("zones", true) ? h("button", { class: "primary", onclick: () => newZoneDialog(views, zones, settings) }, "New zone") : null)),
h("div", { class: "panel" }, list));
h("div", { class: "panel" }, list),
suggestions.length && can("zones", true) ? suggestPanel(suggestions, views) : null);
draw();
if (query.get("new")) {
// Drop ?new=1 so re-rendering after the zone is created doesn't open
@@ -77,6 +79,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
const syncLink = () => { linkBox.style.display = cloneSel.value ? "" : "none"; };
cloneSel.addEventListener("change", syncLink);
const comment = input();
const fill = checkbox("Add a PTR record for each address the view's A and AAAA records use in this network", true);
const fillRow = field("PTR records", fill, "Names already in your zones, so the reverse zone answers from the start. Leave it off to add them yourself.");
const updateClones = () => {
// Records can only be copied into a single zone.
const n = (kind === "reverse" ? reverseZoneFor(name.value).name || "-" : name.value.trim().toLowerCase().replace(/\.$/, "")) + ".";
@@ -144,6 +148,8 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
nameLabel.textContent = k === "reverse" ? "Network" : "Domain";
name.placeholder = k === "reverse" ? "10.10.0.0/24 or 2001:db8:1::/48" : "adbraun.lol";
for (const b of kindButtons.children) b.classList.toggle("active", b.dataset.kind === k);
syncFill();
if (views.length) view.options[0].textContent = k === "reverse" ? "– the private view that uses these addresses –" : "– choose a view –";
check();
name.focus();
};
@@ -167,7 +173,9 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
"in-view": [field("Share from view", inView, "The zone must exist in an earlier view.")],
};
const typeRows = h("div", { class: "form-grid" });
const syncFill = () => { for (const n of fillRow) if (n) n.style.display = kind === "reverse" && type.value === "primary" ? "" : "none"; };
const drawType = () => {
syncFill();
mount(typeRows, rows[type.value] || []);
if (type.value === "hint" && !name.value.trim()) name.value = ".";
check();
@@ -180,6 +188,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
nameLabel, h("div", null, name, preview),
views.length ? field("View", view) : null,
field("Type", type),
fillRow,
field("Comment", comment)),
h("div", { style: { marginTop: "8px" } }, typeRows),
err);
@@ -197,6 +206,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
if (view.value) data.view = view.value;
const opts = [];
const list = (ta) => lines(ta.value).map((l) => l.replace(/;$/, "") + ";").join(" ");
if (t === "primary" && kind === "reverse" && fill.input.checked) data.fill = true;
if (t === "primary") {
data.nameservers = lines(ns.value);
if (contact.value.trim()) data.soa = { rname: contact.value.trim() };
@@ -229,6 +239,7 @@ export async function newZoneDialog(views, zones, settings, preset = {}) {
? await changeMany(zoneName.map((n) => ({ op: "zone.create", data: { ...data, name: n } })), "reverse zones for " + name.value.trim())
: await change("POST", "/zones", data);
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
return true;
} catch (e) {
@@ -268,3 +279,28 @@ export function dnssecLock(z) {
export function pendingBadge(kind) {
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
}
// suggestPanel lists the reverse zones the private views lack for their
// records' addresses, to create filled with PTR records (docs/reverse.md).
function suggestPanel(sugg, views) {
const create = async (list) => {
const reqs = list.map((s) => ({ op: "zone.create", data: { name: s.prefix, view_id: s.view_id || undefined, fill: true } }));
try {
const r = reqs.length === 1 ? await change("POST", "/zones", reqs[0].data) : await changeMany(reqs, plural(reqs.length, "reverse zone"));
toast(describeResult(r));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
return h("div", { class: "panel" },
h("div", { class: "page-head" }, h("h2", { class: "grow" }, "Missing reverse zones"),
sugg.length > 1 ? h("div", { class: "actions" }, h("button", { onclick: () => create(sugg) }, "Create all " + sugg.length)) : null),
h("p", { class: "muted small" }, "Your records use these addresses, but no reverse zone answers \"which host has this address?\" for them" + (views.length ? " in their view" : "") + ". Jarl creates each with a PTR record for every address there. Only your own clients ask this server: the internet asks whoever owns the addresses."),
table([
views.length ? { label: "View", render: (s) => s.view } : null,
{ label: "Network", render: (s) => [h("span", { class: "mono" }, s.prefix), h("div", { class: "muted small" }, s.why)] },
{ label: "Zone", render: (s) => h("span", { class: "mono small" }, s.zone) },
{ label: "PTR records", render: (s) => [String(s.fill), s.samples.length ? h("div", { class: "muted small mono" }, s.samples.join(", ") + (s.fill > s.samples.length ? ", …" : "")) : null] },
{ label: "", render: (s) => h("button", { class: "small", onclick: () => create([s]) }, "Create"), tdClass: "actions-cell" },
].filter(Boolean), sugg));
}