Jarl 0.1.0: manage BIND through a web UI, REST API and CLI

Jarl keeps a BIND 9.20 server's whole configuration (views, ACLs, TSIG
keys, zones of every type, records, named.conf options) in SQLite and
renders named.conf, keys.conf and zone files from it.

Every change goes through one changeset pipeline: whole-configuration
validation, named-checkconf -z and named-checkzone in a staging
directory, atomic install, rndc reconfig/reload, serial verification
with rndc zonestatus, and rollback if named does not load the change.
Changes apply one at a time by default or are bundled and applied
together. Applied changesets can be reverted.

- jarld: the daemon with the REST API and an embedded web UI, plus import,
  adopt, check, resync, backup and local user recovery commands
- jarl: the CLI, including ACME DNS-01 hooks for certbot and acme.sh
- users with argon2id passwords and CSRF-protected sessions; hashed API
  tokens with granular grants and roles; an audit log
- systemd unit, sysusers.d/tmpfiles.d files, Makefile
- unit tests, integration tests against a real named, scripts/e2e.sh
- docs/ guides, README and CHANGELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-07 15:03:13 +00:00
commit bd6b8778e4
122 files changed
+29217

No files matched your search

+549
View File
@@ -0,0 +1,549 @@
package server
import (
"encoding/json"
"net/http"
"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
}
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))
}
}
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
}
writeJSON(w, http.StatusOK, presentView(st, v))
return nil
}
func viewObject(st *model.State, id int64) any {
if v := st.Views[id]; v != nil {
return presentView(st, v)
}
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)}, viewObject)
}
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 viewObject(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
}