package firewall import ( "encoding/json" "os" "os/exec" "strings" "testing" "time" ) func TestNativeValidationAndCriteria(t *testing.T) { for _, r := range []FilterRule{{Chain: "input", Action: "tarpit"}, {Chain: "input", Action: "accept", Layer7Protocol: "regex"}, {Chain: "input", Action: "drop", TLSHost: "*.example.com"}, {Chain: "input", Action: "accept", Protocol: "tcp\" -j ACCEPT"}, {Chain: "input", Action: "accept", ID: "id\" -j ACCEPT"}} { if validateRule("filter", r) == nil { t.Fatalf("accepted unsafe/unsupported rule: %+v", r) } } r := FilterRule{ID: "filter-test", Chain: "forward", Action: "drop", Protocol: "tcp", DstPort: "!80-90", AnyPort: "443", PacketMark: "!100", SrcMACAddress: "02:00:00:00:00:01", DSCP: "0", PacketSize: "64-1500", TCPFlags: "syn,!ack", Limit: "100,5:packet", Time: "08:00-17:00", Days: "mon,fri", Log: true, LogPrefix: "audit\"prefix"} if err := validateRule("filter", r); err != nil { t.Fatal(err) } payload := BuildIPTablesRestore([]FilterRule{r}, nil, nil) for _, want := range []string{"! --dport 80:90", "--ports 443", "-m mark ! --mark 100", "--mac-source 02:00:00:00:00:01", "--dscp 0", "--length 64:1500", "--tcp-flags SYN,ACK SYN", "--limit 100.000000/second", "--weekdays Mon,Fri", "-j LOG"} { if !strings.Contains(payload, want) { t.Errorf("missing %q in %s", want, payload) } } if validateRule("filter", FilterRule{Chain: "input", Action: "accept", Time: "24:00-25:00"}) == nil { t.Fatal("invalid time accepted") } if _, err := timeoutSeconds("1d"); err != nil { t.Fatal(err) } if GetIPSetName("a b") == GetIPSetName("a_b") { t.Fatal("colliding set names") } for _, s := range []ConnectionSelector{{}, {Protocol: "tcp", Src: "192.0.2.1", Dst: "198.51.100.1"}, {Protocol: "udp", Src: "192.0.2.1", Dst: "bad", Sport: 10, Dport: 11}} { if _, err := connectionDeleteArgs(s); err == nil { t.Fatal("incomplete tuple accepted") } } c := parseConntrackLine("ipv4 2 tcp 6 432000 ESTABLISHED src=192.0.2.1 dst=198.51.100.1 sport=123 dport=443 packets=4 bytes=400 src=198.51.100.1 dst=192.0.2.1 sport=443 dport=123 packets=3 bytes=300 [ASSURED] mark=7 zone=2") if c.Timeout != 432000 || c.Bytes != 700 || c.Packets != 7 || c.Zone != 2 || c.TCPState != "ESTABLISHED" { t.Fatalf("bad conntrack parse: %+v", c) } } func TestFirewallNativeKernel(t *testing.T) { if os.Getenv("DEBTIK_FIREWALL_NATIVE") != "1" { t.Skip("isolated mount/network namespaces only") } run := func(binary string, args ...string) string { t.Helper() out, err := exec.Command(binary, args...).CombinedOutput() if err != nil { t.Fatalf("%s %v: %s: %v", binary, args, out, err) } return string(out) } run("ip", "link", "set", "lo", "up") dir := t.TempDir() run("mount", "--bind", dir, "/etc/proxmox-router/firewall") meta := t.TempDir() + "/metadata.json" os.WriteFile(meta, []byte(`{"interfaces":{"wan0":{"role":"WAN"},"lan0":{"role":"LAN"}}}`), 0600) run("mount", "--bind", meta, "/etc/proxmox-router/metadata.json") os.MkdirAll(GeoIPZonesDir, 0755) os.WriteFile(GeoIPConfigFile, []byte(`{"auto_update":false,"interval_hours":24,"enabled_countries":[]}`), 0600) os.WriteFile(GeoIPZonesDir+"/gr.zone", []byte("192.0.2.0/24\n"), 0600) // Validate the kernel accepts every supported native match in a suitable chain. samples := []map[string]any{{"any_port": "443"}, {"log": true, "log_prefix": "audit\"safe"}, {"dst_port": "!80-90"}, {"packet_mark": "!100"}, {"connection_type": "ftp"}, {"connection_state": []string{"new"}, "connection_nat_state": []string{"dstnat"}}, {"content": "example.com"}, {"src_mac_address": "02:00:00:00:00:01"}, {"in_bridge_port": "lan0"}, {"out_bridge_port": "wan0"}, {"ipsec_policy": "in,none"}, {"dscp": "0"}, {"tcp_mss": "1200-1500"}, {"tos": "min-delay"}, {"packet_size": "64-1500"}, {"random": "25"}, {"tcp_flags": "syn,!ack"}, {"ttl": "greater-than:20"}, {"limit": "100,5:packet"}, {"time": "08:00-17:00", "days": "mon,fri"}} for _, sample := range samples { sample["id"] = "filter-fields" sample["chain"] = "forward" sample["action"] = "accept" sample["protocol"] = "tcp" b, _ := json.Marshal(sample) var r FilterRule json.Unmarshal(b, &r) if err := ApplyDualStack([]FilterRule{r}, nil, nil); err != nil { t.Fatalf("match %v failed: %v", sample, err) } } m := NewFirewallManager() if !m.applied { t.Fatal(m.lastError) } for _, r := range m.ListFilterRules() { if err := m.DeleteFilterRule(r.ID); err != nil { t.Fatal(err) } } if m = NewFirewallManager(); len(m.ListFilterRules()) != 0 { t.Fatal("empty filter list resurrected") } r := FilterRule{ID: "filter-count", Chain: "output", Action: "accept", Protocol: "icmp"} if err := m.SaveFilterRule(r); err != nil { t.Fatal(err) } run("ping", "-c", "1", "-W", "1", "127.0.0.1") before, _ := ReadRuleCounters(nil, nil, nil) if before[r.ID][0] == 0 { t.Fatal("no counter") } r.Comment = "comment change" if err := m.SaveFilterRule(r); err != nil { t.Fatal(err) } after, _ := ReadRuleCounters(nil, nil, nil) if after[r.ID][0] != before[r.ID][0] { t.Fatalf("counters lost: %v -> %v", before, after) } if err := m.ResetRuleCounters("filter", r.ID); err != nil { t.Fatal(err) } counts, _ := ReadRuleCounters(nil, nil, nil) if counts[r.ID][0] != 0 { t.Fatal("single reset failed") } // First marking rule must stop the managed mangle chain even after changing // the mark that formed part of its own criterion. for _, r := range []MangleRule{{ID: "mangle-first", Chain: "output", Action: "mark-packet", NewPacketMark: "100", Passthrough: false, Protocol: "icmp"}, {ID: "mangle-second", Chain: "output", Action: "mark-packet", NewPacketMark: "200", Passthrough: true, Protocol: "icmp"}} { if err := m.SaveMangleRule(r); err != nil { t.Fatal(err) } } run("ping", "-c", "1", "-W", "1", "127.0.0.1") counts, _ = ReadRuleCounters(nil, nil, nil) if counts["mangle-first"][0] == 0 || counts["mangle-second"][0] != 0 { t.Fatalf("passthrough=false failed: %v", counts) } if err := m.DeleteFilterRule(r.ID); err != nil { t.Fatal(err) } dyn := FilterRule{ID: "filter-dynamic", Chain: "output", Action: "add-src-to-address-list", AddressList: "dynamic", AddressListTimeout: "2s", Protocol: "icmp"} if err := m.SaveFilterRule(dyn); err != nil { t.Fatal(err) } run("ping", "-c", "1", "-W", "1", "127.0.0.1") run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1") visible := false for _, e := range m.ListAddressLists() { visible = visible || e.Dynamic && e.Address == "127.0.0.1" } if !visible { t.Fatal("dynamic address not visible") } dyn.Comment = "unrelated edit" if err := m.SaveFilterRule(dyn); err != nil { t.Fatal(err) } run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1") if err := m.SaveAddressListEntry(AddressListEntry{Name: "dynamic", Address: "127.0.0.2"}); err != nil { t.Fatal(err) } run("ipset", "test", GetIPSetName("dynamic"), staticIPSetName("dynamic")) run("ipset", "test", staticIPSetName("dynamic"), "127.0.0.2") time.Sleep(2200 * time.Millisecond) if exec.Command("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1").Run() == nil { t.Fatal("dynamic address did not expire") } if err := m.DeleteFilterRule(dyn.ID); err != nil { t.Fatal(err) } if err := m.SaveFilterRule(FilterRule{ID: "filter-list-match", Chain: "output", Action: "drop", Protocol: "icmp", SrcAddressList: "dynamic"}); err != nil { t.Fatal(err) } if exec.Command("ping", "-I", "127.0.0.2", "-c", "1", "-W", "1", "127.0.0.1").Run() == nil { t.Fatal("static list member did not match union set") } if err := m.SaveFilterRule(FilterRule{ID: "filter-geo", Chain: "input", Action: "drop", SrcGeoIP: "GR"}); err != nil { t.Fatal(err) } if m.GeoIP().ToggleCountry("GR", false) == nil { t.Fatal("disabled a referenced GeoIP set") } run("ipset", "test", GetGeoIPSetName("GR"), "192.0.2.1") if err := m.GeoIP().BatchToggle("custom", true, []string{"ZZ"}); err == nil { t.Fatal("invalid batch falsely succeeded") } // IPv6 mangle ICMP and hop-limit are native IPv6 operations. if err := ApplyDualStack(nil, nil, []MangleRule{{IPVersion: 6, ID: "mangle-v6", Chain: "output", Action: "drop", Protocol: "icmp", Passthrough: true}}); err != nil { t.Fatal(err) } if exec.Command("ping", "-6", "-c", "1", "-W", "1", "::1").Run() == nil { t.Fatal("IPv6 ICMP drop did not match") } for _, r := range []MangleRule{{IPVersion: 6, ID: "mangle-ttl", Chain: "output", Action: "change-ttl", NewTTL: "64", Passthrough: true}, {ID: "mangle-mss", Chain: "forward", Protocol: "tcp", Action: "change-mss", NewMSS: "1400", Passthrough: true}, {ID: "mangle-dscp", Chain: "output", Action: "change-dscp", NewDSCP: 0, Passthrough: true}} { if err := ApplyDualStack(nil, nil, []MangleRule{r}); err != nil { t.Fatal(err) } } t.Log("Native firewall integration complete") } func TestNATPortProtocolMustBeExplicit(t *testing.T) { r := NATRule{Chain: "dstnat", Action: "dst-nat", Protocol: "all", DstPort: "53", ToAddresses: "192.0.2.53"} if err := validateRule("nat", r); err == nil || !strings.Contains(err.Error(), "TCP or UDP") { t.Fatalf("protocol all accepted: %v", err) } for _, protocol := range []string{"tcp", "udp"} { r.Protocol = protocol if err := validateRule("nat", r); err != nil { t.Fatal(err) } payload := BuildIPTablesRestore(nil, []NATRule{r}, nil) for _, want := range []string{"-p " + protocol, "--dport 53", "-j DNAT --to-destination 192.0.2.53"} { if !strings.Contains(payload, want) { t.Fatalf("missing %s: %s", want, payload) } } } } func TestTTLTargetSyntax(t *testing.T) { for _, tc := range []struct{ value, want string }{{"64", "--ttl-set 64"}, {"-1", "--ttl-dec 1"}, {"+1", "--ttl-inc 1"}} { r := MangleRule{Chain: "output", Action: "change-ttl", NewTTL: tc.value, Passthrough: true} if err := ValidateRule("mangle", r); err != nil { t.Fatalf("%s: %v", tc.value, err) } if payload := BuildIPTablesRestore(nil, nil, []MangleRule{r}); !strings.Contains(payload, tc.want) { t.Errorf("%s missing %s", tc.value, tc.want) } } }