Files
housecarl/internal/importer/importer_test.go
T
abrown38andClaude Opus 5.5 2880f7ddc9 Add changesets, bandwidth limits and quotas; generic examples
Changesets, as in Jarl: configuration changes are operations that can be
staged in a changeset, checked as they are added (validation and nft -c),
reviewed with the diff of the ruleset, and applied together as one
history entry, then on probation like any change made over the network.
The web UI collects edits for review by default (pending rows, a bar with
Review and Apply now, a Changes page, a switch for live edits); hc stages
with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has
hc changeset create/list/show/diff/apply/discard/remove. Undo, import and
takeover refuse a changeset. --dry-run shows the ruleset's diff.

Bandwidth and quotas: rate limits in bytes, per source or destination;
quotas per rule or per address that reset daily, weekly, monthly on a
day, or by hand, with their usage carried across reloads, saved for
reboots, shown by hc quotas and on the rules page, and reset with hc rule
reset-quota. Rules that count traffic are checked before established
connections are accepted (so they see every packet) and can't accept.
Per-address limits group IPv6 by /64 by default (--group-v4/-v6).
Per-source rate meters use update. The importer turns meters, byte rates
and quotas into these fields.

The examples in the web UI, hc's help and the docs use documentation
addresses and generic names instead of the maintainer's network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:18:54 +01:00

304 lines
11 KiB
Go

package importer
import (
"fmt"
"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)
}
}
// TestImportMaps: dnat maps become port forwards and verdict maps rules,
// one per element (testdata/maps.nft); a map that can't be expanded is
// left out with a note, never kept as a raw rule naming it.
func TestImportMaps(t *testing.T) {
js, _ := os.ReadFile("testdata/maps.json")
txt, _ := os.ReadFile("testdata/maps.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var fwds []string
for _, f := range res.State.Forwards {
fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort))
}
var rules []string
for _, r := range res.State.Rules {
rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","),
strings.Join(r.Services, ","), r.Action, r.Raw))
}
want := []string{
"portforwards-80 tcp/80->192.168.1.50:8080",
"portforwards-443 tcp/443->192.168.1.50:",
"hosts-2222 tcp/2222->192.168.1.60:",
" udp/5000->192.168.1.70:5001",
" udp/6000->192.168.1.71:",
}
if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") {
t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
// In order; the jump in the verdict map is inlined.
want = []string{
"input in= from= accept",
"input in=lan from= tcp/22 accept",
"input in=lan from= tcp/23 drop",
"input in=lan from=192.168.1.0/24 tcp/80 accept",
"input in=wan from= udp/53 accept",
"input in=wan from= udp/123 drop",
"input in= from=10.0.0.0/8 accept",
"input in= from=192.0.2.1 drop",
}
if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
notes := strings.Join(res.Notes, "\n")
for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward",
"uses the map @pairs, which couldn't be expanded"} {
if !strings.Contains(notes, n) {
t.Errorf("note %q missing:\n%s", n, notes)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}
// TestImportMeters: meters (per-address limits, connection counts and
// quotas), byte rates and quotas become rules' limits and quotas
// (testdata/meters.nft); the meters' sets aren't imported as address sets.
func TestImportMeters(t *testing.T) {
js, _ := os.ReadFile("testdata/meters.json")
txt, _ := os.ReadFile("testdata/meters.txt")
res, err := Import(Input{JSON: js, Text: string(txt)})
if err != nil {
t.Fatal(err)
}
var got []string
for _, r := range res.State.Rules {
s := r.Chain
if l := r.Limit; l != nil {
s += fmt.Sprintf(" limit %d %s/%s burst %d over=%v src=%v dst=%v", l.Rate, l.Unit, l.Per, l.Burst, l.Over, l.PerSource, l.PerDestination)
}
if r.ConnLimit > 0 {
s += fmt.Sprintf(" conn %d", r.ConnLimit)
}
if q := r.Quota; q != nil {
s += fmt.Sprintf(" quota %d over=%v src=%v", q.Bytes, q.Over, q.PerSource)
}
if r.GroupV4 != 0 || r.GroupV6 != 0 {
s += fmt.Sprintf(" group %d/%d", r.GroupV4, r.GroupV6)
}
s += " " + r.Action + r.Raw
got = append(got, s)
}
want := []string{
"input accept", // established: only in input here, so a rule
"input limit 3 /minute burst 5 over=true src=true dst=false drop",
"input limit 3 /minute burst 5 over=true src=true dst=false group 0/64 drop",
"input accept",
"forward conn 20 drop",
"forward limit 10240 kbytes/second burst 512 over=true src=false dst=false drop",
"forward quota 1048576000 over=true src=true group 24/0 drop",
"forward quota 104857600000 over=true src=false drop",
"forward accept",
}
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("rules:\n%s\nwant:\n%s\nnotes: %v", strings.Join(got, "\n"), strings.Join(want, "\n"), res.Notes)
}
for _, s := range res.State.Sets {
if s.Name != "trusted" {
t.Errorf("a meter became a set: %s", s.Name)
}
}
if len(res.Raw) > 0 {
t.Errorf("raw rules: %v", res.Raw)
}
if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 {
t.Errorf("invalid: %+v", ps)
}
if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 {
c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text)
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("nft -c: %v\n%s", err, out)
}
}
}