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"), func(z *model.Zone) bool { return p.CanSeeZone(z.Name, st.ViewName(z)) }) 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) } // zoneLink: POST /zones/{zone}/link {"with": ["external"]} links the zone // with the zones of the same name in those views (default: every view // that has one). zoneUnlink: POST /zones/{zone}/unlink. func (s *Server) zoneLink(link bool) handler { return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } z, err := s.zoneByPath(r, st, p) if err != nil { return err } in := engine.LinkInput{ZoneID: z.ID} op := engine.OpZoneUnlink if link { op = engine.OpZoneLink var body struct { With []string `json:"with"` } if r.ContentLength != 0 { if err := decodeJSON(r, &body); err != nil { return err } } in.With = body.With } return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil) } } func (s *Server) zoneFile(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { 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"` Local *bool `json:"local,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, Local: in.Local}) 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} }) }