Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
5.2 KiB
Go
199 lines
5.2 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/engine"
|
|
"housecarl/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(kind string) int {
|
|
switch kind {
|
|
case engine.KindBadRequest:
|
|
return http.StatusBadRequest
|
|
case engine.KindNotFound:
|
|
return http.StatusNotFound
|
|
case engine.KindConflict:
|
|
return http.StatusConflict
|
|
case engine.KindValidation, engine.KindFirewall:
|
|
return http.StatusUnprocessableEntity
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// writeErr writes an error response. result, if given, is the preview of
|
|
// a refused change.
|
|
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
|
var oe *opError
|
|
if errors.As(err, &oe) {
|
|
info, status := oe.info()
|
|
writeJSON(w, status, api.ErrorBody{Error: info})
|
|
return
|
|
}
|
|
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: ee.Kind, Message: ee.Message, Problems: ee.Problems}}
|
|
if result != nil {
|
|
body.Error.Result, _ = json.Marshal(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 body into v, refusing 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 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("the request body is empty")
|
|
}
|
|
var mbe *http.MaxBytesError
|
|
if errors.As(err, &mbe) {
|
|
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"}
|
|
}
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeOptional is decodeJSON, but an empty body leaves v alone.
|
|
func decodeOptional(r *http.Request, v any) error {
|
|
b, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(string(b)) == "" {
|
|
return nil
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(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
|
|
}
|
|
|
|
func require(p *auth.Principal, resource string, write bool) error {
|
|
if p.Can(resource, write) {
|
|
return nil
|
|
}
|
|
access := "read"
|
|
if write {
|
|
access = "write"
|
|
}
|
|
return forbidden("permission denied: this needs %s:%s", resource, access)
|
|
}
|
|
|
|
func requireAdmin(p *auth.Principal) error {
|
|
if !p.IsAdmin() {
|
|
return forbidden("this needs an administrator (and not a restricted token)")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// via names where a request came from, for the history.
|
|
func via(r *http.Request, p *auth.Principal) string {
|
|
switch {
|
|
case strings.Contains(r.UserAgent(), "housecarl-cli"):
|
|
return "cli"
|
|
case p.Method == "session":
|
|
return "web"
|
|
}
|
|
return "api"
|
|
}
|