Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
package importer
import (
"os"
"os/exec"
"strings"
"testing"
"housecarl/internal/model"
"housecarl/internal/render"
"housecarl/internal/validate"
)
// The maintainer's router ruleset (testdata/router.nft), as nft 1.1.3
// lists it in JSON (router.json) and text (router.txt).
func importRouter(t *testing.T) *Result {
t.Helper()
js, _ := os.ReadFile("testdata/router.json")
txt, _ := os.ReadFile("testdata/router.txt")
defs, _ := os.ReadFile("testdata/router.nft")
res, err := Import(Input{JSON: js, Text: string(txt), Defines: string(defs)})
if err != nil {
t.Fatal(err)
}
return res
}
func TestImportRouter(t *testing.T) {
res := importRouter(t)
st := res.State
for _, n := range res.Notes {
t.Log("note:", n)
}
for _, r := range res.Raw {
t.Log("raw:", r)
}
// Zones, named by the defines; wan is external.
wan := st.Zone("wan")
if wan == nil || !wan.External || wan.Interfaces[0] != "wan" {
t.Errorf("zone wan: %+v", wan)
}
if z := st.Zone("dhcp"); z == nil || z.Interfaces[0] != "enp2s0" {
t.Errorf("zone dhcp: %+v (zones %+v)", z, st.Zones)
}
// Sets named by the defines.
if s := st.Set("trusted_ips"); s == nil || len(s.Entries) != 7 {
t.Errorf("set trusted_ips: %+v", s)
}
if s := st.Set("masq_to_internet"); s == nil {
t.Errorf("no set masq_to_internet (sets %v)", setNames(st))
}
// Built-ins: the ones at the head of their chains.
b := st.Settings.Builtins
if !b.Loopback || !b.Established || !b.ICMPv6 || !b.PingInternal || !b.DHCPv6Client || !b.MSSClamp || !b.Antispoof {
t.Errorf("built-ins %+v", b)
}
// The forward chain's asymmetric-routing rule sits between the
// established and invalid rules: invalid stays rules, in order.
if b.DropInvalid {
t.Error("drop_invalid became a built-in, which would put it before the asymmetric-routing rule")
}
var fwd []string
for _, r := range st.Rules {
if r.Chain == model.ChainForward {
fwd = append(fwd, r.Comment+"|"+strings.Join(r.CtState, ","))
}
}
if len(fwd) < 2 || fwd[0] != "Needed due to asymmetric routing (internal only)|invalid" || fwd[1] != "|invalid" {
t.Errorf("forward rules start %q", fwd)
}
if st.Settings.OwnPrefixes[0] != "2a02:8013:7f3d::/48" || len(st.Settings.AntispoofV4) != 6 {
t.Errorf("antispoof %+v %+v", st.Settings.OwnPrefixes, st.Settings.AntispoofV4)
}
// Port forwards with their hairpin; the forward-chain accepts are
// folded in.
if len(st.Forwards) != 5 {
t.Fatalf("forwards %+v", st.Forwards)
}
jf := st.Forwards[0]
if jf.Name != "jellyfin" || jf.Ports != "8096" || jf.To != "10.0.10.57" || jf.ToPort != "443" || jf.PublicAddresses[0] != "217.155.8.194" ||
!jf.Hairpin || jf.HairpinSources[0] != "internal_net" {
t.Errorf("jellyfin %+v", jf)
}
for _, r := range st.Rules {
if r.Chain == model.ChainForward && strings.Contains(r.Comment, "Forward") && len(r.CtStatus) > 0 {
t.Errorf("forward accept not folded in: %+v", r)
}
}
// Source NAT: the masquerades.
if len(st.SNATs) != 2 || st.SNATs[0].OutZones[0] != "wan" || st.SNATs[0].Sources[0] != "masq_to_internet" ||
!st.SNATs[1].NotOut || st.SNATs[1].OutZones[0] != "wg0" {
t.Errorf("snats %+v %+v", st.SNATs[0], st.SNATs[1])
}
// The jump to wg0_input is inlined: its rules carry the source.
var wgDNS bool
for _, r := range st.Rules {
if r.Chain == model.ChainInput && len(r.Sources) == 1 && r.Sources[0] == "wg0_net" && strings.Join(r.Services, ",") == "udp/53" {
wgDNS = true
}
}
if !wgDNS {
t.Errorf("wg0_input not inlined")
}
// The jump's and the rule's sources both: the rule's are kept, noted.
if !strings.Contains(strings.Join(res.Notes, "\n"), "the rule's own (10.50.50.3, 10.50.50.6) are kept") {
t.Errorf("no note on the overlapping sources: %q", res.Notes)
}
// The imported configuration is valid, and renders.
if ps := validate.Check(st, nil); validate.HasErrors(ps) {
t.Errorf("problems: %+v", validate.Errors(ps))
}
text := render.Render(st, render.Options{}).Text
for _, want := range []string{
`ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443`,
`ip saddr @internal_net_v4 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`,
`oifname "wan" ip saddr @masq_to_internet_v4 counter masquerade`,
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu`,
`ip saddr @trusted_ips_v4 tcp dport 8053 counter accept comment "hc:r`,
} {
if !strings.Contains(text, want) {
t.Errorf("render: missing %q", want)
}
}
if t.Failed() {
t.Log(text)
}
}
func setNames(st *model.State) []string {
var out []string
for _, s := range st.Sets {
out = append(out, s.Name)
}
return out
}
func TestImportNeedsATable(t *testing.T) {
js := []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"ip","name":"nat"}}]}`)
if _, err := Import(Input{JSON: js}); err == nil || !strings.Contains(err.Error(), "several tables") {
t.Errorf("several tables: %v", err)
}
if _, err := Import(Input{JSON: js, Table: "ip nat"}); err != nil {
t.Errorf("--table: %v", err)
}
}
func TestForwardNames(t *testing.T) {
im := &importer{st: &model.State{}}
for _, c := range []string{"Port forward: Jellyfin", "8096 to media", "web", "web", "-"} {
im.st.Forwards = append(im.st.Forwards, &model.PortForward{Name: forwardName(c)})
}
im.nameForwards()
var got []string
for _, f := range im.st.Forwards {
got = append(got, f.Name)
}
if want := "jellyfin fwd-8096-to-media web web-2 "; strings.Join(got, " ") != want {
t.Errorf("names %q, want %q", strings.Join(got, " "), want)
}
}
// TestImportedRulesetPassesNft: the ruleset rendered from the import is
// accepted by the real nft (HOUSECARL_NFT, as in internal/render).
func TestImportedRulesetPassesNft(t *testing.T) {
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
if len(cmd) == 0 {
t.Skip("HOUSECARL_NFT not set")
}
rs := render.Render(importRouter(t).State, render.Options{})
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(rs.Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}