Files
jarl/internal/server/h_config.go
T
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
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>
2026-10-11 23:17:14 +01:00

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
}