native_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. package firewall
  2. import (
  3. "encoding/json"
  4. "os"
  5. "os/exec"
  6. "strings"
  7. "testing"
  8. "time"
  9. )
  10. func TestNativeValidationAndCriteria(t *testing.T) {
  11. 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"}} {
  12. if validateRule("filter", r) == nil {
  13. t.Fatalf("accepted unsafe/unsupported rule: %+v", r)
  14. }
  15. }
  16. 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"}
  17. if err := validateRule("filter", r); err != nil {
  18. t.Fatal(err)
  19. }
  20. payload := BuildIPTablesRestore([]FilterRule{r}, nil, nil)
  21. 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"} {
  22. if !strings.Contains(payload, want) {
  23. t.Errorf("missing %q in %s", want, payload)
  24. }
  25. }
  26. if validateRule("filter", FilterRule{Chain: "input", Action: "accept", Time: "24:00-25:00"}) == nil {
  27. t.Fatal("invalid time accepted")
  28. }
  29. if _, err := timeoutSeconds("1d"); err != nil {
  30. t.Fatal(err)
  31. }
  32. if GetIPSetName("a b") == GetIPSetName("a_b") {
  33. t.Fatal("colliding set names")
  34. }
  35. 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}} {
  36. if _, err := connectionDeleteArgs(s); err == nil {
  37. t.Fatal("incomplete tuple accepted")
  38. }
  39. }
  40. 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")
  41. if c.Timeout != 432000 || c.Bytes != 700 || c.Packets != 7 || c.Zone != 2 || c.TCPState != "ESTABLISHED" {
  42. t.Fatalf("bad conntrack parse: %+v", c)
  43. }
  44. }
  45. func TestFirewallNativeKernel(t *testing.T) {
  46. if os.Getenv("DEBTIK_FIREWALL_NATIVE") != "1" {
  47. t.Skip("isolated mount/network namespaces only")
  48. }
  49. run := func(binary string, args ...string) string {
  50. t.Helper()
  51. out, err := exec.Command(binary, args...).CombinedOutput()
  52. if err != nil {
  53. t.Fatalf("%s %v: %s: %v", binary, args, out, err)
  54. }
  55. return string(out)
  56. }
  57. run("ip", "link", "set", "lo", "up")
  58. dir := t.TempDir()
  59. run("mount", "--bind", dir, "/etc/proxmox-router/firewall")
  60. meta := t.TempDir() + "/metadata.json"
  61. os.WriteFile(meta, []byte(`{"interfaces":{"wan0":{"role":"WAN"},"lan0":{"role":"LAN"}}}`), 0600)
  62. run("mount", "--bind", meta, "/etc/proxmox-router/metadata.json")
  63. os.MkdirAll(GeoIPZonesDir, 0755)
  64. os.WriteFile(GeoIPConfigFile, []byte(`{"auto_update":false,"interval_hours":24,"enabled_countries":[]}`), 0600)
  65. os.WriteFile(GeoIPZonesDir+"/gr.zone", []byte("192.0.2.0/24\n"), 0600)
  66. // Validate the kernel accepts every supported native match in a suitable chain.
  67. 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"}}
  68. for _, sample := range samples {
  69. sample["id"] = "filter-fields"
  70. sample["chain"] = "forward"
  71. sample["action"] = "accept"
  72. sample["protocol"] = "tcp"
  73. b, _ := json.Marshal(sample)
  74. var r FilterRule
  75. json.Unmarshal(b, &r)
  76. if err := ApplyDualStack([]FilterRule{r}, nil, nil); err != nil {
  77. t.Fatalf("match %v failed: %v", sample, err)
  78. }
  79. }
  80. m := NewFirewallManager()
  81. if !m.applied {
  82. t.Fatal(m.lastError)
  83. }
  84. for _, r := range m.ListFilterRules() {
  85. if err := m.DeleteFilterRule(r.ID); err != nil {
  86. t.Fatal(err)
  87. }
  88. }
  89. if m = NewFirewallManager(); len(m.ListFilterRules()) != 0 {
  90. t.Fatal("empty filter list resurrected")
  91. }
  92. r := FilterRule{ID: "filter-count", Chain: "output", Action: "accept", Protocol: "icmp"}
  93. if err := m.SaveFilterRule(r); err != nil {
  94. t.Fatal(err)
  95. }
  96. run("ping", "-c", "1", "-W", "1", "127.0.0.1")
  97. before, _ := ReadRuleCounters(nil, nil, nil)
  98. if before[r.ID][0] == 0 {
  99. t.Fatal("no counter")
  100. }
  101. r.Comment = "comment change"
  102. if err := m.SaveFilterRule(r); err != nil {
  103. t.Fatal(err)
  104. }
  105. after, _ := ReadRuleCounters(nil, nil, nil)
  106. if after[r.ID][0] != before[r.ID][0] {
  107. t.Fatalf("counters lost: %v -> %v", before, after)
  108. }
  109. if err := m.ResetRuleCounters("filter", r.ID); err != nil {
  110. t.Fatal(err)
  111. }
  112. counts, _ := ReadRuleCounters(nil, nil, nil)
  113. if counts[r.ID][0] != 0 {
  114. t.Fatal("single reset failed")
  115. }
  116. // First marking rule must stop the managed mangle chain even after changing
  117. // the mark that formed part of its own criterion.
  118. 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"}} {
  119. if err := m.SaveMangleRule(r); err != nil {
  120. t.Fatal(err)
  121. }
  122. }
  123. run("ping", "-c", "1", "-W", "1", "127.0.0.1")
  124. counts, _ = ReadRuleCounters(nil, nil, nil)
  125. if counts["mangle-first"][0] == 0 || counts["mangle-second"][0] != 0 {
  126. t.Fatalf("passthrough=false failed: %v", counts)
  127. }
  128. if err := m.DeleteFilterRule(r.ID); err != nil {
  129. t.Fatal(err)
  130. }
  131. dyn := FilterRule{ID: "filter-dynamic", Chain: "output", Action: "add-src-to-address-list", AddressList: "dynamic", AddressListTimeout: "2s", Protocol: "icmp"}
  132. if err := m.SaveFilterRule(dyn); err != nil {
  133. t.Fatal(err)
  134. }
  135. run("ping", "-c", "1", "-W", "1", "127.0.0.1")
  136. run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1")
  137. visible := false
  138. for _, e := range m.ListAddressLists() {
  139. visible = visible || e.Dynamic && e.Address == "127.0.0.1"
  140. }
  141. if !visible {
  142. t.Fatal("dynamic address not visible")
  143. }
  144. dyn.Comment = "unrelated edit"
  145. if err := m.SaveFilterRule(dyn); err != nil {
  146. t.Fatal(err)
  147. }
  148. run("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1")
  149. if err := m.SaveAddressListEntry(AddressListEntry{Name: "dynamic", Address: "127.0.0.2"}); err != nil {
  150. t.Fatal(err)
  151. }
  152. run("ipset", "test", GetIPSetName("dynamic"), staticIPSetName("dynamic"))
  153. run("ipset", "test", staticIPSetName("dynamic"), "127.0.0.2")
  154. time.Sleep(2200 * time.Millisecond)
  155. if exec.Command("ipset", "test", GetDynamicIPSetName("dynamic"), "127.0.0.1").Run() == nil {
  156. t.Fatal("dynamic address did not expire")
  157. }
  158. if err := m.DeleteFilterRule(dyn.ID); err != nil {
  159. t.Fatal(err)
  160. }
  161. if err := m.SaveFilterRule(FilterRule{ID: "filter-list-match", Chain: "output", Action: "drop", Protocol: "icmp", SrcAddressList: "dynamic"}); err != nil {
  162. t.Fatal(err)
  163. }
  164. if exec.Command("ping", "-I", "127.0.0.2", "-c", "1", "-W", "1", "127.0.0.1").Run() == nil {
  165. t.Fatal("static list member did not match union set")
  166. }
  167. if err := m.SaveFilterRule(FilterRule{ID: "filter-geo", Chain: "input", Action: "drop", SrcGeoIP: "GR"}); err != nil {
  168. t.Fatal(err)
  169. }
  170. if m.GeoIP().ToggleCountry("GR", false) == nil {
  171. t.Fatal("disabled a referenced GeoIP set")
  172. }
  173. run("ipset", "test", GetGeoIPSetName("GR"), "192.0.2.1")
  174. if err := m.GeoIP().BatchToggle("custom", true, []string{"ZZ"}); err == nil {
  175. t.Fatal("invalid batch falsely succeeded")
  176. }
  177. // IPv6 mangle ICMP and hop-limit are native IPv6 operations.
  178. if err := ApplyDualStack(nil, nil, []MangleRule{{IPVersion: 6, ID: "mangle-v6", Chain: "output", Action: "drop", Protocol: "icmp", Passthrough: true}}); err != nil {
  179. t.Fatal(err)
  180. }
  181. if exec.Command("ping", "-6", "-c", "1", "-W", "1", "::1").Run() == nil {
  182. t.Fatal("IPv6 ICMP drop did not match")
  183. }
  184. 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}} {
  185. if err := ApplyDualStack(nil, nil, []MangleRule{r}); err != nil {
  186. t.Fatal(err)
  187. }
  188. }
  189. t.Log("Native firewall integration complete")
  190. }
  191. func TestNATPortProtocolMustBeExplicit(t *testing.T) {
  192. r := NATRule{Chain: "dstnat", Action: "dst-nat", Protocol: "all", DstPort: "53", ToAddresses: "192.0.2.53"}
  193. if err := validateRule("nat", r); err == nil || !strings.Contains(err.Error(), "TCP or UDP") {
  194. t.Fatalf("protocol all accepted: %v", err)
  195. }
  196. for _, protocol := range []string{"tcp", "udp"} {
  197. r.Protocol = protocol
  198. if err := validateRule("nat", r); err != nil {
  199. t.Fatal(err)
  200. }
  201. payload := BuildIPTablesRestore(nil, []NATRule{r}, nil)
  202. for _, want := range []string{"-p " + protocol, "--dport 53", "-j DNAT --to-destination 192.0.2.53"} {
  203. if !strings.Contains(payload, want) {
  204. t.Fatalf("missing %s: %s", want, payload)
  205. }
  206. }
  207. }
  208. }