Files
adamandClaude Opus 5.5 57012fece8 One-view tokens: local records in linked zones, zone lookup, view list
A token for one view of a linked zone (an ACME token for external)
couldn't add records: a new record was shared and reached the other
view. A new name and type is now local when the user may not change it
in the other views. A zone name in several views resolves to the only
one the user can see, and the error no longer names views they can't.
GET /views lists the views a user sees zones in (name, colour, count;
limited) without views:read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:57:06 +01:00

643 lines
18 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"), 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}
})
}