package server import ( "encoding/json" "errors" "net/http" "net/netip" "sort" "strings" udiff "github.com/aymanbagabas/go-udiff" "housecarl/internal/api" "housecarl/internal/auth" "housecarl/internal/engine" "housecarl/internal/matrix" "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) out := s.mutation(res, objectOf(&op, id)) out.ConfirmToken = s.issueCheck(w, r, p, res) writeJSON(w, s.mutationStatus(res, op.Op == model.OpCreate), out) 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, p *auth.Principal, res *engine.Result, object func(st *model.State) any, creates bool) { out := s.mutation(res, object) out.ConfirmToken = s.issueCheck(w, r, p, res) writeJSON(w, s.mutationStatus(res, creates), out) } // 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 "" } // renameRefs makes every object that refers to a zone, set or service by // its old name use the new one: renaming it changes them in the same // change, so the history shows (and undoes) them together. func renameRefs(st *model.State, kind, old, now string) { fix := func(list []string) { for i, x := range list { if strings.EqualFold(x, old) { list[i] = now } } } match := func(m *model.Match) { switch kind { case model.KindZone: fix(m.InZones) fix(m.OutZones) case model.KindSet: fix(m.Sources) fix(m.Destinations) case model.KindService: fix(m.Services) } } for _, x := range st.Rules { match(&x.Match) } for _, x := range st.SNATs { match(&x.Match) } for _, x := range st.Redirects { match(&x.Match) } if kind == model.KindSet { for _, f := range st.Forwards { fix(f.Sources) fix(f.PublicAddresses) fix(f.HairpinSources) } } } // 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 zone matrix // zoneMatrix answers what the rules do to new connections between zones // (internal/matrix); with a changeset named, as it will make them. func (s *Server) zoneMatrix(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, matrix.Build(st, matrix.Options{Connected: s.connected(st)})) return nil } // connected maps the networks on the firewall's interfaces (link-local // and loopback aside) to their zones. func (s *Server) connected(st *model.State) map[netip.Prefix]string { out := map[netip.Prefix]string{} for _, i := range s.hostInterfaces(st) { if i.Zone == "" { continue } for _, a := range i.Addresses { p, err := netip.ParsePrefix(a) if err != nil || p.Addr().IsLinkLocalUnicast() || p.Addr().IsLoopback() { continue } out[p.Masked()] = i.Zone } } return out } // ---- 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 } id, err := pathID(r, "id") if err != nil { return err } // Staged, an undo is an operation of its own, reviewed like any. if csID, err := changesetParam(r); err != nil { return err } else if csID != 0 { 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 } if h.RevertedBy != 0 { return conflict("history entry %d was already undone by entry %d", id, h.RevertedBy) } data, _ := json.Marshal(h) return s.stage(w, r, p, csID, model.Op{Op: model.OpRevert, ID: id, Data: data}) } 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, p, 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 } var in api.ConfirmInput if r.ContentLength != 0 { if err := decodeJSON(r, &in); err != nil { return err } } confirm := func() (int, error) { return s.confirm(r.Context(), p) } if in.Token != "" { confirm = func() (int, error) { return s.confirmToken(r, p, in.Token) } } n, err := confirm() 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 }