A DNAT map (named or anonymous) becomes one port forward per element, named after the map; a verdict map becomes one rule per element, in place, with jumps inlined. A lookup matches one element at most, so the expanded rules do the same. A named map that can't be expanded (keyed by several fields) is left out with a note instead of a raw rule naming it, which nftables would refuse. Test fixture made with nft 1.1.3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
8.5 KiB
Go
242 lines
8.5 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)
|
|
}
|
|
}
|
|
}
|