Files
jarl/internal/server/h_config.go
T
abrown38andClaude Opus 5.5 b05711b13e Link views from the zone list, add breadcrumbs and view colours
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>
2026-10-09 14:43:57 +00:00

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
}