package engine import ( "context" "encoding/json" "fmt" "net/netip" "reflect" "slices" "strings" "time" "github.com/miekg/dns" "jarl/internal/auth" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/rr" "jarl/internal/validate" ) // IDAllocator reserves object IDs. type IDAllocator func(ctx context.Context, n int) ([]int64, error) type expander struct { ctx context.Context st *model.State // working state: base plus the changeset's ops so far alloc IDAllocator 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 // ptrForced are the address sets whose PTR records the request being // expanded asked to update (ptrForce). ptrForced map[rrKey]bool // sharing are the local sets the request deletes so that the shared // version reaches them (dropOverrides). sharing map[rrKey]bool } func (x *expander) ids(n int) ([]int64, error) { ids, err := x.alloc(x.ctx, n) if err != nil { return nil, fmt.Errorf("allocating IDs: %w", err) } return ids, nil } func mustJSON(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { panic(err) } return b } func newOp(kind string, id int64, base int, data any, summary string) model.Op { op := model.Op{Kind: kind, ID: id, BaseVersion: base, Summary: summary} if data != nil { op.Data = mustJSON(data) } return op } // resolveZone finds the zone a record request refers to. func (x *expander) resolveZone(zoneID int64, zone, view string) (*model.Zone, error) { if zoneID != 0 { z := x.st.Zones[zoneID] if z == nil { return nil, errf(KindNotFound, "zone %d does not exist", zoneID) } return z, nil } if zone == "" { return nil, errf(KindBadRequest, "the zone is missing (zone_id or zone)") } return FindZoneRef(x.st, zone, view, func(z *model.Zone) bool { return x.p.CanSeeZone(z.Name, x.st.ViewName(z)) }) } // FindZoneRef finds a zone by name and optional view name. Without a // view, a name in several views needs one, counting only the zones sees // allows (nil: all): a token for one view finds that view's zone. func FindZoneRef(st *model.State, zone, view string, sees func(*model.Zone) bool) (*model.Zone, error) { name, err := ParseZoneName(zone) if err != nil { return nil, errf(KindBadRequest, "%v", err) } if view != "" { v := st.ViewByName(view) if v == nil { return nil, errf(KindNotFound, "view %q does not exist", view) } z := st.FindZone(name, v.ID) if z == nil { return nil, errf(KindNotFound, "zone %s does not exist in view %s", dnsname.Display(name), v.Name) } return z, nil } zs := st.ZonesNamed(name) if sees != nil && len(zs) > 1 { var vis []*model.Zone for _, z := range zs { if sees(z) { vis = append(vis, z) } } zs = vis // none: not found, without naming the views } st.SortByView(zs) switch len(zs) { case 0: return nil, errf(KindNotFound, "zone %s does not exist", dnsname.Display(name)) case 1: return zs[0], nil } var views []string for _, z := range zs { views = append(views, st.ViewName(z)) } return nil, errf(KindBadRequest, "zone %s exists in several views (%s); say which view", dnsname.Display(name), strings.Join(views, ", ")) } func zoneDesc(st *model.State, z *model.Zone) string { s := dnsname.Display(z.Name) if v := st.ViewName(z); v != "" { s += " [" + v + "]" } return s } func ttlText(o model.Opt[uint32]) string { if o.Set && !o.Null { return fmt.Sprintf(" %d", o.Value) } return "" } // InitialSerial returns the first serial for a new zone. func InitialSerial(policy string, now time.Time) uint32 { switch policy { case model.SerialIncrement: return 1 case model.SerialUnixtime: return uint32(now.Unix()) } return dateSerial(now) } func dateSerial(now time.Time) uint32 { y, m, d := now.UTC().Date() return uint32(y*1000000 + int(m)*10000 + d*100) } func (x *expander) expand(req Request) ([]model.Op, error) { x.ptrForced, x.sharing = nil, nil ops, err := x.expandOne(req) if err != nil { return nil, err } switch req.Op { case OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace, OpRecordDisable, OpRecordEnable: // Changes to linked zones reach the other zones of the link, then // PTR records follow the addresses in each view. if ops, err = x.linkSync(ops); err != nil { return nil, err } return x.ptrSync(ops) case OpZoneFillPTR: return x.linkSync(ops) } return ops, nil } func (x *expander) expandOne(req Request) ([]model.Op, error) { switch req.Op { case OpZoneLink: return x.zoneLink(req) case OpZoneUnlink: return x.zoneUnlink(req) case OpZoneCreate: return x.zoneCreate(req) case OpZoneFillPTR: return x.zoneFillPTR(req) case OpReverseDismiss, OpReverseUndismiss: return x.reverseDismiss(req, req.Op == OpReverseDismiss) case OpZoneUpdate, OpZoneDelete: z := x.st.Zones[req.ID] if z == nil { return nil, errf(KindNotFound, "zone %d does not exist", req.ID) } verb := "update" if req.Op == OpZoneDelete { verb = "delete" if n := len(x.st.ZoneRecords(z.ID)); n > 0 { verb += fmt.Sprintf(" (with its %d records)", n) } } else if d := zoneToggle(req, z); d != nil { if *d { x.warnings = append(x.warnings, disabledZoneEffect(x.st, z)) return []model.Op{x.versioned(req, z.Version, "disable zone "+zoneDesc(x.st, z))}, nil } return []model.Op{x.versioned(req, z.Version, "enable zone "+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)") } // An update that leaves the zone as it is stages nothing (a // settings form saved without changes). if zoneUnchanged(x.st, z, &in) { return nil, nil } } return ops, nil case OpRecordCreate: return x.recordCreate(req) case OpRecordUpdate: return x.recordUpdate(req) case OpRecordDelete: return x.recordDelete(req) case OpRRSetReplace: return x.rrsetReplace(req) case OpRecordDisable, OpRecordEnable: return x.recordSetDisabled(req, req.Op == OpRecordDisable) case OpViewCreate: return x.viewCreate(req) case OpViewUpdate, OpViewDelete: v := x.st.Views[req.ID] if v == nil { return nil, errf(KindNotFound, "view %d does not exist", req.ID) } return []model.Op{x.versioned(req, v.Version, strings.TrimPrefix(req.Op, "view.")+" view "+v.Name)}, nil case OpViewReorder: var in ReorderInput if err := decode(req.Data, &in); err != nil { return nil, err } var names []string for _, id := range in.IDs { if v := x.st.Views[id]; v != nil { names = append(names, v.Name) } } return []model.Op{newOp(OpViewReorder, 0, 0, in, "reorder views: "+strings.Join(names, ", "))}, nil case OpACLCreate: return x.simpleCreate(req, OpACLCreate, func(data json.RawMessage) (string, error) { var in ACLInput if err := decode(data, &in); err != nil { return "", err } if in.Name == nil { return "", errf(KindBadRequest, "an ACL needs a name") } return "create ACL " + *in.Name, nil }) case OpACLUpdate, OpACLDelete: a := x.st.ACLs[req.ID] if a == nil { return nil, errf(KindNotFound, "ACL %d does not exist", req.ID) } return []model.Op{x.versioned(req, a.Version, strings.TrimPrefix(req.Op, "acl.")+" ACL "+a.Name)}, nil case OpTSIGCreate: return x.tsigCreate(req) case OpTSIGUpdate, OpTSIGDelete: k := x.st.Keys[req.ID] if k == nil { return nil, errf(KindNotFound, "TSIG key %d does not exist", req.ID) } return []model.Op{x.versioned(req, k.Version, strings.TrimPrefix(req.Op, "tsig.")+" TSIG key "+k.Name)}, nil case OpOptionCreate: return x.optionCreate(req) case OpOptionUpdate, OpOptionDelete: o := x.st.Options[req.ID] if o == nil { return nil, errf(KindNotFound, "option %d does not exist", req.ID) } summary := strings.TrimPrefix(req.Op, "option.") + " " + optionLabel(x.st, o) if req.Op == OpOptionUpdate { var in OptionInput if err := decode(req.Data, &in); err != nil { return nil, err } if in.Value != nil { summary += ": " + o.Value + " → " + *in.Value } } return []model.Op{x.versioned(req, o.Version, summary)}, nil case OpSettingsUpdate: return []model.Op{newOpRaw(OpSettingsUpdate, 0, 0, req.Data, "update settings")}, nil case OpExposureAck, OpExposureUnack: return x.exposureAck(req, req.Op == OpExposureAck) case OpBlocklistCreate: return x.blocklistCreate(req) case OpBlocklistUpdate, OpBlocklistDelete: b := x.st.Blocklists[req.ID] if b == nil { return nil, errf(KindNotFound, "blocklist %d does not exist", req.ID) } verb := "change" if req.Op == OpBlocklistDelete { verb = "delete" } return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil case OpBlockingPause: var in BlockingPauseInput if err := decode(req.Data, &in); err != nil { return nil, err } summary := "resume blocking" if in.Until != nil { summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04") } else if in.Indefinitely { summary = "pause blocking until resumed" } return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil case OpBlockRuleCreate: return x.blockRuleCreate(req) case OpBlockRuleUpdate, OpBlockRuleDelete: r := x.st.BlockRules[req.ID] if r == nil { return nil, errf(KindNotFound, "rule %d does not exist", req.ID) } verb := "change rule: " if req.Op == OpBlockRuleDelete { verb = "delete rule: " } return []model.Op{x.versioned(req, r.Version, verb+ruleLabel(r))}, nil case OpBlockStepCreate: return x.blockStepCreate(req) case OpBlockStepUpdate, OpBlockStepDelete: s := x.st.BlockSteps[req.ID] if s == nil { return nil, errf(KindNotFound, "blocking step %d does not exist", req.ID) } verb := "change blocking step " if req.Op == OpBlockStepDelete { verb = "delete blocking step " } return []model.Op{x.versioned(req, s.Version, verb+s.Name)}, nil case OpClientGroupCreate: return x.clientGroupCreate(req) case OpClientGroupUpdate, OpClientGroupDelete: g := x.st.ClientGroups[req.ID] if g == nil { return nil, errf(KindNotFound, "client group %d does not exist", req.ID) } verb := "change client group " if req.Op == OpClientGroupDelete { verb = "delete client group " } return []model.Op{x.versioned(req, g.Version, verb+g.Name)}, nil } return nil, errf(KindBadRequest, "unknown operation %q", req.Op) } func newOpRaw(kind string, id int64, base int, data json.RawMessage, summary string) model.Op { return model.Op{Kind: kind, ID: id, BaseVersion: base, Data: data, Summary: summary} } // versioned builds an update/delete operation, recording the object's // version for conflict detection. func (x *expander) versioned(req Request, current int, summary string) model.Op { base := req.BaseVersion if base == 0 { base = current } return newOpRaw(req.Op, req.ID, base, req.Data, summary) } func (x *expander) simpleCreate(req Request, kind string, describe func(json.RawMessage) (string, error)) ([]model.Op, error) { summary, err := describe(req.Data) if err != nil { return nil, err } ids, err := x.ids(1) if err != nil { return nil, err } return []model.Op{newOpRaw(kind, ids[0], 0, req.Data, summary)}, nil } func (x *expander) zoneCreate(req Request) ([]model.Op, error) { var in ZoneInput if err := decode(req.Data, &in); err != nil { return nil, err } if in.Name == nil || strings.TrimSpace(*in.Name) == "" { return nil, errf(KindBadRequest, "a zone needs a name") } name, err := ParseZoneName(*in.Name) if err != nil { return nil, errf(KindBadRequest, "%v", err) } in.Name = &name if in.Type == nil { in.Type = model.Ptr(model.ZonePrimary) } zt, err := normalizeZoneType(*in.Type) if err != nil { 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) if v == nil { return nil, errf(KindNotFound, "view %q does not exist", *in.View) } in.ViewID = &v.ID } 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 } } var moveOps []model.Op var moved []*model.Record if zt == model.ZonePrimary { // The records a parent zone in the view has under the new zone // move into it: named answers those names from the new zone, so // left behind they would never be served again. vid := int64(0) if in.ViewID != nil { vid = *in.ViewID } if parent := x.parentZone(name, vid); parent != nil { moved = x.recordsUnder(parent, name) switch { case len(moved) == 0: case parent.LinkGroup != 0: x.warnings = append(x.warnings, fmt.Sprintf("zone %s is linked, so its %d records under %s stay there (hidden by the new zone): move them by hand", zoneDesc(x.st, parent), len(moved), dnsname.Display(name))) moved = nil default: for _, r := range moved { ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data)} if r.TTL != nil { ri.TTL = model.Some(*r.TTL) } if r.Comment != "" { ri.Comment = model.Ptr(r.Comment) } if r.Disabled { ri.Disabled = model.Ptr(true) } in.Records = append(in.Records, ri) moveOps = append(moveOps, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("move %s %s %s from zone %s into the new zone", r.Name, r.Type, r.Data, zoneDesc(x.st, parent)))) } } } } fill, fillOther := in.Fill, in.FillOtherViews in.Fill, in.FillOtherViews = false, 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, moved, fillOther) 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))) } if !fillOther { if h := otherViewsHint(x.st, vid, name, moved, len(fills), "to add PTR records for them in this view too: jarl zone fill-ptr ZONE --other-views"); h != "" { x.warnings = append(x.warnings, h) } } 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 zoneOpts := in.Options linkWith := in.LinkWith in.Nameservers, in.Records, in.CloneFrom, in.Options, in.LinkWith, in.LinkGroup = nil, nil, 0, nil, "", nil if linkWith != "" { if cloneFrom != 0 { return nil, errf(KindBadRequest, "give clone_from or link_with, not both") } v := x.st.ViewByName(linkWith) if v == nil { return nil, errf(KindNotFound, "view %q does not exist", linkWith) } o := x.st.FindZone(name, v.ID) if o == nil { return nil, errf(KindNotFound, "view %s has no zone %s to link with", v.Name, dnsname.Display(name)) } if o.Type != model.ZonePrimary || zt != model.ZonePrimary { return nil, errf(KindBadRequest, "only primary zones can be linked") } cloneFrom = o.ID } set := x.st.Settings var src *model.Zone if cloneFrom != 0 { src = x.st.Zones[cloneFrom] if src == nil { return nil, errf(KindNotFound, "zone %d (clone_from) does not exist", cloneFrom) } if src.Name != name { return nil, errf(KindBadRequest, "clone_from must be a zone with the same name in another view (it is %s)", dnsname.Display(src.Name)) } if !x.p.Can(zoneAction(x.st, src, false)) || !x.p.Can(recordAction(x.st, src, "", "", false)) { return nil, errf(KindForbidden, "permission denied: you can't read zone %s", zoneDesc(x.st, src)) } } if zt == model.ZonePrimary { if len(nameservers) == 0 && src == nil { nameservers = set.Nameservers } if len(nameservers) == 0 && src == nil { return nil, errf(KindBadRequest, "a primary zone needs at least one name server: give nameservers, or set default name servers in the settings") } if in.SOA == nil { in.SOA = &SOAInput{} } s := in.SOA if src != nil { // Copy the source zone's SOA and TTL unless overridden. if s.MName == nil { s.MName = model.Ptr(src.SOA.MName) } if s.RName == nil { s.RName = model.Ptr(src.SOA.RName) } if s.Refresh == nil { s.Refresh = model.Ptr(src.SOA.Refresh) } if s.Retry == nil { s.Retry = model.Ptr(src.SOA.Retry) } if s.Expire == nil { s.Expire = model.Ptr(src.SOA.Expire) } if s.Minimum == nil { s.Minimum = model.Ptr(src.SOA.Minimum) } if in.DefaultTTL == nil { in.DefaultTTL = model.Ptr(src.DefaultTTL) } } if s.MName == nil { first, err := dnsname.Owner(nameservers[0], name) if err != nil { return nil, errf(KindBadRequest, "%v", err) } s.MName = &first } if s.RName == nil { if set.RName != "" { s.RName = model.Ptr(set.RName) } else { s.RName = model.Ptr("hostmaster." + name) } } if s.Refresh == nil { s.Refresh = model.Ptr(set.Refresh) } if s.Retry == nil { s.Retry = model.Ptr(set.Retry) } if s.Expire == nil { s.Expire = model.Ptr(set.Expire) } if s.Minimum == nil { s.Minimum = model.Ptr(set.NegativeTTL) } if in.DefaultTTL == nil { in.DefaultTTL = model.Ptr(set.DefaultTTL) } if s.Serial == nil { policy := set.SerialPolicy if in.SerialPolicy != nil && *in.SerialPolicy != "" { policy = *in.SerialPolicy } serial := InitialSerial(policy, x.now) if src != nil && serialGreater(src.SOA.Serial, serial) { serial = src.SOA.Serial } s.Serial = &serial } } else if len(nameservers) > 0 || len(records) > 0 || src != nil { return nil, errf(KindBadRequest, "nameservers, records and clone_from only apply to primary zones") } type recSpec struct { in RecordInput summary string } var recs []recSpec seen := map[string]bool{} addRec := func(ri RecordInput) error { tmp := &model.Zone{Name: name, Type: zt} owner, p, err := parseRecord(tmp, deref(ri.Name), deref(ri.Type), deref(ri.Data)) if err != nil { return err } k := owner + "|" + p.Type + "|" + strings.ToLower(p.Data) if seen[k] { return nil } seen[k] = true ri.Name, ri.Type, ri.Data = &owner, &p.Type, &p.Data recs = append(recs, recSpec{ri, fmt.Sprintf("add %s%s IN %s %s", owner, ttlText(ri.TTL), p.Type, p.Data)}) return nil } for _, ns := range nameservers { target, err := dnsname.Owner(ns, name) if err != nil { return nil, errf(KindBadRequest, "name server %q: %v", ns, err) } if err := addRec(RecordInput{Name: model.Ptr("@"), Type: model.Ptr("NS"), Data: model.Ptr(target)}); err != nil { return nil, err } } var linkOps []model.Op if src != nil && linkWith != "" { // The new zone joins src's link, or starts one with src: then // all of src's records become shared. group := src.LinkGroup if group == 0 { group = src.ID linkOps = x.shareAllOps(src, group) } in.LinkGroup = &group } if src != nil { for _, r := range x.st.ZoneRecords(src.ID) { if linkWith != "" && src.LinkGroup != 0 && r.SharedID == 0 { continue // local to src's view } ri := RecordInput{Name: model.Ptr(r.Name), Type: model.Ptr(r.Type), Data: model.Ptr(r.Data), Comment: model.Ptr(r.Comment)} if linkWith != "" { id := r.SharedID if id == 0 { id = r.ID } ri.SharedID = &id } if r.Disabled { ri.Disabled = model.Ptr(true) } if r.TTL != nil { ri.TTL = model.Some(*r.TTL) } else { ri.TTL = model.Opt[uint32]{Set: true, Null: true} } if err := addRec(ri); err != nil { return nil, err } } } for _, ri := range records { ri.ZoneID, ri.Zone, ri.View = 0, "", "" if err := addRec(ri); err != nil { return nil, err } } ids, err := x.ids(1 + len(recs) + len(zoneOpts)) if err != nil { return nil, err } summary := fmt.Sprintf("create %s zone %s", zt, dnsname.Display(name)) if in.ViewID != nil && *in.ViewID != 0 { if v := x.st.Views[*in.ViewID]; v != nil { summary += " in view " + v.Name } } if src != nil && linkWith != "" { summary += " (linked with " + zoneDesc(x.st, src) + ")" } else if src != nil { summary += " (copy of " + zoneDesc(x.st, src) + ")" } ops := append(linkOps, newOp(OpZoneCreate, ids[0], 0, in, summary)) for i, zo := range zoneOpts { oi := OptionInput{Scope: model.Ptr(model.ScopeZone), ScopeID: model.Ptr(ids[0]), Name: model.Ptr(zo.Name), Value: model.Ptr(zo.Value)} if zo.Comment != "" { oi.Comment = model.Ptr(zo.Comment) } ops = append(ops, newOp(OpOptionCreate, ids[1+len(recs)+i], 0, oi, fmt.Sprintf("set zone %s option %s %s", dnsname.Display(name), zo.Name, zo.Value))) } for i, r := range recs { r.in.ZoneID = ids[0] ops = append(ops, newOp(OpRecordCreate, ids[1+i], 0, r.in, r.summary)) } return append(ops, moveOps...), nil } // parentZone returns the most specific primary zone in a view that // contains name (not name itself). func (x *expander) parentZone(name string, viewID int64) *model.Zone { var best *model.Zone for _, z := range x.st.ZonesInView(viewID) { if z.Type == model.ZonePrimary && dnsname.IsStrictSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) { best = z } } return best } // recordsUnder returns a zone's records at or below name, except a // delegation's NS and DS records and its glue, which belong to the parent. func (x *expander) recordsUnder(z *model.Zone, name string) []*model.Record { all := x.st.ZoneRecords(z.ID) var ns []string for _, r := range all { if r.Name == name && r.Type == "NS" { ns = append(ns, strings.ToLower(r.Data)) } } var out []*model.Record for _, r := range all { if !dnsname.IsSubdomain(r.Name, name) { continue } if r.Name == name && (r.Type == "NS" || r.Type == "DS") { continue } if (r.Type == "A" || r.Type == "AAAA") && slices.Contains(ns, strings.ToLower(r.Name)) { continue // glue } out = append(out, r) } return out } func deref(s *string) string { if s == nil { return "" } return *s } // reverseZoneFor finds the primary reverse zone for addr in a view. func reverseZoneFor(st *model.State, viewID int64, addr netip.Addr) (*model.Zone, string) { name := dnsname.ReverseName(addr) var best *model.Zone for _, z := range st.ZonesInView(viewID) { if z.Type == model.ZonePrimary && dnsname.IsSubdomain(name, z.Name) && (best == nil || len(z.Name) > len(best.Name)) { best = z } } return best, name } func addrOf(p *rr.Parsed) (netip.Addr, bool) { switch v := p.RR.(type) { case *dns.A: a, ok := netip.AddrFromSlice(v.A.To4()) return a, ok case *dns.AAAA: a, ok := netip.AddrFromSlice(v.AAAA) return a, ok } return netip.Addr{}, false } // PTR records follow address records per view (docs/reverse.md): after a // request's operations (and those keeping linked zones in step), ptrSync // compares each touched A or AAAA set before and after, in every view it // is in, and changes the PTR records of that view's reverse zone to match. // So a record local to one view of a linked zone changes PTR records in // that view only, and an address that goes from one view keeps its PTR // record in the views that still have it. // ptrForce marks a name and type whose PTR records the request asked to // update (ptr: true), in its zone and the zones linked with it. func (x *expander) ptrForce(z *model.Zone, name, typ string) { if x.ptrForced == nil { x.ptrForced = map[rrKey]bool{} } x.ptrForced[rrKey{z.ID, name, typ}] = true for _, o := range x.st.LinkedWith(z) { x.ptrForced[rrKey{o.ID, name, typ}] = true } } // Whether a set has an address: none, only in disabled records, enabled. const ( addrNone = iota addrDisabled addrEnabled ) // addrStates returns the addresses of a zone's A or AAAA set. func addrStates(st *model.State, k rrKey) map[netip.Addr]int { out := map[netip.Addr]int{} for _, r := range st.RRset(k.zone, k.name, k.typ) { a, err := netip.ParseAddr(r.Data) if err != nil { continue } s := addrDisabled if !r.Disabled { s = addrEnabled } out[a.Unmap()] = max(out[a.Unmap()], s) } return out } // ptrSync returns ops followed by the PTR changes they call for: for the // zone's auto_ptr, or because the request asked (ptrForce). func (x *expander) ptrSync(ops []model.Op) ([]model.Op, error) { forced := x.ptrForced x.ptrForced = 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) for _, k := range sortedForced(forced) { if !slices.Contains(keys, k) { keys = append(keys, k) } } var extra []model.Op for _, k := range keys { z := work.Zones[k.zone] if z == nil || (k.typ != "A" && k.typ != "AAAA") || strings.HasPrefix(k.name, "*.") { continue } force := forced[k] if !force && !z.AutoPTR { continue } before, after := addrStates(x.st, k), addrStates(work, k) var addrs []netip.Addr for _, m := range []map[netip.Addr]int{before, after} { for a := range m { if !slices.Contains(addrs, a) { addrs = append(addrs, a) } } } slices.SortFunc(addrs, func(a, b netip.Addr) int { return a.Compare(b) }) for _, a := range addrs { was, is := before[a], after[a] if !force && (was == is || was == addrNone && is == addrDisabled) { continue } rz, ptrName := reverseZoneFor(work, z.ViewID, a) if rz == nil { if force && is == addrEnabled { x.warnings = append(x.warnings, fmt.Sprintf("no reverse zone for %s in %s, so no PTR record was added there (create one: jarl zone create %s/%d%s)", a, viewOrServer(work, z.ViewID), a, a.BitLen(), viewFlag(work, z.ViewID))) } continue } more, err := x.ptrChange(work, rz, ptrName, k.name, is, !force) 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...) } } if len(extra) == 0 { return ops, nil } // The PTR records are local (see ptrChange), so this only keeps a // linked reverse zone consistent. extra, err := x.linkSync(extra) if err != nil { return nil, err } return append(ops, extra...), nil } func sortedForced(m map[rrKey]bool) []rrKey { var out []rrKey for k := range m { out = append(out, k) } slices.SortFunc(out, func(a, b rrKey) int { if a.zone != b.zone { return int(a.zone - b.zone) } if a.name != b.name { return strings.Compare(a.name, b.name) } return strings.Compare(a.typ, b.typ) }) return out } func viewFlag(st *model.State, viewID int64) string { if v := st.Views[viewID]; v != nil { return " --view " + v.Name } return "" } // ptrChange returns the operations that give reverse zone rz's PTR // records at ptrName for host owner the state of its address: none // (deleted), disabled, or enabled (added when missing). // // In a linked reverse zone the PTR records are local to each view, since // each view's follow its own address records: a shared set at ptrName // is made local in every view of the link first, as each view has it. func (x *expander) ptrChange(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) { nwarn := len(x.warnings) ops, err := x.ptrChangeIn(st, rz, ptrName, owner, state, auto) if err != nil || len(ops) == 0 || rz.LinkGroup == 0 { return ops, err } var local []model.Op for _, m := range st.LinkMembers(rz.LinkGroup) { for _, r := range st.RRset(m.ID, ptrName, "PTR") { if r.SharedID != 0 { local = append(local, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{SharedID: model.Ptr(int64(0))}, fmt.Sprintf("linked: %s PTR %s becomes local to %s (PTR records follow each view's own address records)", r.Name, r.Data, zoneDesc(st, m)))) } } } if len(local) == 0 { return ops, nil } // Again on the local records (their versions changed). scratch := st.Clone() c := &applyCtx{p: x.p, now: x.now} for _, op := range local { if err := applyOp(scratch, op, c); err != nil { return nil, err } } x.warnings = x.warnings[:nwarn] ops, err = x.ptrChangeIn(scratch, rz, ptrName, owner, state, auto) if err != nil { return nil, err } return append(local, ops...), nil } func (x *expander) ptrChangeIn(st *model.State, rz *model.Zone, ptrName, owner string, state int, auto bool) ([]model.Op, error) { var ops []model.Op switch state { case addrNone: for _, r := range st.RRset(rz.ID, ptrName, "PTR") { if strings.EqualFold(r.Data, owner) { ops = append(ops, newOp(OpRecordDelete, r.ID, r.Version, nil, fmt.Sprintf("delete %s PTR %s", r.Name, r.Data))) } } case addrDisabled: for _, r := range st.RRset(rz.ID, ptrName, "PTR") { if strings.EqualFold(r.Data, owner) && !r.Disabled { ops = append(ops, newOp(OpRecordUpdate, r.ID, r.Version, RecordInput{Disabled: model.Ptr(true)}, fmt.Sprintf("disable %s PTR %s", r.Name, r.Data))) } } default: return x.ptrCreateIn(st, rz, ptrName, owner, auto) } return ops, nil } // ptrCreateIn returns operations creating the PTR record ptrName -> owner // in reverse zone rz (none when it is there; enabling it when disabled). // auto is set when the zone's auto_ptr asked rather than the request: an // address whose PTR record names another host keeps it then. func (x *expander) ptrCreateIn(st *model.State, rz *model.Zone, ptrName, owner string, auto bool) ([]model.Op, error) { var other string for _, r := range st.RRset(rz.ID, ptrName, "PTR") { if strings.EqualFold(r.Data, owner) { if r.Disabled { in := RecordInput{Disabled: model.Ptr(false)} return []model.Op{newOp(OpRecordUpdate, r.ID, r.Version, in, fmt.Sprintf("enable %s PTR %s", r.Name, r.Data))}, nil } return nil, nil // already there } 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 } in := RecordInput{ZoneID: rz.ID, Name: model.Ptr(ptrName), Type: model.Ptr("PTR"), Data: model.Ptr(owner)} if rz.LinkGroup != 0 { in.SharedID = model.Ptr(int64(0)) } return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, fmt.Sprintf("add %s IN PTR %s", ptrName, owner))}, nil } func enableWord(disabled bool) string { if disabled { return "disable" } return "enable" } // parseWarnings keeps what parsing noticed (e.g. a name read as absolute). // The stored operation holds the canonical data, so applying it doesn't // notice again. func (x *expander) parseWarnings(owner string, p *rr.Parsed) { for _, w := range p.Warnings { x.warnings = append(x.warnings, fmt.Sprintf("%s %s: %s", dnsname.Display(owner), p.Type, w)) } } func (x *expander) recordCreate(req Request) ([]model.Op, error) { var in RecordInput 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 in.Name == nil || in.Type == nil || in.Data == nil { return nil, errf(KindBadRequest, "a record needs a name, a type and data") } owner, p, err := parseRecord(z, *in.Name, *in.Type, *in.Data) if err != nil { return nil, err } x.parseWarnings(owner, p) if in.PTR { if _, ok := addrOf(p); !ok { return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") } x.ptrForce(z, owner, p.Type) } 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) if err != nil { return nil, err } 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, notLinked(x.st, z) } // local on a zone that isn't linked: its records are local anyway. in.Local = nil return []model.Op{newOp(OpRecordCreate, ids[0], 0, in, summary)}, nil } func (x *expander) recordUpdate(req Request) ([]model.Op, error) { r := x.st.Records[req.ID] if r == nil { return nil, errf(KindNotFound, "record %d does not exist", req.ID) } z := x.st.Zones[r.ZoneID] var in RecordInput if err := decode(req.Data, &in); err != nil { return nil, err } ptr := in.PTR in.PTR = false in.Zone, in.View = "", "" if in.SharedID != nil { return nil, errf(KindBadRequest, "shared_id is managed by Jarl; use local: true or false to make a record local to its view or shared") } var pre []model.Op if in.Local != nil { if z.LinkGroup == 0 && !*in.Local { return nil, notLinked(x.st, z) } if want := *in.Local; z.LinkGroup != 0 && 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 z.LinkGroup != 0 && !*in.Local { // Sharing a version makes it every view's, also when it is // shared already and another view overrides it. pre = append(pre, x.dropOverrides(z, r.Name, r.Type)...) } in.Local = nil if string(mustJSON(in)) == "{}" { return pre, nil } } name, typ, data := r.Name, r.Type, r.Data if in.Name != nil { name = *in.Name } if in.Type != nil { typ = *in.Type } if in.Data != nil { data = *in.Data } owner, p, err := parseRecord(z, name, typ, data) if err != nil { return nil, err } x.parseWarnings(owner, p) if in.Name != nil { in.Name = &owner } if in.Type != nil { in.Type = &p.Type } if in.Data != nil { in.Data = &p.Data } var changes []string if owner != r.Name { changes = append(changes, "name "+r.Name+" → "+owner) } if p.Type != r.Type { changes = append(changes, "type "+r.Type+" → "+p.Type) } if p.Data != r.Data { changes = append(changes, "data "+r.Data+" → "+p.Data) } if in.TTL.Set { old := "default" if r.TTL != nil { old = fmt.Sprint(*r.TTL) } nw := "default" if !in.TTL.Null { nw = fmt.Sprint(in.TTL.Value) } if old != nw { changes = append(changes, "ttl "+old+" → "+nw) } } if in.Comment != nil && *in.Comment != r.Comment { changes = append(changes, "comment") } toggled := in.Disabled != nil && *in.Disabled != r.Disabled summary := fmt.Sprintf("change %s %s %s", r.Name, r.Type, r.Data) switch { case toggled && len(changes) == 0: summary = fmt.Sprintf("%s %s %s %s", enableWord(*in.Disabled), r.Name, r.Type, r.Data) case toggled: changes = append(changes, enableWord(*in.Disabled)+"d") fallthrough case len(changes) > 0: summary += ": " + strings.Join(changes, ", ") } if z.LinkGroup != 0 && r.SharedID != 0 && len(pre) == 0 && (owner != r.Name || p.Type != r.Type) && x.st.HasLocal(z.ID, owner, p.Type) { return nil, errf(KindConflict, "%s %s is local in view %s, so a shared record can't move there", dnsname.Display(owner), p.Type, x.st.ViewName(z)) } base := req.BaseVersion if base == 0 { base = r.Version } if len(pre) > 0 { base = 0 // the record was just updated by pre } ops := pre if string(mustJSON(in)) != "{}" || !ptr { ops = append(ops, newOp(OpRecordUpdate, r.ID, base, in, summary)) } if ptr { // The PTR records of the old and the new record, whichever is an // address (ptrSync). _, isAddr := addrOf(p) if old, err := rr.Parse(z.Name, r.Name, r.Type, r.Data); err == nil { if _, ok := addrOf(old); ok { isAddr = true x.ptrForce(z, r.Name, r.Type) } } if !isAddr { return nil, errf(KindBadRequest, "ptr only applies to A and AAAA records") } if _, ok := addrOf(p); ok { x.ptrForce(z, owner, p.Type) } } return ops, nil } func (x *expander) recordDelete(req Request) ([]model.Op, error) { var in RecordInput if err := decode(req.Data, &in); err != nil { return nil, err } if req.ID != 0 { r := x.st.Records[req.ID] if r == nil { if in.MissingOK { return nil, nil } return nil, errf(KindNotFound, "record %d does not exist", req.ID) } base := req.BaseVersion if base == 0 { 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 { x.ptrForce(x.st.Zones[r.ZoneID], r.Name, r.Type) } return ops, nil } z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) if err != nil { return nil, err } if in.Name == nil || in.Type == nil { return nil, errf(KindBadRequest, "deleting by match needs a name and a type (or give the record id)") } owner, err := dnsname.Owner(*in.Name, z.Name) if err != nil { return nil, errf(KindBadRequest, "%v", err) } typ, _, err := rr.NormalizeType(*in.Type) if err != nil { return nil, errf(KindBadRequest, "%v", err) } what := owner + " " + typ var parsed *rr.Parsed if in.Data != nil && strings.TrimSpace(*in.Data) != "" { _, p, err := parseRecord(z, owner, typ, *in.Data) if err != nil { return nil, err } in.Data = &p.Data what += " " + p.Data parsed = p } else { in.Data = nil what += " (all)" } if in.MissingOK { what += " (if present)" } if in.PTR && parsed != nil { x.ptrForce(z, owner, typ) } in.ZoneID, in.Zone, in.View, in.PTR = z.ID, "", "", false in.Name, in.Type = &owner, &typ return []model.Op{newOp(OpRecordDelete, 0, 0, in, "delete "+what)}, nil } // recordSetDisabled expands record.disable and record.enable: the record // with the request's ID, or the records matching zone, name, type and // (optionally) data. func (x *expander) recordSetDisabled(req Request, disabled bool) ([]model.Op, error) { var in RecordInput if err := decode(req.Data, &in); err != nil { return nil, err } var recs []*model.Record if req.ID != 0 { r := x.st.Records[req.ID] if r == nil { return nil, errf(KindNotFound, "record %d does not exist", req.ID) } recs = append(recs, r) } else { z, err := x.resolveZone(in.ZoneID, in.Zone, in.View) if err != nil { return nil, err } if in.Name == nil || in.Type == nil { return nil, errf(KindBadRequest, "finding records needs a name and a type (or give the record id)") } owner, err := dnsname.Owner(*in.Name, z.Name) if err != nil { return nil, errf(KindBadRequest, "%v", err) } typ, _, err := rr.NormalizeType(*in.Type) if err != nil { return nil, errf(KindBadRequest, "%v", err) } data, what := "", owner+" "+typ if in.Data != nil && strings.TrimSpace(*in.Data) != "" { _, p, err := parseRecord(z, owner, typ, *in.Data) if err != nil { return nil, err } data, what = p.Data, what+" "+p.Data } for _, r := range x.st.ZoneRecords(z.ID) { if r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) { recs = append(recs, r) } } if len(recs) == 0 { if in.MissingOK { return nil, nil } return nil, errf(KindNotFound, "no record %s in zone %s", dnsname.Display(what), dnsname.Display(z.Name)) } } var ops []model.Op for _, r := range recs { if r.Disabled == disabled { continue // already so } base := 0 if req.ID != 0 { base = req.BaseVersion } more, err := x.recordUpdate(Request{Op: OpRecordUpdate, ID: r.ID, BaseVersion: base, Data: mustJSON(RecordInput{Disabled: model.Ptr(disabled), PTR: in.PTR})}) if err != nil { return nil, err } ops = append(ops, more...) } if len(ops) == 0 && !in.MissingOK { what := fmt.Sprintf("%s %s %s is", recs[0].Name, recs[0].Type, recs[0].Data) if len(recs) > 1 { what = fmt.Sprintf("all %d %s %s records are", len(recs), recs[0].Name, recs[0].Type) } return nil, errf(KindConflict, "%s already %sd", what, enableWord(disabled)) } return ops, nil } func (x *expander) rrsetReplace(req Request) ([]model.Op, error) { var in RRSetInput 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 } owner, err := dnsname.Owner(in.Name, z.Name) if err != nil { return nil, errf(KindBadRequest, "%v", err) } typ, _, err := rr.NormalizeType(in.Type) if err != nil { return nil, errf(KindBadRequest, "%v", err) } var canon []string for _, d := range in.Data { _, p, err := parseRecord(z, owner, typ, d) if err != nil { return nil, err } x.parseWarnings(owner, p) canon = append(canon, p.Data) } in.ZoneID, in.Zone, in.View, in.Name, in.Type, in.Data = z.ID, "", "", owner, typ, canon where := "" var pre []model.Op if z.LinkGroup != 0 { local, err := x.localFor(z, owner, typ, in.Local) if err != nil { return nil, err } in.Shared = model.Ptr(!local) if local { where = " (local to view " + x.st.ViewName(z) + ")" } else if in.Local != nil { // Explicitly shared: this version becomes every view's. pre = x.dropOverrides(z, owner, typ) } } else if in.Local != nil && !*in.Local { return nil, notLinked(x.st, z) } in.Local = nil if len(canon) > 0 { in.NewIDs, err = x.ids(len(canon)) if err != nil { return nil, err } } summary := fmt.Sprintf("set %s%s IN %s to [%s]%s", owner, ttlText(in.TTL), typ, strings.Join(canon, ", "), where) 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 } func (x *expander) viewCreate(req Request) ([]model.Op, error) { var in ViewInput if err := decode(req.Data, &in); err != nil { return nil, err } if in.Name == nil || strings.TrimSpace(*in.Name) == "" { return nil, errf(KindBadRequest, "a view needs a name") } adopt := in.AdoptZones in.AdoptZones = false ids, err := x.ids(1) if err != nil { return nil, err } ops := []model.Op{newOp(OpViewCreate, ids[0], 0, in, "create view "+*in.Name)} if adopt { for _, z := range x.st.ZonesInView(0) { ops = append(ops, newOp(OpZoneUpdate, z.ID, z.Version, ZoneInput{ViewID: model.Ptr(ids[0])}, "move zone "+dnsname.Display(z.Name)+" into view "+*in.Name)) } } return ops, nil } func (x *expander) tsigCreate(req Request) ([]model.Op, error) { var in TSIGInput if err := decode(req.Data, &in); err != nil { return nil, err } if in.Name == nil { return nil, errf(KindBadRequest, "a TSIG key needs a name") } alg := "hmac-sha256" if in.Algorithm != nil { alg = strings.ToLower(*in.Algorithm) } in.Algorithm = &alg if in.Secret == nil || *in.Secret == "" { n := validate.TSIGAlgorithms[alg] if n == 0 { return nil, errf(KindBadRequest, "unsupported TSIG algorithm %q", alg) } in.Secret = model.Ptr(auth.GenerateTSIGSecret(n)) } ids, err := x.ids(1) if err != nil { return nil, err } return []model.Op{newOp(OpTSIGCreate, ids[0], 0, in, "create TSIG key "+model.NormalizeKeyName(*in.Name)+" ("+alg+")")}, nil } func (x *expander) optionCreate(req Request) ([]model.Op, error) { var in OptionInput if err := decode(req.Data, &in); err != nil { return nil, err } if in.Scope == nil || in.Name == nil { return nil, errf(KindBadRequest, "an option needs a scope and a name") } scopeID := int64(0) if in.ScopeID != nil { scopeID = *in.ScopeID } val := "" if in.Value != nil { val = *in.Value } n, v, err := NormalizeOption(x.st, *in.Scope, scopeID, *in.Name, val) if err != nil { return nil, err } in.Name, in.Value = &n, &v ids, err := x.ids(1) if err != nil { return nil, err } o := &model.Option{Scope: *in.Scope, ScopeID: scopeID, Name: n} return []model.Op{newOp(OpOptionCreate, ids[0], 0, in, "set "+optionLabel(x.st, o)+" "+v)}, nil } // RedactOps hides TSIG secrets in operation data. func RedactOps(ops []model.Op) []model.Op { out := make([]model.Op, len(ops)) for i, op := range ops { out[i] = op if op.Kind != OpTSIGCreate && op.Kind != OpTSIGUpdate || len(op.Data) == 0 { continue } var m map[string]any if json.Unmarshal(op.Data, &m) == nil { if _, ok := m["secret"]; ok { m["secret"] = "(hidden)" out[i].Data = mustJSON(m) } } } return out } // zoneToggle returns the new disabled state when a zone update only // disables or enables the zone (nil otherwise). func zoneToggle(req Request, z *model.Zone) *bool { var in ZoneInput if decode(req.Data, &in) != nil || in.Disabled == nil || *in.Disabled == z.Disabled { return nil } only := ZoneInput{Disabled: in.Disabled} if string(mustJSON(in)) != string(mustJSON(only)) { return nil } return in.Disabled } // disabledZoneEffect says what clients get once named no longer loads a // zone: in a view that offers recursion, answers from recursion (the // internet's version of the zone, if it has one); elsewhere, REFUSED. func disabledZoneEffect(st *model.State, z *model.Zone) string { where := "this server" if v := st.ViewName(z); v != "" { where = "view " + v } if st.RecursionOn(z.ViewID) { return fmt.Sprintf("once zone %s is disabled, clients of %s that may use recursion get its names from recursion: the internet's version of the zone, if there is one", dnsname.Display(z.Name), where) } return fmt.Sprintf("once zone %s is disabled, %s answers REFUSED for its names", dnsname.Display(z.Name), where) } // exposureAck acknowledges an exposure finding of a view or the server, // or takes the acknowledgement back: the view's (or the settings') // acknowledgements are updated as a whole. func (x *expander) exposureAck(req Request, ack bool) ([]model.Op, error) { var in AckInput if err := decode(req.Data, &in); err != nil { return nil, err } in.Code = strings.TrimSpace(in.Code) if !validate.Ackable(in.Code) { return nil, errf(KindBadRequest, "%q is not an exposure finding that can be acknowledged (exposure_... or security_open_...)", in.Code) } if ack && strings.TrimSpace(in.Reason) == "" { return nil, errf(KindBadRequest, "say why %s is accepted (a reason is required)", in.Code) } var v *model.View switch { case in.ViewID != 0: v = x.st.Views[in.ViewID] case in.View != "": v = x.st.ViewByName(in.View) } if (in.ViewID != 0 || in.View != "") && v == nil { return nil, errf(KindNotFound, "no view %s", firstNonEmptyStr(in.View, fmt.Sprint(in.ViewID))) } acks := x.st.Settings.Acknowledged where := "the server" if v != nil { acks, where = v.Acknowledged, "view "+v.Name } var out []model.Acknowledgement for _, a := range acks { if a.Code != in.Code { out = append(out, a) } } summary := "acknowledge " + in.Code + " for " + where + ": " + strings.TrimSpace(in.Reason) if ack { out = append(out, model.Acknowledgement{Code: in.Code, Reason: strings.TrimSpace(in.Reason), By: x.p.Name(), At: x.now}) } else { if len(out) == len(acks) { return nil, errf(KindConflict, "%s isn't acknowledged for %s", in.Code, where) } summary = "take back the acknowledgement of " + in.Code + " for " + where } if out == nil { out = []model.Acknowledgement{} } if v != nil { return []model.Op{newOp(OpViewUpdate, v.ID, v.Version, ViewInput{Acknowledged: &out}, summary)}, nil } return []model.Op{newOp(OpSettingsUpdate, 0, 0, SettingsInput{Acknowledged: &out}, summary)}, nil } func firstNonEmptyStr(a, b string) string { if a != "" { return a } return b } // notLinked refuses sharing a record of a zone that isn't linked. func notLinked(st *model.State, z *model.Zone) error { return errf(KindBadRequest, "zone %s isn't linked with other views, so there is nothing to share its records with (link it first: jarl zone link)", zoneDesc(st, z)) } // zoneUnchanged reports whether an update would leave the zone as it is. // Errors are left for applying the update to report. func zoneUnchanged(st *model.State, z *model.Zone, in *ZoneInput) bool { if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 { return false } c := st.Clone() n := c.Zones[z.ID] if err := setZoneFields(c, n, in, false); err != nil { return false } return reflect.DeepEqual(n, z) }