package server import ( "context" "fmt" "net/http" "os" "sort" "strings" "sync" "time" "housecarl/internal/api" "housecarl/internal/auth" "housecarl/internal/model" "housecarl/internal/render" "housecarl/internal/store" "housecarl/internal/validate" "housecarl/internal/version" ) // ---- status func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLog, false) { return forbidden("permission denied: this needs config:read or log:read") } st := s.eng.State() out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(), Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}} if out.Problems == nil { out.Problems = []validate.Problem{} } if p.IsAdmin() { out.Database = s.store.Path() } for _, k := range model.Kinds { out.Counts[k] = len(st.Objects(k)) } if t, err := s.fw.Table(r.Context()); err != nil && st.Empty() { out.Unconfigured = true } else if err != nil { out.ApplyError = err.Error() } else if t.Fallback { out.ApplyError = fallbackInForce } else { out.Applied = true } if data, err := s.fw.ListRuleset(r.Context()); err == nil { out.OtherTables = otherTables(data) } out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...) out.Problems = append(out.Problems, s.statsProblems()...) writeJSON(w, http.StatusOK, out) return nil } const importedTableKey = "imported_table" // nftablesUnitLinks are where an enabled nftables.service shows (Debian // enables it for sysinit.target). var nftablesUnitLinks = []string{ "/etc/systemd/system/sysinit.target.wants/nftables.service", "/etc/systemd/system/multi-user.target.wants/nftables.service", } // hostProblems are warnings about the machine rather than the // configuration: another ruleset that still loads at boot, or the table // an import came from, still in force beside Housecarl's. func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem { var out []validate.Problem for _, link := range s.unitLinks { if _, err := os.Lstat(link); err == nil { out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service", Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"}) break } } if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" { for _, o := range others { if o == t { out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t, Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)}) } } } return out } // hostInterfaces lists the machine's interfaces with their zones. func (s *Server) hostInterfaces(st *model.State) []api.HostInterface { out := []api.HostInterface{} if s.ifaces == nil { return out } all, err := s.ifaces.All() if err != nil { return out } for name, ps := range all { hi := api.HostInterface{Name: name, Addresses: []string{}, Up: s.ifaces.Up(name), Zone: zoneOf(st, name)} for _, a := range ps { hi.Addresses = append(hi.Addresses, a.String()) } out = append(out, hi) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // zoneOf names the zone an interface is in: an exact name, else the // longest matching wildcard. func zoneOf(st *model.State, name string) string { best, bestLen := "", -1 for _, z := range st.Zones { for _, i := range z.Interfaces { if i == name { return z.Name } if p, ok := strings.CutSuffix(i, "*"); ok && strings.HasPrefix(name, p) && len(p) > bestLen { best, bestLen = z.Name, len(p) } } } return best } func (s *Server) interfaces(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.hostInterfaces(s.eng.State())) return nil } func (s *Server) counters(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.ResLog, false); err != nil { return err } t, err := s.fw.Table(r.Context()) if err != nil && s.eng.State().Empty() { // Nothing applied yet: nothing counted. writeJSON(w, http.StatusOK, api.Counters{Since: s.started, Counters: map[string]api.Counter{}}) return nil } if err != nil { return err } out := api.Counters{Since: t.Since, Counters: map[string]api.Counter{}} for k, c := range t.Counters { out.Counters[k] = api.Counter{Packets: c.Packets, Bytes: c.Bytes} } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.ResAudit, false); err != nil { return err } q := r.URL.Query() evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"), Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)}) if err != nil { return err } writeJSON(w, http.StatusOK, evs) return nil } func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { roles := map[string][]string{} for n, gs := range auth.Roles { roles[n] = grantStrings(gs) } writeJSON(w, http.StatusOK, map[string]any{ "services": model.BuiltinServices(), "chains": model.Chains, "actions": model.Actions, "roles": roles, "log_group": render.LogGroup, }) return nil } // ---- dynamic sets' entries func (s *Server) dynamicSet(r *http.Request, p *auth.Principal, write bool) (*model.AddressSet, error) { name := r.PathValue("ref") if !p.CanSet(name, write) { access := "read" if write { access = "write" } return nil, forbidden("permission denied: this needs sets:%s for %s", access, name) } set := s.eng.State().Set(name) if set == nil { return nil, notFound("no set %s", name) } if !set.Dynamic { return nil, badRequest("set %s isn't dynamic: change its entries with PATCH /sets/%s", set.Name, set.Name) } return set, nil } func (s *Server) listElements(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { set, err := s.dynamicSet(r, p, false) if err != nil { return err } t, err := s.fw.Table(r.Context()) if err != nil { return err } out := []api.Element{} for _, e := range t.Elements[set.Name] { out = append(out, api.Element{Address: e.Addr, Timeout: e.Timeout}) } writeJSON(w, http.StatusOK, out) return nil } func (s *Server) addElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { set, err := s.dynamicSet(r, p, true) if err != nil { return err } var in api.Element if err := decodeJSON(r, &in); err != nil { return err } a, err := model.ParseAddress(in.Address) if err != nil || !a.Prefix.IsSingleIP() { return badRequest("a dynamic set takes single addresses (%q)", in.Address) } if in.Timeout < 0 || in.Timeout > 366*24*3600 { return badRequest("the timeout is in seconds, up to a year") } if err := s.fw.AddElement(r.Context(), set.Name, a.String(), in.Timeout); err != nil { return err } s.eng.Audit(r.Context(), p, "set.add", set.Name+" "+a.String(), "success", "") writeJSON(w, http.StatusCreated, api.Element{Address: a.String(), Timeout: in.Timeout}) return nil } func (s *Server) deleteElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { set, err := s.dynamicSet(r, p, true) if err != nil { return err } a, err := model.ParseAddress(r.PathValue("addr")) if err != nil { return badRequest("%v", err) } if err := s.fw.DeleteElement(r.Context(), set.Name, a.String()); err != nil { return notFound("%s isn't in set %s", a, set.Name) } s.eng.Audit(r.Context(), p, "set.delete", set.Name+" "+a.String(), "success", "") w.WriteHeader(http.StatusNoContent) return nil } // ---- the log // logRing keeps the latest logged packets. type logRing struct { mu sync.Mutex max int list []api.LogEntry } func newLogRing(max int) *logRing { return &logRing{max: max} } // Add records a logged packet. func (l *logRing) Add(e api.LogEntry) { l.mu.Lock() defer l.mu.Unlock() l.list = append(l.list, e) if len(l.list) > l.max { l.list = append([]api.LogEntry(nil), l.list[len(l.list)-l.max:]...) } } // Latest returns up to n entries, newest first, after a time. func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) []api.LogEntry { l.mu.Lock() defer l.mu.Unlock() out := []api.LogEntry{} for i := len(l.list) - 1; i >= 0 && (n <= 0 || len(out) < n); i-- { e := l.list[i] if !e.Time.After(after) { break } if keep == nil || keep(e) { out = append(out, e) } } return out } // LogPacket records a packet the firewall logged (internal/nflog calls // it). func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) if s.stats != nil { s.stats.Packet(e) } } func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := require(p, auth.ResLog, false); err != nil { return err } q := r.URL.Query() var after time.Time if v := q.Get("after"); v != "" { after, _ = time.Parse(time.RFC3339Nano, v) } search := strings.ToLower(q.Get("q")) writeJSON(w, http.StatusOK, s.logs.Latest(queryInt(r, "limit", 200), after, func(e api.LogEntry) bool { if search == "" { return true } return strings.Contains(strings.ToLower(e.Prefix+" "+e.Source+" "+e.Dest+" "+e.In+" "+e.Out+" "+e.Protocol), search) })) return nil }