Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
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, engine.KindRisk:
|
|
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
|
|
}
|