The view shown in the zone list and on the zone page now links to that
view. The zone page and changeset page get breadcrumbs, and the zone list
keeps its filters in the address so going back restores them. Views can
be given a colour (Views > Edit, `jarl view color`, PUT /views/{id}/color)
so zones of the same view are easy to spot; the colour is UI-only, kept
outside changesets, and needs no deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
16 KiB
Go
599 lines
16 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/model"
|
|
"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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|