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>
66 lines
1.9 KiB
Go
66 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|