A disabled record stays in Jarl with its ID, TTL and comment but is left out of the zone; the zone file lists it as a comment. Web UI toggle, jarl record disable|enable, record.disable|enable operations and API endpoints. Database schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
611 lines
17 KiB
Go
611 lines
17 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/dnsname"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/importer"
|
|
"jarl/internal/model"
|
|
"jarl/internal/render"
|
|
)
|
|
|
|
// zoneByPath resolves {zone}: an ID, a zone name (with ?view= when the
|
|
// name exists in several views), or "root" for ".".
|
|
func (s *Server) zoneByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Zone, error) {
|
|
ref := r.PathValue("zone")
|
|
var z *model.Zone
|
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
|
z = st.Zones[id]
|
|
if z == nil {
|
|
return nil, notFound("zone %d not found", id)
|
|
}
|
|
} else {
|
|
name := ref
|
|
if name == "root" {
|
|
name = "."
|
|
}
|
|
var err error
|
|
z, err = engine.FindZoneRef(st, name, r.URL.Query().Get("view"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if !p.CanSeeZone(z.Name, st.ViewName(z)) {
|
|
return nil, notFound("zone %s not found", ref)
|
|
}
|
|
return z, nil
|
|
}
|
|
|
|
type recordCount struct{ all, disabled int }
|
|
|
|
func recordCounts(st *model.State) map[int64]recordCount {
|
|
counts := map[int64]recordCount{}
|
|
for _, r := range st.Records {
|
|
c := counts[r.ZoneID]
|
|
c.all++
|
|
if r.Disabled {
|
|
c.disabled++
|
|
}
|
|
counts[r.ZoneID] = c
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func (s *Server) listZones(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, base, err := s.states(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
view, typ, search := q.Get("view"), q.Get("type"), strings.ToLower(q.Get("q"))
|
|
counts := recordCounts(st)
|
|
out := []api.Zone{}
|
|
for _, z := range st.SortedZones() {
|
|
vn := st.ViewName(z)
|
|
if !p.CanSeeZone(z.Name, vn) {
|
|
continue
|
|
}
|
|
if view != "" && !strings.EqualFold(view, vn) || typ != "" && typ != z.Type || search != "" && !strings.Contains(z.Name, search) {
|
|
continue
|
|
}
|
|
zo := s.presentZone(st, z, counts)
|
|
if base != nil {
|
|
if old := base.Zones[z.ID]; old == nil {
|
|
zo.Pending = "add"
|
|
} else if old.Version != z.Version {
|
|
zo.Pending = "change"
|
|
}
|
|
}
|
|
out = append(out, zo)
|
|
}
|
|
if base != nil {
|
|
for _, z := range base.SortedZones() {
|
|
if st.Zones[z.ID] == nil && p.CanSeeZone(z.Name, base.ViewName(z)) {
|
|
zo := s.presentZone(base, z, recordCounts(base))
|
|
zo.Pending = "delete"
|
|
out = append(out, zo)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getZone(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
|
|
}
|
|
writeJSON(w, http.StatusOK, s.presentZone(st, z, recordCounts(st)))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) zoneObject(st *model.State, id int64) any {
|
|
if z := st.Zones[id]; z != nil {
|
|
return s.presentZone(st, z, recordCounts(st))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createZone(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneCreate, 0, body)}, s.zoneObject)
|
|
}
|
|
|
|
func (s *Server) updateZone(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
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := z.ID
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpZoneUpdate, id, body)}, func(st *model.State, _ int64) any { return s.zoneObject(st, id) })
|
|
}
|
|
|
|
func (s *Server) deleteZone(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{request(engine.OpZoneDelete, z.ID, nil)}, nil)
|
|
}
|
|
|
|
func (s *Server) zoneFile(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
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return badRequest("only primary zones have a zone file generated by Jarl")
|
|
}
|
|
if err := require(p, auth.Action{Resource: auth.ResRecords, Zone: z.Name, View: st.ViewName(z)}); err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`inline; filename="%s.zone"`, dnsname.FileSafe(z.Name)))
|
|
w.Write([]byte(render.ZoneFile(st, z)))
|
|
return nil
|
|
}
|
|
|
|
// ---- records
|
|
|
|
func (s *Server) listRecords(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, base, err := s.states(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z, err := s.zoneByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
name := q.Get("name")
|
|
if name != "" {
|
|
if n, err := dnsname.Owner(name, z.Name); err == nil {
|
|
name = n
|
|
}
|
|
}
|
|
typ := strings.ToUpper(q.Get("type"))
|
|
search := strings.ToLower(q.Get("q"))
|
|
disabled := q.Get("disabled") // "1": only disabled records, "0": only enabled ones
|
|
match := func(rec *model.Record) bool {
|
|
if name != "" && rec.Name != name || typ != "" && rec.Type != typ {
|
|
return false
|
|
}
|
|
if disabled != "" && rec.Disabled != (disabled == "1" || disabled == "true") {
|
|
return false
|
|
}
|
|
return search == "" || strings.Contains(rec.Name, search) || strings.Contains(strings.ToLower(rec.Data), search) || strings.Contains(strings.ToLower(rec.Comment), search)
|
|
}
|
|
recs := st.ZoneRecords(z.ID)
|
|
var deleted []*model.Record
|
|
if base != nil {
|
|
// Show records the changeset deletes too, marked as such.
|
|
for _, rec := range base.ZoneRecords(z.ID) {
|
|
if st.Records[rec.ID] == nil {
|
|
deleted = append(deleted, rec)
|
|
}
|
|
}
|
|
recs = append(recs, deleted...)
|
|
model.SortRecords(recs, z)
|
|
}
|
|
out := []api.Record{}
|
|
for _, rec := range recs {
|
|
if !match(rec) {
|
|
continue
|
|
}
|
|
src := st
|
|
if st.Records[rec.ID] == nil {
|
|
src = base
|
|
}
|
|
if !canReadRecord(p, src, rec) {
|
|
continue
|
|
}
|
|
ro := presentRecord(src, rec)
|
|
if base != nil {
|
|
switch old := base.Records[rec.ID]; {
|
|
case src == base:
|
|
ro.Pending = "delete"
|
|
case old == nil:
|
|
ro.Pending = "add"
|
|
case old.Version != rec.Version:
|
|
ro.Pending = "change"
|
|
if only := *old; only.Disabled != rec.Disabled {
|
|
// Just disabled or enabled: say which.
|
|
only.Disabled, only.Meta = rec.Disabled, rec.Meta
|
|
if reflect.DeepEqual(only, *rec) {
|
|
ro.Pending = map[bool]string{true: "disable", false: "enable"}[rec.Disabled]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out = append(out, ro)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func recordObject(st *model.State, id int64) any {
|
|
if r := st.Records[id]; r != nil {
|
|
return presentRecord(st, r)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// withZone puts the zone ID into a record request body.
|
|
func withZone(body json.RawMessage, zoneID int64) (json.RawMessage, error) {
|
|
m := map[string]any{}
|
|
if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
return nil, badRequest("invalid JSON: %v", err)
|
|
}
|
|
}
|
|
delete(m, "zone")
|
|
delete(m, "view")
|
|
m["zone_id"] = zoneID
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
func (s *Server) createRecord(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
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Several records may be posted as a JSON array.
|
|
var reqs []engine.Request
|
|
if len(body) > 0 && strings.HasPrefix(strings.TrimSpace(string(body)), "[") {
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(body, &items); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
for _, it := range items {
|
|
b, err := withZone(it, z.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reqs = append(reqs, request(engine.OpRecordCreate, 0, b))
|
|
}
|
|
} else {
|
|
b, err := withZone(body, z.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
reqs = []engine.Request{request(engine.OpRecordCreate, 0, b)}
|
|
}
|
|
return s.mutate(w, r, p, "", reqs, recordObject)
|
|
}
|
|
|
|
func (s *Server) recordByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Record, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rec := st.Records[id]
|
|
if rec == nil || !canReadRecord(p, st, rec) {
|
|
return nil, notFound("record %d not found", id)
|
|
}
|
|
return rec, nil
|
|
}
|
|
|
|
func (s *Server) getRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, err := s.recordByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, presentRecord(st, rec))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) updateRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, err := s.recordByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req := request(engine.OpRecordUpdate, rec.ID, body)
|
|
if v := r.Header.Get("If-Match"); v != "" {
|
|
if n, err := strconv.Atoi(strings.Trim(v, `"W/`)); err == nil {
|
|
req.BaseVersion = n
|
|
}
|
|
}
|
|
id := rec.ID
|
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) })
|
|
}
|
|
|
|
func (s *Server) deleteRecord(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, err := s.recordByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var data json.RawMessage
|
|
if queryBool(r, "ptr") {
|
|
data, _ = json.Marshal(map[string]any{"ptr": true})
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpRecordDelete, rec.ID, data)}, nil)
|
|
}
|
|
|
|
// deleteRecordsByMatch: DELETE /zones/{zone}/records?name=&type=[&data=][&missing_ok=1][&ptr=1]
|
|
func (s *Server) deleteRecordsByMatch(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
|
|
}
|
|
q := r.URL.Query()
|
|
if q.Get("name") == "" || q.Get("type") == "" {
|
|
return badRequest("give name and type (and optionally data) of the records to delete")
|
|
}
|
|
in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")}
|
|
if d := q.Get("data"); d != "" {
|
|
in.Data = &d
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpRecordDelete, 0, in)}, nil)
|
|
}
|
|
|
|
// setRecordDisabled: POST /records/{id}/disable or /enable [?ptr=1]
|
|
func (s *Server) setRecordDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
|
|
op := disableOp(disabled)
|
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rec, err := s.recordByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req := engine.NewRequest(op, rec.ID, engine.RecordInput{PTR: queryBool(r, "ptr")})
|
|
id := rec.ID
|
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any { return recordObject(st, id) })
|
|
}
|
|
}
|
|
|
|
// setRecordsDisabled: POST /zones/{zone}/records/disable or /enable
|
|
// ?name=&type=[&data=][&missing_ok=1][&ptr=1]
|
|
func (s *Server) setRecordsDisabled(disabled bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
|
|
op := disableOp(disabled)
|
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
z, err := s.zoneByPath(r, st, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
if q.Get("name") == "" || q.Get("type") == "" {
|
|
return badRequest("give name and type (and optionally data) of the records")
|
|
}
|
|
in := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(q.Get("name")), Type: model.Ptr(q.Get("type")), MissingOK: queryBool(r, "missing_ok"), PTR: queryBool(r, "ptr")}
|
|
if d := q.Get("data"); d != "" {
|
|
in.Data = &d
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
|
|
}
|
|
}
|
|
|
|
func disableOp(disabled bool) string {
|
|
if disabled {
|
|
return engine.OpRecordDisable
|
|
}
|
|
return engine.OpRecordEnable
|
|
}
|
|
|
|
func (s *Server) putRRSet(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
|
|
}
|
|
var in struct {
|
|
TTL model.Opt[uint32] `json:"ttl,omitzero"`
|
|
Data []string `json:"data"`
|
|
Comment *string `json:"comment,omitempty"`
|
|
}
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), TTL: in.TTL, Data: in.Data, Comment: in.Comment})
|
|
zid, name, typ := z.ID, r.PathValue("name"), strings.ToUpper(r.PathValue("type"))
|
|
return s.mutate(w, r, p, "", []engine.Request{req}, func(st *model.State, _ int64) any {
|
|
z := st.Zones[zid]
|
|
owner, _ := dnsname.Owner(name, z.Name)
|
|
out := []api.Record{}
|
|
for _, rec := range st.ZoneRecords(zid) {
|
|
if rec.Name == owner && rec.Type == typ {
|
|
out = append(out, presentRecord(st, rec))
|
|
}
|
|
}
|
|
return out
|
|
})
|
|
}
|
|
|
|
func (s *Server) deleteRRSet(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
|
|
}
|
|
req := engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: r.PathValue("name"), Type: r.PathValue("type"), Data: []string{}})
|
|
return s.mutate(w, r, p, "", []engine.Request{req}, nil)
|
|
}
|
|
|
|
// importZoneFile turns zone file text into record operations.
|
|
func (s *Server) importZoneFile(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
|
|
}
|
|
if z.Type != model.ZonePrimary {
|
|
return badRequest("records can only be imported into primary zones")
|
|
}
|
|
var in api.ZoneImport
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
if in.Mode == "" {
|
|
in.Mode = "merge"
|
|
}
|
|
if in.Mode != "merge" && in.Mode != "replace" {
|
|
return badRequest(`mode must be "merge" or "replace"`)
|
|
}
|
|
pz, err := importer.ParseZoneText(z.Name, in.Text)
|
|
if err != nil {
|
|
return badRequest("could not parse the zone file: %v", err)
|
|
}
|
|
defTTL := z.DefaultTTL
|
|
var reqs []engine.Request
|
|
if in.UseSOA && pz.SOA != nil {
|
|
soa := pz.SOA
|
|
zi := engine.ZoneInput{SOA: &engine.SOAInput{Refresh: &soa.Refresh, Retry: &soa.Retry, Expire: &soa.Expire, Minimum: &soa.Minttl}}
|
|
if pz.HasTTL {
|
|
zi.DefaultTTL = &pz.DefaultTTL
|
|
defTTL = pz.DefaultTTL
|
|
}
|
|
reqs = append(reqs, engine.NewRequest(engine.OpZoneUpdate, z.ID, zi))
|
|
}
|
|
ttlOf := func(t uint32) model.Opt[uint32] {
|
|
if t == defTTL {
|
|
return model.Opt[uint32]{Set: true, Null: true}
|
|
}
|
|
return model.Some(t)
|
|
}
|
|
existing := map[string]bool{}
|
|
sets := map[model.RRSetKey]bool{}
|
|
for _, rec := range st.ZoneRecords(z.ID) {
|
|
existing[rec.Name+"|"+rec.Type+"|"+strings.ToLower(rec.Data)] = true
|
|
sets[model.RRSetKey{Name: rec.Name, Type: rec.Type}] = true
|
|
}
|
|
switch in.Mode {
|
|
case "merge":
|
|
for _, pr := range pz.Records {
|
|
if existing[pr.Name+"|"+pr.Type+"|"+strings.ToLower(pr.Data)] {
|
|
continue
|
|
}
|
|
ri := engine.RecordInput{ZoneID: z.ID, Name: model.Ptr(pr.Name), Type: model.Ptr(pr.Type), Data: model.Ptr(pr.Data)}
|
|
if pr.TTL != defTTL {
|
|
ri.TTL = model.Some(pr.TTL)
|
|
}
|
|
if pr.Comment != "" {
|
|
ri.Comment = model.Ptr(pr.Comment)
|
|
}
|
|
reqs = append(reqs, engine.NewRequest(engine.OpRecordCreate, 0, ri))
|
|
}
|
|
case "replace":
|
|
type set struct {
|
|
ttl uint32
|
|
data []string
|
|
}
|
|
want := map[model.RRSetKey]*set{}
|
|
var order []model.RRSetKey
|
|
for _, pr := range pz.Records {
|
|
k := model.RRSetKey{Name: pr.Name, Type: pr.Type}
|
|
if want[k] == nil {
|
|
want[k] = &set{ttl: pr.TTL}
|
|
order = append(order, k)
|
|
}
|
|
want[k].data = append(want[k].data, pr.Data)
|
|
}
|
|
// Delete RRsets that are not in the file first, so CNAMEs can
|
|
// replace other data.
|
|
var gone []model.RRSetKey
|
|
for k := range sets {
|
|
if want[k] == nil {
|
|
gone = append(gone, k)
|
|
}
|
|
}
|
|
sort.Slice(gone, func(i, j int) bool { return gone[i].Name+gone[i].Type < gone[j].Name+gone[j].Type })
|
|
for _, k := range gone {
|
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, Data: []string{}}))
|
|
}
|
|
for _, k := range order {
|
|
reqs = append(reqs, engine.NewRequest(engine.OpRRSetReplace, 0, engine.RRSetInput{ZoneID: z.ID, Name: k.Name, Type: k.Type, TTL: ttlOf(want[k].ttl), Data: want[k].data}))
|
|
}
|
|
}
|
|
if len(reqs) == 0 {
|
|
writeJSON(w, http.StatusOK, api.MutationResponse{Warnings: append([]string{"nothing to import: all records already exist"}, pz.Skipped...)})
|
|
return nil
|
|
}
|
|
if in.DryRun {
|
|
q := r.URL.Query()
|
|
q.Set("dry_run", "1")
|
|
r.URL.RawQuery = q.Encode()
|
|
}
|
|
desc := fmt.Sprintf("import %d records into %s (%s)", len(pz.Records), engine.ZoneLabel(st, z), in.Mode)
|
|
// Report skipped records as warnings by wrapping the writer.
|
|
if len(pz.Skipped) > 0 {
|
|
w.Header().Set("X-Jarl-Skipped", strconv.Itoa(len(pz.Skipped)))
|
|
}
|
|
return s.mutate(w, r, p, desc, reqs, func(st *model.State, _ int64) any {
|
|
return map[string]any{"records": len(pz.Records), "skipped": pz.Skipped}
|
|
})
|
|
}
|