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:
commit
bd6b8778e4
122 files changed
+29217
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user