ACLs, views' match lists, options with address match lists and blocking client groups take ranges such as 10.0.10.101-10.0.10.199 or 2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too). Jarl stores the range as written and writes the fewest prefixes that cover exactly that range to named.conf, with the range as a comment. New package internal/iprange; namedconf gets an AMRange element (a range used to parse as an ACL name), ExpandRanges and statement comments. Mistakes are refused when entered, validation sees what ranges cover, and IPv6 ranges in the IPv4-mapped space get a warning. The web UI shows what a range becomes while typing (GET /catalog/ranges). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
662 lines
18 KiB
Go
662 lines
18 KiB
Go
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
|
|
}
|