Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -0,0 +1,192 @@
|
||||
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 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"
|
||||
}
|
||||
Reference in new issue
Block a user