package server import ( "encoding/json" "net/http" "slices" "sort" "strconv" "strings" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/catalog" "jarl/internal/dnsname" "jarl/internal/engine" "jarl/internal/iprange" "jarl/internal/model" "jarl/internal/render" "jarl/internal/rr" ) // state returns the configuration a request works on: the current one, // or, when the request names an open changeset (?changeset= or the // X-Jarl-Changeset header), the configuration as it will be after that // changeset, so staged objects can be referred to. func (s *Server) state(r *http.Request) (*model.State, error) { st, _, err := s.states(r) return st, err } // states is like state but also returns the current configuration when a // changeset is involved (base is nil otherwise). func (s *Server) states(r *http.Request) (work, base *model.State, err error) { if csID, _ := changesetTarget(r); csID > 0 { if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok { if w, b, err := s.eng.WorkingState(r.Context(), csID, p); err == nil { return w, b, nil } } } st, err := s.store.LoadState(r.Context()) return st, nil, err } // ---- views func (s *Server) listViews(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } colors, err := s.store.ViewColors(r.Context()) if err != nil { return err } out := []api.View{} for _, v := range st.OrderedViews() { if p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) { out = append(out, presentView(st, v, colors)) } } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) viewByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.View, error) { ref := r.PathValue("id") var v *model.View if id, err := strconv.ParseInt(ref, 10, 64); err == nil { v = st.Views[id] } else { v = st.ViewByName(ref) } if v == nil || !p.Can(auth.Action{Resource: auth.ResViews, View: v.Name}) { return nil, notFound("view %s not found", ref) } return v, nil } func (s *Server) getView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } v, err := s.viewByPath(r, st, p) if err != nil { return err } colors, err := s.store.ViewColors(r.Context()) if err != nil { return err } writeJSON(w, http.StatusOK, presentView(st, v, colors)) return nil } // viewKey: GET /views/{id}/key is the TSIG key that selects the view // (render/viewkeys.go), with its secret: like revealing a TSIG key's, it // needs tsig:write and is audited. func (s *Server) viewKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } v, err := s.viewByPath(r, st, p) if err != nil { return err } if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { return forbidden("showing a view's key needs tsig:write") } seed, err := engine.ViewKeySeed(r.Context(), s.store) if err != nil { return err } name := render.ViewKeyName(v.Name) s.eng.Audit(r.Context(), p, "tsig.reveal", "view", v.ID, v.Name, "success", "view key "+name+" shown") writeJSON(w, http.StatusOK, map[string]string{"name": name, "algorithm": render.ViewKeyAlgorithm, "secret": render.ViewKeySecret(seed, v.ID)}) return nil } func (s *Server) viewObject(r *http.Request) func(st *model.State, id int64) any { return func(st *model.State, id int64) any { if v := st.Views[id]; v != nil { colors, _ := s.store.ViewColors(r.Context()) return presentView(st, v, colors) } return nil } } // setViewColor sets the colour the web UI shows a view in. It is not part // of the configuration, so it applies at once, outside changesets. func (s *Server) setViewColor(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } v, err := s.viewByPath(r, st, p) if err != nil { return err } if err := require(p, auth.Action{Resource: auth.ResViews, Write: true, View: v.Name}); err != nil { return err } var in api.ViewColor if err := decodeJSON(r, &in); err != nil { return err } if in.Color != "" && !slices.Contains(model.ViewColors, in.Color) { return badRequest("unknown colour %q: use one of %s, or \"\" for the default", in.Color, strings.Join(model.ViewColors, ", ")) } if err := s.store.SetViewColor(r.Context(), v.ID, in.Color); err != nil { return err } detail := in.Color if detail == "" { detail = "default" } s.eng.Audit(r.Context(), p, "view.color", "view", v.ID, v.Name, "success", detail) colors, err := s.store.ViewColors(r.Context()) if err != nil { return err } writeJSON(w, http.StatusOK, presentView(st, v, colors)) return nil } func (s *Server) createView(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.OpViewCreate, 0, body)}, s.viewObject(r)) } func (s *Server) updateView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } v, err := s.viewByPath(r, st, p) if err != nil { return err } body, err := readBody(r) if err != nil { return err } id := v.ID return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewUpdate, id, body)}, func(st *model.State, _ int64) any { return s.viewObject(r)(st, id) }) } func (s *Server) deleteView(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } v, err := s.viewByPath(r, st, p) if err != nil { return err } data, _ := json.Marshal(engine.DeleteInput{Cascade: queryBool(r, "cascade")}) return s.mutate(w, r, p, "", []engine.Request{request(engine.OpViewDelete, v.ID, data)}, nil) } func (s *Server) reorderViews(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.OpViewReorder, 0, body)}, nil) } // ---- ACLs func (s *Server) listACLs(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil { return err } st, err := s.state(r) if err != nil { return err } out := []api.ACL{} for _, a := range st.SortedACLs() { out = append(out, presentACL(st, a)) } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) aclByPath(r *http.Request, st *model.State) (*model.ACL, error) { ref := r.PathValue("id") var a *model.ACL if id, err := strconv.ParseInt(ref, 10, 64); err == nil { a = st.ACLs[id] } else { a = st.ACLByName(ref) } if a == nil { return nil, notFound("ACL %s not found", ref) } return a, nil } func (s *Server) getACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.Action{Resource: auth.ResACLs}); err != nil { return err } st, err := s.state(r) if err != nil { return err } a, err := s.aclByPath(r, st) if err != nil { return err } writeJSON(w, http.StatusOK, presentACL(st, a)) return nil } func aclObject(st *model.State, id int64) any { if a := st.ACLs[id]; a != nil { return presentACL(st, a) } return nil } func (s *Server) createACL(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.OpACLCreate, 0, body)}, aclObject) } func (s *Server) updateACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } a, err := s.aclByPath(r, st) if err != nil { return err } body, err := readBody(r) if err != nil { return err } id := a.ID return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLUpdate, id, body)}, func(st *model.State, _ int64) any { return aclObject(st, id) }) } func (s *Server) deleteACL(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } a, err := s.aclByPath(r, st) if err != nil { return err } return s.mutate(w, r, p, "", []engine.Request{request(engine.OpACLDelete, a.ID, nil)}, nil) } // ---- TSIG keys func (s *Server) listKeys(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil { return err } st, err := s.state(r) if err != nil { return err } out := []api.TSIGKey{} for _, k := range st.SortedKeys() { out = append(out, presentKey(st, k, false)) } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) keyByPath(r *http.Request, st *model.State) (*model.TSIGKey, error) { ref := r.PathValue("id") var k *model.TSIGKey if id, err := strconv.ParseInt(ref, 10, 64); err == nil { k = st.Keys[id] } else { k = st.KeyByName(ref) } if k == nil { return nil, notFound("TSIG key %s not found", ref) } return k, nil } func (s *Server) getKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.Action{Resource: auth.ResTSIG}); err != nil { return err } st, err := s.state(r) if err != nil { return err } k, err := s.keyByPath(r, st) if err != nil { return err } withSecret := queryBool(r, "secret") if withSecret { if err := require(p, auth.Action{Resource: auth.ResTSIG, Write: true}); err != nil { return forbidden("showing a TSIG secret needs tsig:write") } s.eng.Audit(r.Context(), p, "tsig.reveal", "tsig", k.ID, k.Name, "success", "secret shown") } writeJSON(w, http.StatusOK, presentKey(st, k, withSecret)) return nil } func keyObject(withSecret bool) func(st *model.State, id int64) any { return func(st *model.State, id int64) any { if k := st.Keys[id]; k != nil { return presentKey(st, k, withSecret) } return nil } } func (s *Server) createKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { body, err := readBody(r) if err != nil { return err } // The creator gets to see a generated secret once. return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGCreate, 0, body)}, keyObject(true)) } func (s *Server) updateKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } k, err := s.keyByPath(r, st) if err != nil { return err } body, err := readBody(r) if err != nil { return err } id := k.ID return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGUpdate, id, body)}, func(st *model.State, _ int64) any { return keyObject(false)(st, id) }) } func (s *Server) deleteKey(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } k, err := s.keyByPath(r, st) if err != nil { return err } return s.mutate(w, r, p, "", []engine.Request{request(engine.OpTSIGDelete, k.ID, nil)}, nil) } // ---- options func (s *Server) listOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } scope := r.URL.Query().Get("scope") scopeID := int64(queryInt(r, "scope_id", 0)) out := []api.Option{} var list []*model.Option if scope != "" { list = st.ScopedOptions(scope, scopeID) } else { for _, sc := range []string{model.ScopeTopLevel, model.ScopeOptions} { list = append(list, st.ScopedOptions(sc, 0)...) } for _, v := range st.OrderedViews() { list = append(list, st.ScopedOptions(model.ScopeView, v.ID)...) } for _, z := range st.SortedZones() { list = append(list, st.ScopedOptions(model.ScopeZone, z.ID)...) } } for _, o := range list { if canReadOption(p, st, o) { out = append(out, presentOption(st, o)) } } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) optionByPath(r *http.Request, st *model.State, p *auth.Principal) (*model.Option, error) { id, err := pathID(r, "id") if err != nil { return nil, err } o := st.Options[id] if o == nil || !canReadOption(p, st, o) { return nil, notFound("option %d not found", id) } return o, nil } func (s *Server) getOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } o, err := s.optionByPath(r, st, p) if err != nil { return err } writeJSON(w, http.StatusOK, presentOption(st, o)) return nil } func optionObject(st *model.State, id int64) any { if o := st.Options[id]; o != nil { return presentOption(st, o) } return nil } func (s *Server) createOption(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.OpOptionCreate, 0, body)}, optionObject) } func (s *Server) updateOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } o, err := s.optionByPath(r, st, p) if err != nil { return err } body, err := readBody(r) if err != nil { return err } id := o.ID return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionUpdate, id, body)}, func(st *model.State, _ int64) any { return optionObject(st, id) }) } func (s *Server) deleteOption(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } o, err := s.optionByPath(r, st, p) if err != nil { return err } return s.mutate(w, r, p, "", []engine.Request{request(engine.OpOptionDelete, o.ID, nil)}, nil) } // ---- settings func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { st, err := s.state(r) if err != nil { return err } writeJSON(w, http.StatusOK, st.Settings) return nil } func (s *Server) updateSettings(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, "update settings", []engine.Request{request(engine.OpSettingsUpdate, 0, body)}, func(st *model.State, _ int64) any { return st.Settings }) } // ---- catalogs func (s *Server) catalogOptions(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { cat := catalog.Default() scope := r.URL.Query().Get("scope") if scope == "" { writeJSON(w, http.StatusOK, map[string]any{"scopes": cat.Scopes(), "zone_types": cat.ZoneTypes()}) return nil } list := cat.List(scope) if list == nil { return notFound("unknown scope %q (use one of %s)", scope, strings.Join(cat.Scopes(), ", ")) } out := []*catalog.Statement{} for _, st := range list { if !st.Managed || queryBool(r, "all") { out = append(out, st) } } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) catalogRRTypes(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { writeJSON(w, http.StatusOK, rr.Types()) return nil } // catalogRanges: GET /catalog/ranges?r=A-B,C-D shows the prefixes each // address range is written to named.conf as (the web UI shows them while // you type). Text that isn't a range is left out; a range with a mistake // comes back with its error. func (s *Server) catalogRanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { type rangeInfo struct { Input string `json:"input"` Range string `json:"range,omitempty"` Prefixes []string `json:"prefixes,omitempty"` Size string `json:"size,omitempty"` Error string `json:"error,omitempty"` } out := []rangeInfo{} for _, in := range strings.Split(r.URL.Query().Get("r"), ",") { in = strings.TrimSpace(in) if in == "" || len(out) >= 100 { continue } rg, ok, err := iprange.Parse(strings.TrimSpace(strings.TrimPrefix(in, "!"))) switch { case !ok: continue case err != nil: out = append(out, rangeInfo{Input: in, Error: err.Error()}) default: ri := rangeInfo{Input: in, Range: rg.String(), Size: rg.SizeText()} for _, pf := range rg.Prefixes() { ri.Prefixes = append(ri.Prefixes, pf.String()) } out = append(out, ri) } } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) catalogGrants(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { roles := map[string][]string{} for name, gs := range auth.RoleTemplates { for _, g := range gs { roles[name] = append(roles[name], auth.FormatGrant(g)) } } writeJSON(w, http.StatusOK, map[string]any{"resources": auth.Resources, "access": []string{auth.Read, auth.Write}, "roles": roles, "syntax": "resource:access [zones=a,b] [views=x] [names=pattern] [types=TXT,CNAME]"}) return nil } // ---- search func (s *Server) search(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) if len(q) < 2 { return badRequest("search for at least 2 characters") } st, err := s.state(r) if err != nil { return err } limit := queryInt(r, "limit", 200) out := []api.SearchResult{} add := func(res api.SearchResult) bool { out = append(out, res) return len(out) >= limit } for _, z := range st.SortedZones() { if strings.Contains(z.Name, q) && p.CanSeeZone(z.Name, st.ViewName(z)) { if add(api.SearchResult{Kind: "zone", ID: z.ID, ZoneID: z.ID, Label: engine.ZoneLabel(st, z), Detail: z.Type}) { break } } } var recs []*model.Record for _, r := range st.Records { if strings.Contains(r.Name, q) || strings.Contains(strings.ToLower(r.Data), q) || strings.Contains(strings.ToLower(r.Comment), q) { recs = append(recs, r) } } sort.Slice(recs, func(i, j int) bool { if recs[i].Name != recs[j].Name { return model.CanonicalLess(recs[i].Name, recs[j].Name) } return recs[i].ID < recs[j].ID }) for _, rec := range recs { if len(out) >= limit { break } if !canReadRecord(p, st, rec) { continue } z := st.Zones[rec.ZoneID] add(api.SearchResult{Kind: "record", ID: rec.ID, ZoneID: rec.ZoneID, Label: dnsname.Display(rec.Name) + " " + rec.Type + " " + rec.Data, Detail: engine.ZoneLabel(st, z)}) } if p.Can(auth.Action{Resource: auth.ResACLs}) { for _, a := range st.SortedACLs() { if strings.Contains(strings.ToLower(a.Name), q) || strings.Contains(strings.ToLower(strings.Join(a.Elements, " ")), q) { add(api.SearchResult{Kind: "acl", ID: a.ID, Label: "ACL " + a.Name, Detail: strings.Join(a.Elements, "; ")}) } } } writeJSON(w, http.StatusOK, out) return nil }