package engine import ( "bytes" "encoding/json" "fmt" "net/netip" "strings" "time" "jarl/internal/auth" "jarl/internal/catalog" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/namedconf" "jarl/internal/rr" "jarl/internal/validate" ) const maxTTL = 2147483647 // applyCtx carries what applying an operation needs. type applyCtx struct { p *auth.Principal now time.Time warnings []string } func (c *applyCtx) user() string { return c.p.Name() } func (c *applyCtx) require(a auth.Action) error { if c.p.Can(a) { return nil } return errf(KindForbidden, "permission denied: this needs %s", a) } func (c *applyCtx) warn(format string, args ...any) { c.warnings = append(c.warnings, fmt.Sprintf(format, args...)) } func decode(data json.RawMessage, v any) error { if len(data) == 0 { return nil } dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return errf(KindBadRequest, "invalid operation data: %v", err) } return nil } func checkVersion(op model.Op, current int, what string) error { if op.BaseVersion != 0 && op.BaseVersion != current { return errf(KindConflict, "%s was changed by someone else after this change was staged (version %d, now %d); review it and stage the change again", what, op.BaseVersion, current) } return nil } // applyOp applies one stored operation to st. func applyOp(st *model.State, op model.Op, c *applyCtx) error { var err error switch op.Kind { case OpZoneCreate: err = applyZoneCreate(st, op, c) case OpZoneUpdate: err = applyZoneUpdate(st, op, c) case OpZoneDelete: err = applyZoneDelete(st, op, c) case OpRecordCreate: err = applyRecordCreate(st, op, c) case OpRecordUpdate: err = applyRecordUpdate(st, op, c) case OpRecordDelete: err = applyRecordDelete(st, op, c) case OpRRSetReplace: err = applyRRSetReplace(st, op, c) case OpViewCreate: err = applyViewCreate(st, op, c) case OpViewUpdate: err = applyViewUpdate(st, op, c) case OpViewDelete: err = applyViewDelete(st, op, c) case OpViewReorder: err = applyViewReorder(st, op, c) case OpACLCreate, OpACLUpdate: err = applyACLPut(st, op, c) case OpACLDelete: err = applyACLDelete(st, op, c) case OpTSIGCreate, OpTSIGUpdate: err = applyTSIGPut(st, op, c) case OpTSIGDelete: err = applyTSIGDelete(st, op, c) case OpOptionCreate, OpOptionUpdate: err = applyOptionPut(st, op, c) case OpOptionDelete: err = applyOptionDelete(st, op, c) case OpSettingsUpdate: err = applySettings(st, op, c) default: err = errf(KindBadRequest, "unknown operation %q", op.Kind) } if e, ok := AsError(err); ok && e.OpSeq == 0 { e.OpSeq = op.Seq } return err } // ---- zones func zoneAction(st *model.State, z *model.Zone, write bool) auth.Action { return auth.Action{Resource: auth.ResZones, Write: write, Zone: z.Name, View: st.ViewName(z)} } // ParseZoneName accepts a zone name or a CIDR prefix (for reverse zones). func ParseZoneName(s string) (string, error) { s = strings.TrimSpace(s) if strings.Contains(s, "/") { if p, err := netip.ParsePrefix(s); err == nil { return dnsname.ReverseZone(p) } } a, err := dnsname.ToASCII(s) if err != nil { return "", err } name := dnsname.Fqdn(a) if err := dnsname.CheckZoneName(name); err != nil { return "", fmt.Errorf("invalid zone name %q: %v", s, err) } return name, nil } func normalizeZoneType(t string) (string, error) { t = strings.ToLower(strings.TrimSpace(t)) if s, ok := catalog.ZoneTypeSynonyms[t]; ok { t = s } for _, zt := range model.ZoneTypes { if t == zt { return t, nil } } return "", fmt.Errorf("unknown zone type %q (use one of %s)", t, strings.Join(model.ZoneTypes, ", ")) } func setZoneFields(st *model.State, z *model.Zone, in *ZoneInput, creating bool) error { if in.Name != nil { name, err := ParseZoneName(*in.Name) if err != nil { return errf(KindBadRequest, "%v", err) } if !creating && name != z.Name { return errf(KindBadRequest, "zones can't be renamed; create a new zone (it can copy the records with clone_from) and delete the old one") } z.Name = name } if in.ViewID != nil { if *in.ViewID != 0 && st.Views[*in.ViewID] == nil { return errf(KindNotFound, "view %d does not exist", *in.ViewID) } z.ViewID = *in.ViewID } else if in.View != nil { if *in.View == "" { z.ViewID = 0 } else if v := st.ViewByName(*in.View); v != nil { z.ViewID = v.ID } else { return errf(KindNotFound, "view %q does not exist", *in.View) } } if in.Type != nil { t, err := normalizeZoneType(*in.Type) if err != nil { return errf(KindBadRequest, "%v", err) } z.Type = t } if in.InView != nil { z.InView = strings.TrimSpace(*in.InView) } if in.File != nil { z.File = strings.TrimSpace(*in.File) } if s := in.SOA; s != nil { if s.MName != nil { n, err := dnsname.Owner(*s.MName, z.Name) if err != nil { return errf(KindBadRequest, "SOA mname: %v", err) } z.SOA.MName = n } if s.RName != nil { mb, err := dnsname.MailboxFromEmail(*s.RName) if err != nil { return errf(KindBadRequest, "SOA rname: %v", err) } z.SOA.RName = mb } if s.Serial != nil { z.SOA.Serial = *s.Serial } set := func(dst *uint32, v *uint32, name string) error { if v == nil { return nil } if *v > maxTTL { return errf(KindBadRequest, "SOA %s must be at most 2147483647", name) } *dst = *v return nil } for _, f := range []struct { dst *uint32 v *uint32 name string }{{&z.SOA.Refresh, s.Refresh, "refresh"}, {&z.SOA.Retry, s.Retry, "retry"}, {&z.SOA.Expire, s.Expire, "expire"}, {&z.SOA.Minimum, s.Minimum, "minimum"}} { if err := set(f.dst, f.v, f.name); err != nil { return err } } } if in.DefaultTTL != nil { if *in.DefaultTTL > maxTTL { return errf(KindBadRequest, "default TTL must be at most 2147483647") } z.DefaultTTL = *in.DefaultTTL } if in.SerialPolicy != nil { z.SerialPolicy = strings.ToLower(strings.TrimSpace(*in.SerialPolicy)) } if in.Comment != nil { z.Comment = *in.Comment } return nil } func applyZoneCreate(st *model.State, op model.Op, c *applyCtx) error { var in ZoneInput if err := decode(op.Data, &in); err != nil { return err } if op.ID == 0 || st.Zones[op.ID] != nil { return errf(KindConflict, "zone ID %d is already in use", op.ID) } if in.Name == nil || in.Type == nil { return errf(KindBadRequest, "a new zone needs a name and a type") } z := &model.Zone{ID: op.ID} if err := setZoneFields(st, z, &in, true); err != nil { return err } if err := c.require(zoneAction(st, z, true)); err != nil { return err } if existing := st.FindZone(z.Name, z.ViewID); existing != nil { where := "" if v := st.ViewName(z); v != "" { where = " in view " + v } return errf(KindConflict, "zone %s already exists%s", dnsname.Display(z.Name), where) } z.Touch(c.user(), c.now) st.Zones[z.ID] = z return nil } func applyZoneUpdate(st *model.State, op model.Op, c *applyCtx) error { z := st.Zones[op.ID] if z == nil { return errf(KindNotFound, "zone %d does not exist", op.ID) } if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil { return err } if err := c.require(zoneAction(st, z, true)); err != nil { return err } var in ZoneInput if err := decode(op.Data, &in); err != nil { return err } if len(in.Nameservers) > 0 || len(in.Records) > 0 || in.CloneFrom != 0 || len(in.Options) > 0 { return errf(KindBadRequest, "nameservers, records, options and clone_from can only be used when creating a zone") } oldView := z.ViewID if err := setZoneFields(st, z, &in, false); err != nil { return err } if z.ViewID != oldView { if other := st.FindZone(z.Name, z.ViewID); other != nil && other.ID != z.ID { return errf(KindConflict, "the target view already has a zone %s", dnsname.Display(z.Name)) } if err := c.require(zoneAction(st, z, true)); err != nil { return err } } z.Touch(c.user(), c.now) return nil } func applyZoneDelete(st *model.State, op model.Op, c *applyCtx) error { z := st.Zones[op.ID] if z == nil { return errf(KindNotFound, "zone %d does not exist", op.ID) } if err := checkVersion(op, z.Version, "zone "+dnsname.Display(z.Name)); err != nil { return err } if err := c.require(zoneAction(st, z, true)); err != nil { return err } deleteZone(st, z.ID) return nil } func deleteZone(st *model.State, id int64) { for rid, r := range st.Records { if r.ZoneID == id { delete(st.Records, rid) } } for oid, o := range st.Options { if o.Scope == model.ScopeZone && o.ScopeID == id { delete(st.Options, oid) } } delete(st.Zones, id) } // ---- records func recordAction(st *model.State, z *model.Zone, name, typ string, write bool) auth.Action { return auth.Action{Resource: auth.ResRecords, Write: write, Zone: z.Name, View: st.ViewName(z), Name: name, Type: typ} } func recordLabel(r *model.Record) string { return fmt.Sprintf("record %s %s %s", r.Name, r.Type, r.Data) } // parseRecord resolves and validates name, type and data for zone z. func parseRecord(z *model.Zone, name, typ, data string) (string, *rr.Parsed, error) { if !z.HasRecords() { return "", nil, errf(KindBadRequest, "zone %s is a %s zone; records can only be managed in primary zones", dnsname.Display(z.Name), z.Type) } owner, err := dnsname.Owner(name, z.Name) if err != nil { return "", nil, errf(KindBadRequest, "%v", err) } if err := dnsname.CheckOwner(owner); err != nil { return "", nil, errf(KindBadRequest, "invalid name %q: %v", name, err) } if !dnsname.IsSubdomain(owner, z.Name) { return "", nil, errf(KindBadRequest, "%s is not inside zone %s", dnsname.Display(owner), dnsname.Display(z.Name)) } p, err := rr.Parse(z.Name, owner, typ, data) if err != nil { return "", nil, errf(KindBadRequest, "%s %s: %v", dnsname.Display(owner), strings.ToUpper(typ), err) } return owner, p, nil } func rrsetTTL(st *model.State, zoneID int64, name, typ string, exceptID int64) (*uint32, bool) { for _, r := range st.Records { if r.ZoneID == zoneID && r.Name == name && r.Type == typ && r.ID != exceptID { if r.TTL == nil { return nil, true } t := *r.TTL return &t, true } } return nil, false } func applyRecordCreate(st *model.State, op model.Op, c *applyCtx) error { var in RecordInput if err := decode(op.Data, &in); err != nil { return err } if op.ID == 0 || st.Records[op.ID] != nil { return errf(KindConflict, "record ID %d is already in use", op.ID) } z := st.Zones[in.ZoneID] if z == nil { return errf(KindNotFound, "zone %d does not exist", in.ZoneID) } if in.Name == nil || in.Type == nil || in.Data == nil { return 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 err } if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil { return err } r := &model.Record{ID: op.ID, ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} switch { case in.TTL.Set && !in.TTL.Null: if in.TTL.Value > maxTTL { return errf(KindBadRequest, "TTL must be at most 2147483647") } r.TTL = model.Ptr(in.TTL.Value) case !in.TTL.Set: // Join an existing RRset with its TTL. if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, 0); ok { r.TTL = ttl } } if in.Comment != nil { r.Comment = *in.Comment } for _, w := range p.Warnings { c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) } r.Touch(c.user(), c.now) st.Records[r.ID] = r return nil } func applyRecordUpdate(st *model.State, op model.Op, c *applyCtx) error { r := st.Records[op.ID] if r == nil { return errf(KindNotFound, "record %d does not exist", op.ID) } if err := checkVersion(op, r.Version, recordLabel(r)); err != nil { return err } z := st.Zones[r.ZoneID] if z == nil { return errf(KindNotFound, "the record's zone does not exist") } if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil { return err } var in RecordInput if err := decode(op.Data, &in); err != nil { return err } if in.ZoneID != 0 && in.ZoneID != r.ZoneID { return errf(KindBadRequest, "records can't be moved to another zone; delete and re-create them") } 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 } else if in.Type != nil && !strings.EqualFold(*in.Type, r.Type) { return errf(KindBadRequest, "changing the type of a record needs new data too") } if in.Name != nil || in.Type != nil || in.Data != nil { owner, p, err := parseRecord(z, name, typ, data) if err != nil { return err } if owner != r.Name || p.Type != r.Type { if err := c.require(recordAction(st, z, owner, p.Type, true)); err != nil { return err } } for _, w := range p.Warnings { c.warn("%s %s: %s", dnsname.Display(owner), p.Type, w) } moved := owner != r.Name || p.Type != r.Type r.Name, r.Type, r.Data = owner, p.Type, p.Data if moved && !in.TTL.Set { if ttl, ok := rrsetTTL(st, z.ID, owner, p.Type, r.ID); ok { r.TTL = ttl } } } if in.TTL.Set { if in.TTL.Null { r.TTL = nil } else { if in.TTL.Value > maxTTL { return errf(KindBadRequest, "TTL must be at most 2147483647") } r.TTL = model.Ptr(in.TTL.Value) } } if in.Comment != nil { r.Comment = *in.Comment } r.Touch(c.user(), c.now) return nil } func applyRecordDelete(st *model.State, op model.Op, c *applyCtx) error { if op.ID != 0 { r := st.Records[op.ID] if r == nil { return errf(KindNotFound, "record %d does not exist", op.ID) } if err := checkVersion(op, r.Version, recordLabel(r)); err != nil { return err } z := st.Zones[r.ZoneID] if z != nil { if err := c.require(recordAction(st, z, r.Name, r.Type, true)); err != nil { return err } } delete(st.Records, r.ID) return nil } // Delete by match. var in RecordInput if err := decode(op.Data, &in); err != nil { return err } z := st.Zones[in.ZoneID] if z == nil { return errf(KindNotFound, "zone %d does not exist", in.ZoneID) } if in.Name == nil || in.Type == nil { return errf(KindBadRequest, "deleting records by match needs a name and a type") } owner, err := dnsname.Owner(*in.Name, z.Name) if err != nil { return errf(KindBadRequest, "%v", err) } typ, _, err := rr.NormalizeType(*in.Type) if err != nil { return errf(KindBadRequest, "%v", err) } if err := c.require(recordAction(st, z, owner, typ, true)); err != nil { return err } data := "" if in.Data != nil && strings.TrimSpace(*in.Data) != "" { _, p, err := parseRecord(z, owner, typ, *in.Data) if err != nil { return err } data = p.Data } n := 0 for id, r := range st.Records { if r.ZoneID == z.ID && r.Name == owner && r.Type == typ && (data == "" || strings.EqualFold(r.Data, data)) { delete(st.Records, id) n++ } } if n == 0 && !in.MissingOK { what := dnsname.Display(owner) + " " + typ if data != "" { what += " " + data } return errf(KindNotFound, "no record %s in zone %s", what, dnsname.Display(z.Name)) } return nil } func applyRRSetReplace(st *model.State, op model.Op, c *applyCtx) error { var in RRSetInput if err := decode(op.Data, &in); err != nil { return err } z := st.Zones[in.ZoneID] if z == nil { return errf(KindNotFound, "zone %d does not exist", in.ZoneID) } owner, err := dnsname.Owner(in.Name, z.Name) if err != nil { return errf(KindBadRequest, "%v", err) } typ, _, err := rr.NormalizeType(in.Type) if err != nil { return errf(KindBadRequest, "%v", err) } if err := c.require(recordAction(st, z, owner, typ, true)); err != nil { return err } var desired []string seen := map[string]bool{} for _, d := range in.Data { _, p, err := parseRecord(z, owner, typ, d) if err != nil { return err } for _, w := range p.Warnings { c.warn("%s %s: %s", dnsname.Display(owner), typ, w) } k := strings.ToLower(p.Data) if !seen[k] { seen[k] = true desired = append(desired, p.Data) } } var ttl *uint32 ttlKnown := false if in.TTL.Set { ttlKnown = true if !in.TTL.Null { if in.TTL.Value > maxTTL { return errf(KindBadRequest, "TTL must be at most 2147483647") } ttl = model.Ptr(in.TTL.Value) } } else if t, ok := rrsetTTL(st, z.ID, owner, typ, 0); ok { ttl, ttlKnown = t, true } keep := map[string]bool{} for id, r := range st.Records { if r.ZoneID != z.ID || r.Name != owner || r.Type != typ { continue } k := strings.ToLower(r.Data) if seen[k] && !keep[k] { keep[k] = true if ttlKnown && !sameTTL(r.TTL, ttl) { r.TTL = copyTTL(ttl) r.Touch(c.user(), c.now) } continue } delete(st.Records, id) } ids := in.NewIDs for _, d := range desired { if keep[strings.ToLower(d)] { continue } if len(ids) == 0 { return errf(KindBadRequest, "internal error: no IDs allocated for new records") } r := &model.Record{ID: ids[0], ZoneID: z.ID, Name: owner, Type: typ, Data: d, TTL: copyTTL(ttl)} ids = ids[1:] if st.Records[r.ID] != nil { return errf(KindConflict, "record ID %d is already in use", r.ID) } if in.Comment != nil { r.Comment = *in.Comment } r.Touch(c.user(), c.now) st.Records[r.ID] = r } return nil } func sameTTL(a, b *uint32) bool { if a == nil || b == nil { return a == nil && b == nil } return *a == *b } func copyTTL(t *uint32) *uint32 { if t == nil { return nil } v := *t return &v } // ---- views func viewAction(name string) auth.Action { return auth.Action{Resource: auth.ResViews, Write: true, View: name} } func normalizeElements(list []string, field string) ([]string, error) { out := []string{} for _, s := range list { if strings.TrimSpace(s) == "" { continue } e, err := namedconf.ParseAMElement(s) if err != nil { return nil, errf(KindBadRequest, "%s element %q: %v", field, s, err) } out = append(out, e.String()) } return out, nil } func setViewFields(v *model.View, in *ViewInput) error { if in.Name != nil { v.Name = strings.TrimSpace(*in.Name) } if in.MatchClients != nil { l, err := normalizeElements(*in.MatchClients, "match-clients") if err != nil { return err } v.MatchClients = l } if in.MatchDestinations != nil { l, err := normalizeElements(*in.MatchDestinations, "match-destinations") if err != nil { return err } v.MatchDestinations = l } if in.MatchRecursiveOnly.Set { if in.MatchRecursiveOnly.Null { v.MatchRecursiveOnly = nil } else { v.MatchRecursiveOnly = model.Ptr(in.MatchRecursiveOnly.Value) } } if in.Comment != nil { v.Comment = *in.Comment } if in.Position != nil { v.Position = *in.Position } return nil } func applyViewCreate(st *model.State, op model.Op, c *applyCtx) error { var in ViewInput if err := decode(op.Data, &in); err != nil { return err } if op.ID == 0 || st.Views[op.ID] != nil { return errf(KindConflict, "view ID %d is already in use", op.ID) } if in.Name == nil || strings.TrimSpace(*in.Name) == "" { return errf(KindBadRequest, "a view needs a name") } v := &model.View{ID: op.ID, Position: len(st.Views), MatchClients: []string{}, MatchDestinations: []string{}} if err := setViewFields(v, &in); err != nil { return err } if err := c.require(viewAction(v.Name)); err != nil { return err } if st.ViewByName(v.Name) != nil { return errf(KindConflict, "view %s already exists", v.Name) } v.Touch(c.user(), c.now) st.Views[v.ID] = v renumberViews(st) return nil } // renumberViews makes positions 0..n-1 in the current order. func renumberViews(st *model.State) { for i, v := range st.OrderedViews() { v.Position = i } } func applyViewUpdate(st *model.State, op model.Op, c *applyCtx) error { v := st.Views[op.ID] if v == nil { return errf(KindNotFound, "view %d does not exist", op.ID) } if err := checkVersion(op, v.Version, "view "+v.Name); err != nil { return err } if err := c.require(viewAction(v.Name)); err != nil { return err } var in ViewInput if err := decode(op.Data, &in); err != nil { return err } oldName := v.Name if err := setViewFields(v, &in); err != nil { return err } if v.Name != oldName { if other := st.ViewByName(v.Name); other != nil && other.ID != v.ID { return errf(KindConflict, "view %s already exists", v.Name) } if err := c.require(viewAction(v.Name)); err != nil { return err } // Keep in-view references working. for _, z := range st.Zones { if z.Type == model.ZoneInView && strings.EqualFold(z.InView, oldName) { z.InView = v.Name z.Touch(c.user(), c.now) } } } v.Touch(c.user(), c.now) renumberViews(st) return nil } func applyViewDelete(st *model.State, op model.Op, c *applyCtx) error { v := st.Views[op.ID] if v == nil { return errf(KindNotFound, "view %d does not exist", op.ID) } if err := checkVersion(op, v.Version, "view "+v.Name); err != nil { return err } if err := c.require(viewAction(v.Name)); err != nil { return err } var in DeleteInput if err := decode(op.Data, &in); err != nil { return err } zones := st.ZonesInView(v.ID) if len(zones) > 0 && !in.Cascade { var names []string for _, z := range zones { names = append(names, dnsname.Display(z.Name)) } return errf(KindConflict, "view %s still has %d zone(s) (%s); move or delete them first, or delete the view with cascade", v.Name, len(zones), strings.Join(names, ", ")) } for _, z := range zones { if err := c.require(zoneAction(st, z, true)); err != nil { return err } deleteZone(st, z.ID) } for oid, o := range st.Options { if o.Scope == model.ScopeView && o.ScopeID == v.ID { delete(st.Options, oid) } } delete(st.Views, v.ID) renumberViews(st) return nil } func applyViewReorder(st *model.State, op model.Op, c *applyCtx) error { var in ReorderInput if err := decode(op.Data, &in); err != nil { return err } if len(in.IDs) != len(st.Views) { return errf(KindConflict, "the new order lists %d views, but there are %d", len(in.IDs), len(st.Views)) } seen := map[int64]bool{} for _, id := range in.IDs { v := st.Views[id] if v == nil || seen[id] { return errf(KindBadRequest, "the new order must list every view exactly once") } seen[id] = true if err := c.require(viewAction(v.Name)); err != nil { return err } } for i, id := range in.IDs { v := st.Views[id] if v.Position != i { v.Position = i v.Touch(c.user(), c.now) } } return nil } // ---- ACLs func applyACLPut(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil { return err } var in ACLInput if err := decode(op.Data, &in); err != nil { return err } var a *model.ACL if op.Kind == OpACLCreate { if op.ID == 0 || st.ACLs[op.ID] != nil { return errf(KindConflict, "ACL ID %d is already in use", op.ID) } if in.Name == nil { return errf(KindBadRequest, "an ACL needs a name") } a = &model.ACL{ID: op.ID, Elements: []string{}} } else { a = st.ACLs[op.ID] if a == nil { return errf(KindNotFound, "ACL %d does not exist", op.ID) } if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil { return err } } oldName := a.Name if in.Name != nil { name := strings.TrimSpace(*in.Name) if namedconf.BuiltinACLs[strings.ToLower(name)] { return errf(KindBadRequest, "%q is a built-in ACL name", name) } if err := namedconf.CheckACLName(name); err != nil { return errf(KindBadRequest, "%v", err) } if other := st.ACLByName(name); other != nil && other.ID != a.ID { return errf(KindConflict, "ACL %s already exists", name) } a.Name = name } if in.Elements != nil { l, err := normalizeElements(*in.Elements, "ACL") if err != nil { return err } a.Elements = l } if in.Comment != nil { a.Comment = *in.Comment } a.Touch(c.user(), c.now) st.ACLs[a.ID] = a if op.Kind == OpACLUpdate && oldName != a.Name { renameRefs(st, refACL, oldName, a.Name, c) } return nil } func applyACLDelete(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(auth.Action{Resource: auth.ResACLs, Write: true}); err != nil { return err } a := st.ACLs[op.ID] if a == nil { return errf(KindNotFound, "ACL %d does not exist", op.ID) } if err := checkVersion(op, a.Version, "ACL "+a.Name); err != nil { return err } if users := FindRefs(st, refACL, a.Name); len(users) > 0 { return errf(KindConflict, "ACL %s is still used by %s", a.Name, strings.Join(users, ", ")) } delete(st.ACLs, a.ID) return nil } // ---- TSIG keys func applyTSIGPut(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { return err } var in TSIGInput if err := decode(op.Data, &in); err != nil { return err } var k *model.TSIGKey if op.Kind == OpTSIGCreate { if op.ID == 0 || st.Keys[op.ID] != nil { return errf(KindConflict, "key ID %d is already in use", op.ID) } if in.Name == nil || in.Secret == nil { return errf(KindBadRequest, "a TSIG key needs a name and a secret") } k = &model.TSIGKey{ID: op.ID, Algorithm: "hmac-sha256"} } else { k = st.Keys[op.ID] if k == nil { return errf(KindNotFound, "TSIG key %d does not exist", op.ID) } if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil { return err } } oldName := k.Name if in.Name != nil { name := model.NormalizeKeyName(*in.Name) if err := dnsname.CheckZoneName(dnsname.Fqdn(name)); err != nil || name == "" || name == "." { return errf(KindBadRequest, "invalid key name %q (key names are domain names, e.g. xfer-key or acme.example.com)", *in.Name) } if other := st.KeyByName(name); other != nil && other.ID != k.ID { return errf(KindConflict, "TSIG key %s already exists", name) } k.Name = name } if in.Algorithm != nil { alg := strings.ToLower(strings.TrimSpace(*in.Algorithm)) if _, ok := validate.TSIGAlgorithms[alg]; !ok { return errf(KindBadRequest, "unsupported TSIG algorithm %q", alg) } k.Algorithm = alg } if in.Secret != nil { k.Secret = strings.TrimSpace(*in.Secret) } if in.Comment != nil { k.Comment = *in.Comment } k.Touch(c.user(), c.now) st.Keys[k.ID] = k if op.Kind == OpTSIGUpdate && oldName != k.Name { renameRefs(st, refKey, oldName, k.Name, c) } return nil } func applyTSIGDelete(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { return err } k := st.Keys[op.ID] if k == nil { return errf(KindNotFound, "TSIG key %d does not exist", op.ID) } if err := checkVersion(op, k.Version, "TSIG key "+k.Name); err != nil { return err } if users := FindRefs(st, refKey, k.Name); len(users) > 0 { return errf(KindConflict, "TSIG key %s is still used by %s", k.Name, strings.Join(users, ", ")) } delete(st.Keys, k.ID) return nil } // ---- options func optionAction(st *model.State, scope string, scopeID int64) (auth.Action, error) { switch scope { case model.ScopeOptions, model.ScopeTopLevel: return auth.Action{Resource: auth.ResOptions, Write: true}, nil case model.ScopeView: v := st.Views[scopeID] if v == nil { return auth.Action{}, errf(KindNotFound, "view %d does not exist", scopeID) } return viewAction(v.Name), nil case model.ScopeZone: z := st.Zones[scopeID] if z == nil { return auth.Action{}, errf(KindNotFound, "zone %d does not exist", scopeID) } return zoneAction(st, z, true), nil } return auth.Action{}, errf(KindBadRequest, "unknown option scope %q (use options, view, zone or toplevel)", scope) } // catalogScope returns the catalog scope for an option scope. func catalogScope(st *model.State, scope string, scopeID int64) string { switch scope { case model.ScopeOptions: return catalog.ScopeOptions case model.ScopeTopLevel: return catalog.ScopeTop case model.ScopeView: return catalog.ScopeView case model.ScopeZone: if z := st.Zones[scopeID]; z != nil { return catalog.ZoneScope(z.Type) } } return "" } // NormalizeOption checks an option name for its scope and returns the // canonical name and value. func NormalizeOption(st *model.State, scope string, scopeID int64, name, value string) (string, string, error) { cs := catalogScope(st, scope, scopeID) n := strings.ToLower(strings.TrimSpace(name)) if canon, syn := catalog.Canonical(cs, n); syn { n = canon } stmt, ok := catalog.Default().Lookup(cs, n) if !ok { hint := "" if scope == model.ScopeZone { if z := st.Zones[scopeID]; z != nil { hint = " for " + z.Type + " zones" } } return "", "", errf(KindBadRequest, "%q is not a valid option%s in this scope", name, hint) } if stmt.Managed { return "", "", errf(KindBadRequest, "%q can't be set as an option; it is managed through %s", n, stmt.ManagedBy) } v := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), ";")) if v == "" { if n == "include" { return "", "", errf(KindBadRequest, "include needs a file name") } return n, "", nil } // Quote plain strings for options that take a quoted string. if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") { v = namedconf.QuoteString(v) } parsed, err := namedconf.ParseOptionValue(n, v) if err != nil { return "", "", errf(KindBadRequest, "option %s: %v", n, err) } return n, namedconf.Compact(parsed.Items[1:]), nil } func applyOptionPut(st *model.State, op model.Op, c *applyCtx) error { var in OptionInput if err := decode(op.Data, &in); err != nil { return err } var o *model.Option if op.Kind == OpOptionCreate { if op.ID == 0 || st.Options[op.ID] != nil { return errf(KindConflict, "option ID %d is already in use", op.ID) } if in.Scope == nil || in.Name == nil { return errf(KindBadRequest, "an option needs a scope and a name") } o = &model.Option{ID: op.ID, Scope: *in.Scope} if in.ScopeID != nil { o.ScopeID = *in.ScopeID } if o.Scope == model.ScopeOptions || o.Scope == model.ScopeTopLevel { o.ScopeID = 0 } o.Position = maxPosition(st, o.Scope, o.ScopeID) + 1 } else { o = st.Options[op.ID] if o == nil { return errf(KindNotFound, "option %d does not exist", op.ID) } if err := checkVersion(op, o.Version, "option "+o.Name); err != nil { return err } if in.Scope != nil && *in.Scope != o.Scope || in.ScopeID != nil && *in.ScopeID != o.ScopeID { return errf(KindBadRequest, "options can't be moved to another scope; delete and re-create them") } } act, err := optionAction(st, o.Scope, o.ScopeID) if err != nil { return err } if err := c.require(act); err != nil { return err } name, value := o.Name, o.Value if in.Name != nil { name = *in.Name } if in.Value != nil { value = *in.Value } n, v, err := NormalizeOption(st, o.Scope, o.ScopeID, name, value) if err != nil { return err } o.Name, o.Value = n, v if in.Comment != nil { o.Comment = *in.Comment } if in.Position != nil { o.Position = *in.Position } o.Touch(c.user(), c.now) st.Options[o.ID] = o return nil } func maxPosition(st *model.State, scope string, scopeID int64) int { m := 0 for _, o := range st.Options { if o.Scope == scope && o.ScopeID == scopeID && o.Position > m { m = o.Position } } return m } func applyOptionDelete(st *model.State, op model.Op, c *applyCtx) error { o := st.Options[op.ID] if o == nil { return errf(KindNotFound, "option %d does not exist", op.ID) } if err := checkVersion(op, o.Version, "option "+o.Name); err != nil { return err } act, err := optionAction(st, o.Scope, o.ScopeID) if err != nil { return err } if err := c.require(act); err != nil { return err } delete(st.Options, o.ID) return nil } // ---- settings func applySettings(st *model.State, op model.Op, c *applyCtx) error { if err := c.require(auth.Action{Resource: auth.ResOptions, Write: true}); err != nil { return err } var in SettingsInput if err := decode(op.Data, &in); err != nil { return err } s := &st.Settings if in.DefaultTTL != nil { s.DefaultTTL = *in.DefaultTTL } if in.Nameservers != nil { var ns []string for _, n := range *in.Nameservers { if strings.TrimSpace(n) == "" { continue } a, err := dnsname.ToASCII(strings.TrimSpace(n)) if err != nil { return errf(KindBadRequest, "%v", err) } ns = append(ns, dnsname.Fqdn(a)) } s.Nameservers = ns } if in.RName != nil { if strings.TrimSpace(*in.RName) == "" { s.RName = "" } else { mb, err := dnsname.MailboxFromEmail(*in.RName) if err != nil { return errf(KindBadRequest, "%v", err) } s.RName = mb } } if in.Refresh != nil { s.Refresh = *in.Refresh } if in.Retry != nil { s.Retry = *in.Retry } if in.Expire != nil { s.Expire = *in.Expire } if in.NegativeTTL != nil { s.NegativeTTL = *in.NegativeTTL } if in.SerialPolicy != nil { s.SerialPolicy = strings.ToLower(*in.SerialPolicy) } s.Version++ return nil }