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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 23:16:46 +01:00
1 parent 835a9ba2cc
commit 9d766b9823
38 files changed
+1335 -84

No files matched your search

+41
View File
@@ -381,6 +381,47 @@ func usedBy(st *model.State, kind string, o model.Object) string {
return ""
}
// renameRefs makes every object that refers to a zone, set or service by
// its old name use the new one: renaming it changes them in the same
// change, so the history shows (and undoes) them together.
func renameRefs(st *model.State, kind, old, now string) {
fix := func(list []string) {
for i, x := range list {
if strings.EqualFold(x, old) {
list[i] = now
}
}
}
match := func(m *model.Match) {
switch kind {
case model.KindZone:
fix(m.InZones)
fix(m.OutZones)
case model.KindSet:
fix(m.Sources)
fix(m.Destinations)
case model.KindService:
fix(m.Services)
}
}
for _, x := range st.Rules {
match(&x.Match)
}
for _, x := range st.SNATs {
match(&x.Match)
}
for _, x := range st.Redirects {
match(&x.Match)
}
if kind == model.KindSet {
for _, f := range st.Forwards {
fix(f.Sources)
fix(f.PublicAddresses)
fix(f.HairpinSources)
}
}
}
// moveObject reorders a rule or NAT rule (moveOp).
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
+70
View File
@@ -0,0 +1,70 @@
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
}
+65
View File
@@ -0,0 +1,65 @@
package server
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"housecarl/internal/api"
)
func TestHealthz(t *testing.T) {
e := newEnv(t)
link := filepath.Join(t.TempDir(), "nftables.service")
e.srv.unitLinks = []string{link}
get := func() (int, health) {
t.Helper()
e.srv.health.at = time.Time{} // no caching between the steps
resp, err := http.Get(e.ts.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var h health
json.NewDecoder(resp.Body).Decode(&h)
return resp.StatusCode, h
}
// Nothing configured yet: nothing to be in force.
if code, h := get(); code != 200 || !h.OK || h.Firewall != "unconfigured" || h.Version == "" {
t.Errorf("unconfigured: %d %+v", code, h)
}
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true}, nil); code != 201 {
t.Fatalf("creating a zone: %d", code)
}
if code, h := get(); code != 200 || h.Firewall != "applied" || h.Probation != 1 || len(h.Warnings) != 0 {
t.Errorf("applied, on probation: %d %+v", code, h)
}
os.WriteFile(link, nil, 0o644)
if _, h := get(); strings.Join(h.Warnings, ",") != "nftables_service" {
t.Errorf("warnings %v", h.Warnings)
}
e.fw.mu.Lock()
e.fw.fallback = true
e.fw.mu.Unlock()
if code, h := get(); code != 503 || h.OK || h.Firewall != "fallback" {
t.Errorf("fallback: %d %+v", code, h)
}
var st api.Status
e.call(e.token, "GET", "/status", nil, &st)
if st.Applied || !strings.Contains(st.ApplyError, "boot fallback") {
t.Errorf("status with the fallback: applied %v, %q", st.Applied, st.ApplyError)
}
// Asked often, the ruleset is read once per few seconds.
e.fw.mu.Lock()
e.fw.fallback = false
e.fw.mu.Unlock()
resp, _ := http.Get(e.ts.URL + "/healthz")
resp.Body.Close()
if resp.StatusCode != 503 {
t.Errorf("not cached: %d", resp.StatusCode)
}
}
+3 -1
View File
@@ -37,10 +37,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
for _, k := range model.Kinds {
out.Counts[k] = len(st.Objects(k))
}
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
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
}
+11
View File
@@ -94,10 +94,21 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
return 0, err
}
setID(next, id)
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
}
renameRefs(st, op.Kind, old, now)
}
data, _ := json.Marshal(next)
return id, st.Put(op.Kind, id, data)
}
// isNamed reports whether other objects refer to a kind by name.
func isNamed(kind string) bool {
return kind == model.KindZone || kind == model.KindSet || kind == model.KindService
}
func deleteOp(st *model.State, op *model.Op) error {
o, err := findObject(st, op.Kind, op.Ref)
if err != nil {
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"strings"
"testing"
"housecarl/internal/api"
)
// TestRename: renaming a zone, set or service changes what refers to it,
// in the same change, which undoes as one.
func TestRename(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 0
post := func(path string, body map[string]any) {
t.Helper()
if code := e.call(e.token, "POST", path, body, nil); code != 201 {
t.Fatalf("POST %s: %d", path, code)
}
}
post("/zones", map[string]any{"name": "wan", "interfaces": []string{"wan"}, "external": true})
post("/zones", map[string]any{"name": "dhcp", "interfaces": []string{"eth1"}})
post("/sets", map[string]any{"name": "trusted_ips", "entries": []string{"10.0.10.0/24"}})
post("/sets", map[string]any{"name": "guests", "dynamic": true})
post("/services", map[string]any{"name": "jarl", "entries": []string{"tcp/8053"}})
post("/rules", map[string]any{"chain": "forward", "in_zones": []string{"dhcp"}, "out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/rules", map[string]any{"chain": "input", "in_zones": []string{"dhcp"}, "services": []string{"jarl"}})
post("/snats", map[string]any{"out_zones": []string{"wan"}, "sources": []string{"trusted_ips"}})
post("/forwards", map[string]any{"name": "jf", "ports": "8096", "to": "10.0.10.57", "public_addresses": []string{"217.155.8.194"}, "sources": []string{"trusted_ips"}})
var res api.MutationResponse
if code := e.call(e.token, "PATCH", "/zones/dhcp", map[string]any{"name": "lan"}, &res); code != 200 {
t.Fatalf("renaming the zone: %d %+v", code, res)
}
zoneRename := res.HistoryID
e.call(e.token, "PATCH", "/sets/trusted_ips", map[string]any{"name": "trusted"}, &res)
setRename := res.HistoryID
e.call(e.token, "PATCH", "/services/jarl", map[string]any{"name": "jarl-api"}, &res)
serviceRename := res.HistoryID
st := e.eng.State()
if st.Zone("dhcp") != nil || st.Zone("lan") == nil {
t.Fatalf("zones %+v", st.Zones)
}
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "lan" {
t.Errorf("rule %d in zones %v", r.ID, r.InZones)
}
if r.Chain == "forward" && strings.Join(r.Sources, ",") != "trusted" {
t.Errorf("rule %d sources %v", r.ID, r.Sources)
}
if r.Chain == "input" && strings.Join(r.Services, ",") != "jarl-api" {
t.Errorf("rule %d services %v", r.ID, r.Services)
}
}
if s := st.SNATs[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("snat sources %v", s)
}
if s := st.Forwards[0].Sources; strings.Join(s, ",") != "trusted" {
t.Errorf("forward sources %v", s)
}
if ps := e.eng.Problems(); len(ps) != 0 {
for _, p := range ps {
if p.Severity == "error" {
t.Errorf("problem after renaming: %+v", p)
}
}
}
// Undoing the renames (newest first) puts the references back too.
for _, id := range []int64{serviceRename, setRename, zoneRename} {
if code := e.call(e.token, "POST", "/history/"+itoa(id)+"/revert", nil, nil); code != 200 {
t.Fatalf("undo %d: %d", id, code)
}
}
st = e.eng.State()
for _, r := range st.Rules {
if strings.Join(r.InZones, ",") != "dhcp" || r.Chain == "forward" && r.Sources[0] != "trusted_ips" || r.Chain == "input" && r.Services[0] != "jarl" {
t.Errorf("after undo: rule %+v", r.Match)
}
}
// A dynamic set keeps its name: tokens and programs go by it.
if code := e.call(e.token, "PATCH", "/sets/guests", map[string]any{"name": "visitors"}, nil); code != 409 {
t.Errorf("renaming a dynamic set: %d", code)
}
}
+1 -4
View File
@@ -10,15 +10,12 @@ import (
"time"
"housecarl/internal/api"
"housecarl/internal/version"
)
func (s *Server) routes() {
m := s.mux
p := api.Prefix
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
})
m.HandleFunc("GET /healthz", s.healthz)
// Authentication, users and tokens
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
+1
View File
@@ -90,6 +90,7 @@ type Server struct {
srvs []*http.Server
probation probation
health healthCache
logs *logRing
// unitLinks are where an enabled nftables.service shows (tests change
// them).
+2 -1
View File
@@ -40,6 +40,7 @@ type fakeFW struct {
resets []string
traced string
traceLines []string
fallback bool // the boot fallback is in force
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
@@ -64,7 +65,7 @@ func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl")
}
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements, Fallback: f.fallback}, nil
}
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock()