Files
housecarl/internal/server/h_health.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

71 lines
2.0 KiB
Go

package server
import (
"net/http"
"sync"
"time"
"housecarl/internal/version"
)
// fallbackInForce is what status and /healthz say while the boot fallback
// (render.Fallback) is in force.
const fallbackInForce = "the boot fallback is in force, not Housecarl's ruleset: the saved ruleset failed to load at boot (journalctl -u housecarl-ruleset), and housecarld couldn't apply it since; it lets nothing new in from external zones (with none, nothing but loopback)"
// health is GET /healthz, for monitoring (Checkmk, a load balancer). It
// needs no authentication, so it says only what a monitor needs, and reads
// the ruleset at most every few seconds however often it is asked.
type health struct {
OK bool `json:"ok"`
Version string `json:"version"`
Firewall string `json:"firewall"` // applied, unconfigured, fallback, missing
// Probation: changes waiting to be confirmed, rolled back otherwise.
Probation int `json:"probation"`
// Warnings are the codes of the machine's problems (nftables_service).
Warnings []string `json:"warnings"`
}
const healthTTL = 5 * time.Second
type healthCache struct {
mu sync.Mutex
at time.Time
out health
}
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
c := &s.health
c.mu.Lock()
if time.Since(c.at) >= healthTTL {
c.out, c.at = s.checkHealth(r), time.Now()
}
out := c.out
c.mu.Unlock()
code := http.StatusOK
if !out.OK {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, out)
}
func (s *Server) checkHealth(r *http.Request) health {
out := health{Version: version.String(), Warnings: []string{}}
t, err := s.fw.Table(r.Context())
switch {
case err != nil && s.eng.State().Empty():
out.Firewall, out.OK = "unconfigured", true
case err != nil:
out.Firewall = "missing"
case t.Fallback:
out.Firewall = "fallback"
default:
out.Firewall, out.OK = "applied", true
}
pr := s.probationStatus()
out.Probation = len(pr.Pending) + pr.Takeovers
for _, p := range s.hostProblems(r.Context(), nil) {
out.Warnings = append(out.Warnings, p.Code)
}
return out
}