Zones get a DNSSEC setting: named signs them with BIND's default (or a custom) dnssec-policy and inline signing, with keys in a per-view key directory. Jarl shows the keys, the DS record and when it is safe to publish it, checks the parent's DS records (check-ds, which also tells named), and refuses steps that would break a signed domain: switching straight to unsigned, finishing a turn-off too early, deleting or moving a signed zone, TTLs above max-zone-ttl. Turning off goes through the insecure policy; wipe starts over once the parent has no DS. Backups store the DNSSEC keys and restore puts them back for named; the backup unit gets CAP_DAC_READ_SEARCH to read them. Jarl raises a zone's serial above named's whenever it writes an older one (after a restore). The serials check compares the signed serial. With a Porkbun API key in jarl.yaml, the registrar's name servers, glue records and DS records can be shown and changed, each change checked first (dry run, force to override). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
5.3 KiB
Go
200 lines
5.3 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"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
|
|
}
|
|
|
|
// decodeOptional reads a JSON body into v like decodeJSON, but an empty
|
|
// body leaves v as it is.
|
|
func decodeOptional(r *http.Request, v any) error {
|
|
b, err := readBody(r)
|
|
if err != nil || b == nil {
|
|
return err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(b))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
return 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
|
|
}
|