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>
176 lines
6.0 KiB
Go
176 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|