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>
71 lines
2.0 KiB
Go
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
|
|
}
|