Files
housecarl/internal/server/h_status.go
T
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00

327 lines
9.6 KiB
Go

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
}