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>
519 lines
13 KiB
Go
519 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
udiff "github.com/aymanbagabas/go-udiff"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/engine"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/store"
|
|
)
|
|
|
|
// change makes an operation: at once through the engine, or, with a
|
|
// changeset named (?changeset=ID or X-Housecarl-Changeset), staged in it.
|
|
// A network change starts (or joins) the probation.
|
|
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, op model.Op) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
if op.Op == model.OpCreate && op.ID == 0 {
|
|
ids, err := s.store.AllocateIDs(r.Context(), 1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
op.ID = ids[0]
|
|
}
|
|
csID, err := changesetParam(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if csID != 0 {
|
|
return s.stage(w, r, p, csID, op)
|
|
}
|
|
var id int64
|
|
res, err := s.eng.Apply(r.Context(), p, "", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, func(st *model.State, _ func() int64) error {
|
|
var err error
|
|
id, err = applyOp(st, &op)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), s.mutation(res, objectOf(&op, id)))
|
|
return nil
|
|
}
|
|
|
|
// mutation is the answer to a change: what changed, warnings, the object
|
|
// (as it is after the change), the probation, and for a dry run the
|
|
// ruleset's diff.
|
|
func (s *Server) mutation(res *engine.Result, object func(st *model.State) any) api.MutationResponse {
|
|
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
|
if out.Changes == nil {
|
|
out.Changes = []model.Change{}
|
|
}
|
|
if pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
|
|
out.ConfirmBy = &pr.Deadline
|
|
}
|
|
if object != nil && res.After != nil && (res.Applied || res.DryRun) {
|
|
if v := object(res.After); v != nil {
|
|
out.Object, _ = json.Marshal(v)
|
|
}
|
|
}
|
|
if res.DryRun && len(res.Changes) > 0 {
|
|
out.Diff = s.rulesetDiff(res.Before, res.After)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mutationStatus is 201 for an applied create, else 200.
|
|
func (s *Server) mutationStatus(res *engine.Result, creates bool) int {
|
|
if res.Applied && creates {
|
|
return http.StatusCreated
|
|
}
|
|
return http.StatusOK
|
|
}
|
|
|
|
// respondChange answers a change made without an operation (undo).
|
|
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
|
|
writeJSON(w, s.mutationStatus(res, creates), s.mutation(res, object))
|
|
}
|
|
|
|
// rulesetDiff is the unified diff of the rulesets two configurations
|
|
// make: the file housecarl-ruleset.service loads, now and after.
|
|
func (s *Server) rulesetDiff(before, after *model.State) string {
|
|
if before == nil || after == nil {
|
|
return ""
|
|
}
|
|
return udiff.Unified("ruleset.nft (now)", "ruleset.nft (after)", s.fw.Render(before).Text, s.fw.Render(after).Text)
|
|
}
|
|
|
|
// kindOf maps the API's plural path names to kinds.
|
|
var kindOf = map[string]string{
|
|
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
|
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
|
}
|
|
|
|
func pathKind(r *http.Request) (string, error) {
|
|
k, ok := kindOf[r.PathValue("kind")]
|
|
if !ok {
|
|
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
|
if o := st.Find(kind, ref); o != nil {
|
|
return o, nil
|
|
}
|
|
return nil, notFound("no %s %s", kind, ref)
|
|
}
|
|
|
|
// ---- objects
|
|
|
|
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cur, st, draft, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []any{}
|
|
c := r.URL.Query().Get("chain")
|
|
if draft {
|
|
// Marked new, changed or deleted.
|
|
for _, o := range withPending(cur, st, kind) {
|
|
if c != "" && kind == model.KindRule && o.(map[string]any)["chain"] != c {
|
|
continue
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
} else {
|
|
for _, o := range st.Objects(kind) {
|
|
if c != "" {
|
|
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
|
|
continue
|
|
}
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
// Services: the built-in ones too, unless overridden.
|
|
if kind == model.KindService && !queryBool(r, "own") {
|
|
for _, b := range model.BuiltinServices() {
|
|
if mine := st.Find(kind, b.Name); mine == nil {
|
|
out = append(out, b)
|
|
}
|
|
}
|
|
name := func(x any) string {
|
|
if m, ok := x.(map[string]any); ok {
|
|
n, _ := m["name"].(string)
|
|
return n
|
|
}
|
|
return x.(*model.Service).Name
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return name(out[i]) < name(out[j]) })
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o, err := findObject(st, kind, r.PathValue("ref"))
|
|
if err != nil && kind == model.KindService {
|
|
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
|
|
writeJSON(w, http.StatusOK, b)
|
|
return nil
|
|
}
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, o)
|
|
return nil
|
|
}
|
|
|
|
// decodeObject reads a kind's object from JSON (unknown fields refused).
|
|
func decodeObject(kind string, data []byte) (model.Object, error) {
|
|
var o model.Object
|
|
switch kind {
|
|
case model.KindZone:
|
|
o = &model.Zone{}
|
|
case model.KindSet:
|
|
o = &model.AddressSet{}
|
|
case model.KindService:
|
|
o = &model.Service{}
|
|
case model.KindRule:
|
|
o = &model.Rule{}
|
|
case model.KindForward:
|
|
o = &model.PortForward{}
|
|
case model.KindSNAT:
|
|
o = &model.SourceNAT{}
|
|
case model.KindRedirect:
|
|
o = &model.Redirect{}
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(data)))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(o); err != nil {
|
|
return nil, badRequest("%s: %v", kind, err)
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// lastPosition is after the last rule of the chain (or NAT rule of the
|
|
// kind).
|
|
func lastPosition(st *model.State, kind, chain string) float64 {
|
|
max := 0.0
|
|
for _, o := range st.Objects(kind) {
|
|
var pos float64
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
if x.Chain != chain {
|
|
continue
|
|
}
|
|
pos = x.Position
|
|
case *model.PortForward:
|
|
pos = x.Position
|
|
case *model.SourceNAT:
|
|
pos = x.Position
|
|
case *model.Redirect:
|
|
pos = x.Position
|
|
}
|
|
if pos > max {
|
|
max = pos
|
|
}
|
|
}
|
|
return max + 1
|
|
}
|
|
|
|
func setID(o model.Object, id int64) {
|
|
switch x := o.(type) {
|
|
case *model.Zone:
|
|
x.ID = id
|
|
case *model.AddressSet:
|
|
x.ID = id
|
|
case *model.Service:
|
|
x.ID = id
|
|
case *model.Rule:
|
|
x.ID = id
|
|
case *model.PortForward:
|
|
x.ID = id
|
|
case *model.SourceNAT:
|
|
x.ID = id
|
|
case *model.Redirect:
|
|
x.ID = id
|
|
}
|
|
}
|
|
|
|
func position(o model.Object) *float64 {
|
|
switch x := o.(type) {
|
|
case *model.Rule:
|
|
return &x.Position
|
|
case *model.PortForward:
|
|
return &x.Position
|
|
case *model.SourceNAT:
|
|
return &x.Position
|
|
case *model.Redirect:
|
|
return &x.Position
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chainOf(o model.Object) string {
|
|
if r, ok := o.(*model.Rule); ok {
|
|
return r.Chain
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var raw json.RawMessage
|
|
if err := decodeJSON(r, &raw); err != nil {
|
|
return err
|
|
}
|
|
// Refuse a bad object now, not when it is applied.
|
|
if _, err := decodeObject(kind, raw); err != nil {
|
|
return err
|
|
}
|
|
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
|
}
|
|
|
|
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpUpdate, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.change(w, r, p, model.Op{Op: model.OpDelete, Kind: kind, Ref: r.PathValue("ref")})
|
|
}
|
|
|
|
// usedBy names an object that refers to o (zones, sets and services are
|
|
// referred to by name), or "".
|
|
func usedBy(st *model.State, kind string, o model.Object) string {
|
|
name := o.Label()
|
|
has := func(list []string) bool {
|
|
for _, x := range list {
|
|
if strings.EqualFold(x, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
uses := func(m model.Match) bool {
|
|
switch kind {
|
|
case model.KindZone:
|
|
return has(m.InZones) || has(m.OutZones)
|
|
case model.KindSet:
|
|
return has(m.Sources) || has(m.Destinations)
|
|
case model.KindService:
|
|
return has(m.Services)
|
|
}
|
|
return false
|
|
}
|
|
for _, x := range st.Rules {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.SNATs {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
for _, x := range st.Redirects {
|
|
if uses(x.Match) {
|
|
return x.Label()
|
|
}
|
|
}
|
|
if kind == model.KindSet {
|
|
for _, f := range st.Forwards {
|
|
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
|
|
return f.Label()
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// moveObject reorders a rule or NAT rule (moveOp).
|
|
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
kind, err := pathKind(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.MoveInput
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(in)
|
|
return s.change(w, r, p, model.Op{Op: model.OpMove, Kind: kind, Ref: r.PathValue("ref"), Data: data})
|
|
}
|
|
|
|
// ---- settings
|
|
|
|
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, st.Settings)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
var patch map[string]json.RawMessage
|
|
if err := decodeJSON(r, &patch); err != nil {
|
|
return err
|
|
}
|
|
data, _ := json.Marshal(patch)
|
|
return s.change(w, r, p, model.Op{Op: model.OpSettings, Data: data})
|
|
}
|
|
|
|
// ---- the ruleset
|
|
|
|
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
_, st, _, err := s.viewState(w, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs := s.fw.Render(st)
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Write([]byte(rs.Text))
|
|
return nil
|
|
}
|
|
|
|
// ---- history
|
|
|
|
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, hs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h, err := s.store.GetHistory(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return notFound("history entry %d not found", id)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, h)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
if err := notStaged(r, "an undo"); err != nil {
|
|
return err
|
|
}
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
s.afterChange(r.Context(), p, res)
|
|
s.respondChange(w, r, res, nil, false)
|
|
return nil
|
|
}
|
|
|
|
// ---- probation
|
|
|
|
func (s *Server) getProbation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, s.probationStatus())
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
n, err := s.confirm(r.Context(), p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
ps := s.probationStatus()
|
|
n := len(ps.Pending) + ps.Takeovers
|
|
if n > 0 {
|
|
s.rollback("rolled back by " + p.Name())
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
|
return nil
|
|
}
|