package server import ( "encoding/json" "errors" "net/http" "sort" "strings" "housecarl/internal/api" "housecarl/internal/auth" "housecarl/internal/engine" "housecarl/internal/model" "housecarl/internal/store" ) // change applies an edit through the engine and answers it; object picks // what to return from the new state. A network change starts (or joins) // the probation. func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error { if err := require(p, auth.ResConfig, true); err != nil { return err } res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit) if err != nil { s.writeErr(w, r, err, res) return nil } s.afterChange(r.Context(), p, res) // POST to a collection creates; POST to an object (move) changes it. s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "") return nil } // respondChange answers a change: 201 when it created something. func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) { 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.Applied || res.DryRun) { if v := object(s.eng.State()); v != nil { out.Object, _ = json.Marshal(v) } } status := http.StatusOK if res.Applied && creates { status = http.StatusCreated } writeJSON(w, status, out) } // 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 } st := s.eng.State() out := []any{} q := r.URL.Query() for _, o := range st.Objects(kind) { if c := q.Get("chain"); 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) } } sort.SliceStable(out, func(i, j int) bool { return out[i].(*model.Service).Name < out[j].(*model.Service).Name }) } 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 := s.eng.State() 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 } o, err := decodeObject(kind, raw) if err != nil { return err } if svc, ok := o.(*model.Service); ok { svc.Builtin = false } if rule, ok := o.(*model.Rule); ok && rule.Action == "" { rule.Action = model.ActionAccept } if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 { f.Protocols = []string{"tcp"} } var id int64 return s.change(w, r, p, "", func(st *model.State, newID func() int64) error { id = newID() setID(o, id) if pos := position(o); pos != nil && *pos == 0 { *pos = lastPosition(st, kind, chainOf(o)) } data, _ := json.Marshal(o) return st.Put(kind, id, data) }, func(st *model.State) any { return st.ByID(kind, id) }) } 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 } delete(patch, "id") var id int64 return s.change(w, r, p, "", func(st *model.State, _ func() int64) error { o, err := findObject(st, kind, r.PathValue("ref")) if err != nil { return err } id = o.GetID() // The patch's fields over the object's. var cur map[string]json.RawMessage b, _ := json.Marshal(o) json.Unmarshal(b, &cur) for k, v := range patch { if string(v) == "null" { delete(cur, k) } else { cur[k] = v } } merged, _ := json.Marshal(cur) next, err := decodeObject(kind, merged) if err != nil { return err } setID(next, id) data, _ := json.Marshal(next) return st.Put(kind, id, data) }, func(st *model.State) any { return st.ByID(kind, id) }) } 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, "", func(st *model.State, _ func() int64) error { o, err := findObject(st, kind, r.PathValue("ref")) if err != nil { return err } if used := usedBy(st, kind, o); used != "" { return conflict("%s %s is used by %s; change that first", kind, o.Label(), used) } return st.Put(kind, o.GetID(), nil) }, nil) } // 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: before or after another, or to // the start or end; a rule can change chain at the same time. 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 } var id int64 return s.change(w, r, p, "", func(st *model.State, _ func() int64) error { o, err := findObject(st, kind, r.PathValue("ref")) if err != nil { return err } id = o.GetID() pos := position(o) if pos == nil { return badRequest("only rules, port forwards, source NAT rules and redirects have an order") } if rule, ok := o.(*model.Rule); ok && in.Chain != "" { rule.Chain = in.Chain } chain := chainOf(o) // The others in order. var others []model.Object for _, x := range st.Objects(kind) { if x.GetID() != id && chainOf(x) == chain { others = append(others, x) } } sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) }) place := func(i int) { // before others[i] (i == len: at the end) switch { case len(others) == 0: *pos = 1 case i == 0: *pos = *position(others[0]) - 1 case i == len(others): *pos = *position(others[len(others)-1]) + 1 default: *pos = (*position(others[i-1]) + *position(others[i])) / 2 } } index := func(ref int64) (int, error) { for i, x := range others { if x.GetID() == ref { return i, nil } } return 0, badRequest("%d isn't another %s of the same chain", ref, kind) } switch { case in.Before != 0: i, err := index(in.Before) if err != nil { return err } place(i) case in.After != 0: i, err := index(in.After) if err != nil { return err } place(i + 1) case in.To == "start": place(0) case in.To == "end" || in.Chain != "": place(len(others)) default: return badRequest("say where: before, after, or to start or end") } data, _ := json.Marshal(o) return st.Put(kind, id, data) }, func(st *model.State) any { return st.ByID(kind, id) }) } // ---- 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 } writeJSON(w, http.StatusOK, s.eng.State().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 } return s.change(w, r, p, "", func(st *model.State, _ func() int64) error { var cur map[string]json.RawMessage b, _ := json.Marshal(st.Settings) json.Unmarshal(b, &cur) for k, v := range patch { // Built-ins merge one by one. if k == "builtins" { var cb, pb map[string]json.RawMessage json.Unmarshal(cur[k], &cb) if err := json.Unmarshal(v, &pb); err != nil { return badRequest("builtins: %v", err) } for bk, bv := range pb { cb[bk] = bv } v, _ = json.Marshal(cb) } cur[k] = v } merged, _ := json.Marshal(cur) var next model.Settings dec := json.NewDecoder(strings.NewReader(string(merged))) dec.DisallowUnknownFields() if err := dec.Decode(&next); err != nil { return badRequest("settings: %v", err) } st.Settings = next return nil }, func(st *model.State) any { return st.Settings }) } // ---- 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 } rs := s.fw.Render(s.eng.State()) 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 } 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 }