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

+183
View File
@@ -0,0 +1,183 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/store"
)
// httpError is an error with an HTTP status.
type httpError struct {
status int
code string
msg string
}
func (e *httpError) Error() string { return e.msg }
func badRequest(format string, args ...any) error {
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
}
func notFound(format string, args ...any) error {
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
}
func forbidden(format string, args ...any) error {
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
}
func conflict(format string, args ...any) error {
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
}
func engineStatus(k engine.ErrorKind) int {
switch k {
case engine.KindBadRequest:
return http.StatusBadRequest
case engine.KindForbidden:
return http.StatusForbidden
case engine.KindNotFound:
return http.StatusNotFound
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted:
return http.StatusConflict
case engine.KindValidation:
return http.StatusUnprocessableEntity
case engine.KindBIND:
return http.StatusBadGateway
}
return http.StatusInternalServerError
}
func raw(v any) json.RawMessage {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return nil
}
if string(b) == "null" {
return nil
}
return b
}
// writeErr writes any error as a JSON error response. result, if given, is
// included (e.g. the preview of a change that failed validation).
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
var he *httpError
if errors.As(err, &he) {
writeError(w, he.status, he.code, he.msg)
return
}
if ee, ok := engine.AsError(err); ok {
body := api.ErrorBody{Error: api.ErrorInfo{Code: string(ee.Kind), Message: ee.Error(), Op: ee.OpSeq, Problems: raw(ee.Problems), Check: raw(ee.Check)}}
if result != nil {
body.Error.Result = raw(result)
}
writeJSON(w, engineStatus(ee.Kind), body)
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "not found")
return
}
if errors.Is(err, store.ErrConflict) {
writeError(w, http.StatusConflict, "conflict", "already exists")
return
}
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
}
// handler is an authenticated API handler.
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
// decodeJSON reads a JSON request body into v, rejecting unknown fields.
func decodeJSON(r *http.Request, v any) error {
ct := r.Header.Get("Content-Type")
if ct != "" && !strings.HasPrefix(ct, "application/json") {
return badRequest("expected a JSON request body (Content-Type: application/json)")
}
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return badRequest("request body is empty")
}
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"}
}
return badRequest("invalid JSON: %v", err)
}
return nil
}
// readBody returns the raw JSON body (may be empty).
func readBody(r *http.Request) (json.RawMessage, error) {
b, err := io.ReadAll(r.Body)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return nil, &httpError{http.StatusRequestEntityTooLarge, "too_large", "request body is too large"}
}
return nil, err
}
if len(strings.TrimSpace(string(b))) == 0 {
return nil, nil
}
if !json.Valid(b) {
return nil, badRequest("invalid JSON body")
}
return b, nil
}
func pathID(r *http.Request, name string) (int64, error) {
v := r.PathValue(name)
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return 0, badRequest("invalid %s %q", name, v)
}
return id, nil
}
func queryBool(r *http.Request, name string) bool {
switch strings.ToLower(r.URL.Query().Get(name)) {
case "1", "true", "yes", "on":
return true
}
return false
}
func queryInt(r *http.Request, name string, def int) int {
if v := r.URL.Query().Get(name); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}