package firewall import ( "context" "os" "path/filepath" "strings" "testing" "github.com/stretchr/testify/require" ) func TestIPTablesRules(t *testing.T) { fw := new(IPTables) // Parse a rule that is expected to parse right. rule, err := fw.UnmarshalRule(`-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, IPv4) require.NoError(t, err) // Re-encode the rule which should result in expected rich rule. richRule, err := fw.MarshalRule(rule) require.NoError(t, err) require.Equal(t, `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, richRule, "the rich rule did not encode as expected") // Try encoding a bunch of invalid rules. invalidRules := []string{ `-A TEST -s 192.168.0.0/24 -j DROP`, `-s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23`, `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j MARK`, `-A INPUT -s 192.168.0.0/24 -p tcp -m udp --dport 23 -j DROP`, } for _, richRule := range invalidRules { _, err := fw.UnmarshalRule(richRule, IPv4) require.Error(t, err, "this rich rule was parsed when it should be invalid: %s", richRule) } // Test rules we typically set. validRules := []string{ `-A INPUT -p udp -m udp --dport 4789 -j ACCEPT`, `-A OUTPUT -p udp -m udp --dport 4789 -j ACCEPT`, `-A INPUT -s 67.227.233.116 -p tcp -m tcp --dport 4789 -j ACCEPT`, `-A OUTPUT -d 67.227.233.116 -p tcp -m tcp --dport 4791 -j ACCEPT`, } for _, richRule := range validRules { _, err := fw.UnmarshalRule(richRule, IPv4) require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule) } // A port without a concrete protocol cannot be expressed in iptables // (`-m tcp/udp --dport` is invalid without `-p tcp/udp`), so marshalling // must error rather than silently emit an invalid rule. _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) require.Error(t, err, "expected error marshalling a port with no protocol") } func TestIPTablesRuleValid(t *testing.T) { cases := []struct { name string rule *Rule wantErr bool }{ {"port with no protocol", &Rule{Port: 80, Action: Accept}, true}, {"source port with no protocol", &Rule{SourcePort: 80, Action: Accept}, true}, {"multiport with no protocol", &Rule{Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, true}, {"icmp type on tcp", &Rule{Proto: TCP, ICMPType: Ptr[uint8](8), Action: Accept}, true}, {"icmpv6 type on tcp", &Rule{Proto: TCP, ICMPType: Ptr[uint8](128), Action: Accept}, true}, {"tcp port", &Rule{Proto: TCP, Port: 80, Action: Accept}, false}, {"udp port", &Rule{Proto: UDP, Port: 53, Action: Accept}, false}, {"sctp port", &Rule{Proto: SCTP, Port: 80, Action: Accept}, false}, {"icmp without type", &Rule{Proto: ICMP, Action: Accept}, false}, {"icmp with type", &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false}, {"icmpv6 with type", &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { err := iptablesRuleValid(c.rule) if c.wantErr { require.Error(t, err, "expected rule to be invalid for iptables") } else { require.NoError(t, err, "expected rule to be valid for iptables") } }) } } func TestIPTablesFeatureRules(t *testing.T) { fw := new(IPTables) // Confirm representative encodings. cases := []struct { rule *Rule want string }{ {&Rule{Proto: ICMP, Action: Reject}, "-A INPUT -p icmp -j REJECT"}, {&Rule{Proto: ICMPv6, Action: Accept}, "-A INPUT -p icmpv6 -j ACCEPT"}, {&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "-A INPUT -p icmp -m icmp --icmp-type 8 -j ACCEPT"}, {&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, "-A INPUT -p icmpv6 -m icmp6 --icmpv6-type 128 -j ACCEPT"}, {&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT"}, {&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "-A INPUT -p udp -m multiport --dports 1000:2000 -j ACCEPT"}, {&Rule{Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept}, "-A INPUT -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT"}, {&Rule{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, "-A INPUT -i eth0 -p tcp -m tcp --dport 22 -j ACCEPT"}, {&Rule{Direction: DirOutput, OutInterface: "eth1", Action: Drop}, "-A OUTPUT -o eth1 -j DROP"}, } for _, c := range cases { got, err := fw.MarshalRule(c.rule) require.NoError(t, err, "failed to marshal %+v", *c.rule) require.Equal(t, c.want, got, "marshal %+v", *c.rule) } // Round-trip every new-feature rule shape. rules := []*Rule{ {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept}, {Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, {Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}, {Source: "10.0.0.0/8", Proto: TCP, Port: 22, State: StateNew, Action: Accept}, {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, {Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept}, {Proto: ICMP, Action: Accept}, {Proto: ICMPv6, Action: Accept}, {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, } for _, r := range rules { spec, err := fw.MarshalRule(r) require.NoError(t, err, "failed to marshal %+v", *r) parsed, err := fw.UnmarshalRule(spec, r.Family) require.NoError(t, err, "failed to parse %q", spec) require.True(t, parsed.Equal(r, true), "round-trip mismatch: input %+v, spec %q, output %+v", *r, spec, parsed) } // Bare `--icmp-type ` and `--dport`/`--sport` (without an explicit `-m`), // as used in ufw's iptables rules files, must parse. icmpRule, err := fw.UnmarshalRule("-A INPUT -p icmp --icmp-type echo-request -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, ICMP, icmpRule.Proto, "unexpected bare icmp-type parse: %+v", *icmpRule) require.NotNil(t, icmpRule.ICMPType, "unexpected bare icmp-type parse: %+v", *icmpRule) require.EqualValues(t, 8, *icmpRule.ICMPType, "unexpected bare icmp-type parse: %+v", *icmpRule) dportRule, err := fw.UnmarshalRule("-A INPUT -p udp --dport 5353 -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, UDP, dportRule.Proto, "unexpected bare dport parse: %+v", *dportRule) require.EqualValues(t, 5353, dportRule.Port, "unexpected bare dport parse: %+v", *dportRule) sportRule, err := fw.UnmarshalRule("-A INPUT -p tcp --sport 1234 -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, TCP, sportRule.Proto, "unexpected bare sport parse: %+v", *sportRule) require.EqualValues(t, 1234, sportRule.SourcePort, "unexpected bare sport parse: %+v", *sportRule) // The legacy `-m state --state` match must also parse. r, err := fw.UnmarshalRule("-A INPUT -p tcp -m tcp --dport 22 -m state --state NEW,ESTABLISHED -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, StateNew|StateEstablished, r.State, "unexpected state parse") // Interface/direction mismatches must be rejected. _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "eth0", Action: Accept}) require.Error(t, err, "expected error matching an input interface on an output rule") _, err = fw.MarshalRule(&Rule{OutInterface: "eth0", Action: Accept}) require.Error(t, err, "expected error matching an output interface on an input rule") } // An iptables-save line may carry a leading [pkts:bytes] counter prefix; the // parser must capture it onto the rule and keep parsing the rest of the line. func TestIPTablesCounterPrefix(t *testing.T) { fw := new(IPTables) r, err := fw.UnmarshalRule("[42:3360] -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, uint64(42), r.Packets, "packet counter not captured") require.Equal(t, uint64(3360), r.Bytes, "byte counter not captured") require.True(t, r.EqualBase(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, true), "counters must not be part of rule identity: %+v", r) // A line without a counter prefix still parses, with zero counters. r2, err := fw.UnmarshalRule("-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) require.NoError(t, err) require.Zero(t, r2.Packets) require.Zero(t, r2.Bytes) } // A nat rule in the OUTPUT chain (locally-generated DNAT) cannot be represented // distinctly by the NATRule model, so it must be treated as foreign: skipped on // read (never surfaced as a PREROUTING DNAT) and preserved verbatim by // rewriteNATRules (never relocated to PREROUTING or dropped on Restore). func TestIPTablesNATOutputChainPreserved(t *testing.T) { // UnmarshalNATRule rejects an OUTPUT-chain rule so natRulesInFile skips it. f := new(IPTables) _, err := f.UnmarshalNATRule("-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6", IPv4) require.Error(t, err, "an OUTPUT-chain nat rule must not be surfaced as a managed NAT rule") dir := t.TempDir() p4 := filepath.Join(dir, "iptables") save := "*nat\n" + ":PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + "-A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.5\n" + "-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6\n" + "COMMIT\n" require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: filepath.Join(dir, "ip6tables")} // GetNATRules-side read surfaces only the PREROUTING rule; the OUTPUT rule is skipped. got, err := fw.natRulesInFile(p4) require.NoError(t, err) require.Len(t, got, 1, "only the PREROUTING nat rule should be surfaced, not the OUTPUT one") require.EqualValues(t, 80, got[0].Port) // Restore-side rewrite replaces the managed (PREROUTING) rules but preserves the // foreign OUTPUT rule verbatim. require.NoError(t, fw.rewriteNATRules(p4, []string{"-A PREROUTING -p tcp -m tcp --dport 90 -j DNAT --to-destination 10.0.0.9"})) data, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(data), "-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6", "the OUTPUT nat rule must be preserved verbatim, not relocated or dropped") require.Contains(t, string(data), "--dport 90", "the rewritten PREROUTING rule must be present") require.NotContains(t, string(data), "--dport 80", "the old managed PREROUTING rule must be replaced") } // TestIPTablesGetRulesIgnoresNonFilterTables guards against the *nat/*mangle // tables' INPUT/OUTPUT chains bleeding into the filter read. The save file is a // full iptables-save dump, so a *mangle INPUT/OUTPUT rule with a plain target // (ACCEPT/DROP/LOG) parses as a filter rule; a table-agnostic scan would surface it // via GetRules and, worse, remove it via RemoveRule as if it were a filter rule. func TestIPTablesGetRulesIgnoresNonFilterTables(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "COMMIT\n" + "*mangle\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 25 -j DROP\n" + "-A OUTPUT -p tcp -m tcp --dport 26 -j ACCEPT\n" + "COMMIT\n" require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // GetRules must surface only the *filter INPUT rule, not the *mangle ones. rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1, "only the filter INPUT rule should be surfaced, not the mangle rules") require.EqualValues(t, 22, rules[0].Port) // RemoveRule for a rule matching the *mangle INPUT DROP must not touch the mangle // table; the library manages only *filter, so the mangle rule is preserved. require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Proto: TCP, Port: 25, Action: Drop})) data, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 25 -j DROP", "the mangle INPUT rule must be preserved, not removed as if it were a filter rule") require.Contains(t, string(data), "-A OUTPUT -p tcp -m tcp --dport 26 -j ACCEPT", "the mangle OUTPUT rule must be preserved") require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", "the filter INPUT rule must be preserved") } // TestIPTablesAddRulesBatchPreservesUnmodeledRule verifies the additive batch add // keeps a pre-existing INPUT/OUTPUT rule the parser cannot model (here an -m recent // rate-limit rule), matching the single-rule AddRule path. GetRules cannot // represent such a rule, so a rewrite that dropped it would silently delete a // foreign rule that AddRulesBatch's contract promises to keep. func TestIPTablesAddRulesBatchPreservesUnmodeledRule(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") recent := "-A INPUT -p tcp -m tcp --dport 22 -m recent --update --seconds 60 --hitcount 4 -j DROP" orphanLog := "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"" save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + recent + "\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + orphanLog + "\n" + "-A FORWARD -p tcp -m tcp --dport 8080 -j ACCEPT\n" + "COMMIT\n" require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // The -m recent rule and the orphan LOG are not modeled, so GetRules cannot see // them; only the two modeled rules surface (the INPUT dport 80 and, now that the // forward chain is modeled, the FORWARD dport 8080). rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 2, "only the two modeled rules should surface") // Additively add a new rule. require.NoError(t, fw.AddRulesBatch(ctx, "", []*Rule{{Family: IPv4, Proto: TCP, Port: 443, Action: Accept}})) data, err := os.ReadFile(p4) require.NoError(t, err) got := string(data) require.Contains(t, got, recent, "the foreign -m recent rule must be preserved by an additive batch add") require.Contains(t, got, orphanLog, "the standalone LOG rule must be preserved") require.Contains(t, got, "-A FORWARD -p tcp -m tcp --dport 8080 -j ACCEPT", "the FORWARD rule must be preserved") require.Contains(t, got, "--dport 443", "the newly added rule must be present") require.Contains(t, got, "--dport 80", "the pre-existing modeled rule must be preserved") } func TestIPTablesDefaultPolicy(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT DROP [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() pol, err := f.GetDefaultPolicy(ctx, "") require.NoError(t, err) require.Equal(t, Accept, pol.Input) require.Equal(t, Drop, pol.Output) require.Equal(t, Accept, pol.Forward) // Set only the input direction. require.NoError(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Drop})) pol, err = f.GetDefaultPolicy(ctx, "") require.NoError(t, err) require.Equal(t, Drop, pol.Input, "input policy should be drop after set") require.Equal(t, Drop, pol.Output, "output policy left unchanged") require.Equal(t, Accept, pol.Forward, "forward policy left unchanged") // The change is applied to both family files. p6pol, err := f.policyFromFile(p6) require.NoError(t, err) require.Equal(t, Drop, p6pol.Input, "policy applied to the v6 file too") // Reject is not a valid iptables chain policy. require.Error(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Reject})) } // TestIPTablesDefaultPolicyIgnoresNATTable guards against the *nat table's // built-in :INPUT/:OUTPUT ACCEPT chains shadowing the real *filter policy. Both // real iptables-save and this library's own Restore emit the nat table after the // filter table, so a table-agnostic scan would let nat's ACCEPT overwrite a // hardened filter DROP on read, and stamp a non-ACCEPT policy onto the nat // built-in chains on write (which iptables-legacy-restore rejects). func TestIPTablesDefaultPolicyIgnoresNATTable(t *testing.T) { dir := t.TempDir() // Filter is DROP on input/output; the nat table declares its own chains ACCEPT // and comes afterwards, exactly as iptables-save lays it out. save := "*filter\n:INPUT DROP [0:0]\n:OUTPUT DROP [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(save), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // The filter DROP must win over the nat table's later ACCEPT. pol, err := f.GetDefaultPolicy(ctx, "") require.NoError(t, err) require.Equal(t, Drop, pol.Input, "filter INPUT DROP must not be shadowed by nat :INPUT ACCEPT") require.Equal(t, Drop, pol.Output, "filter OUTPUT DROP must not be shadowed by nat :OUTPUT ACCEPT") // Setting a policy must only touch the filter chains, leaving the nat built-in // chains ACCEPT (a non-ACCEPT policy on a nat built-in chain is invalid). require.NoError(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Accept, Output: Accept, Forward: Accept})) data, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(data), "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]", "the nat table's built-in chains must stay ACCEPT after a policy write") } // TestIPTablesLayoutDetection covers probeRHELLayout/probeDebianLayout's file // presence rules directly, without a live D-Bus/systemd (which NewIPTables // itself requires and which these helpers do not touch). func TestIPTablesLayoutDetection(t *testing.T) { // Neither layout present. root := t.TempDir() _, ok := probeRHELLayout(root) require.False(t, ok, "the RHEL layout should not be found with no save files present") _, ok = probeDebianLayout(root) require.False(t, ok, "the Debian layout should not be found with no save files present") // RHEL layout: both save files present. root = t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "sysconfig"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "iptables"), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "ip6tables"), nil, 0644)) l, ok := probeRHELLayout(root) require.True(t, ok, "the RHEL layout should be found when both save files are present") require.Equal(t, "iptables", l.ip4Service) require.Equal(t, "ip6tables", l.ip6Service) require.Equal(t, "/etc/sysconfig/ipset", l.ipsetPath, "the RHEL layout persists sets to the ipset-service compat file") require.Equal(t, "ipset", l.ipsetService) require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset service, not a plugin file") // RHEL layout: v4 present but v6 missing is an incomplete pair, not a match. root = t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "sysconfig"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "iptables"), nil, 0644)) _, ok = probeRHELLayout(root) require.False(t, ok, "an incomplete RHEL save-file pair should not be reported as a match") // Debian layout: both save files present, one shared service for both families. root = t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "iptables"), 0755)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v4"), nil, 0644)) require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v6"), nil, 0644)) l, ok = probeDebianLayout(root) require.True(t, ok, "the Debian layout should be found when both save files are present") require.Equal(t, "netfilter-persistent", l.ip4Service) require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one service") require.Equal(t, "/etc/iptables/ipsets", l.ipsetPath, "the Debian layout persists sets alongside the rules files") require.NotEmpty(t, l.ipsetPlugin, "the Debian layout gates ipset persistence on the netfilter-persistent plugin file") } func TestCombineSplitComment(t *testing.T) { // strip returns just the user-facing text of a stored comment, discarding the // prefix signal (the inverse direction of combineComment). strip := func(prefix, comment string) string { text, _ := prefixedComment(prefix, comment) return text } // No prefix: the user comment passes through unchanged. require.Equal(t, "ssh", combineComment("", "ssh")) require.Equal(t, "ssh", strip("", "ssh")) // Prefix-only tag. require.Equal(t, "myapp", combineComment("myapp", "")) require.Equal(t, "", strip("myapp", "myapp")) // Prefix + user comment. require.Equal(t, "myapp ssh", combineComment("myapp", "ssh")) require.Equal(t, "ssh", strip("myapp", "myapp ssh")) // A comment without our prefix belongs to another tool; leave it intact. require.Equal(t, "external", strip("myapp", "external")) // Separator is a single space; a prefix-like substring mid-comment is not // stripped. require.Equal(t, "not myapp ssh", strip("myapp", "not myapp ssh")) } // A rule with a user comment keeps the configured prefix as part of the stored // comment so rules this library creates stay identifiable. func TestIPTablesCommentCarriesPrefix(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} ctx := context.Background() // No user comment: only the prefix is stored. require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) // User comment: stored comment is " ". require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"})) // Reading back recovers only the user-facing text. rules, err := fw.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]*Rule{} for _, r := range rules { byPort[r.Port] = r } require.Equal(t, "", byPort[22].Comment, "prefix tag must not surface as a comment") require.Equal(t, "https", byPort[443].Comment, "user comment should round-trip") // The saved file carries the prefix alongside the user comment. data, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(data), `-m comment --comment "myapp https"`) } // A comment containing a backslash, an embedded double-quote or a non-ASCII // rune must round-trip byte-for-byte: strconv.Quote (the previous encoding) // renders these as Go string-literal escapes that shlex.Split — the parser // GetRules reads such a line back with — does not interpret, so the comment // came back mangled (e.g. a literal tab as a two-character "\t") and never // compared equal to the desired rule, so Sync churned on it forever. func TestIPTablesCommentSpecialCharsRoundTrip(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() cases := []struct { port uint16 comment string }{ {22, `back\slash`}, {23, `quote"inside`}, {24, "tab\ttab"}, {25, "unicode ключ"}, } for _, c := range cases { require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: c.port, Action: Accept, Comment: c.comment})) } rules, err := fw.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]*Rule{} for _, r := range rules { byPort[r.Port] = r } for _, c := range cases { require.Equal(t, c.comment, byPort[c.port].Comment, "comment %q must round-trip unchanged", c.comment) } // A literal newline cannot be expressed (it would split the rules file's // one-line-per-rule format), so it is rejected rather than silently mangled. err = fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 26, Action: Accept, Comment: "line1\nline2"}) require.Error(t, err, "a comment containing a newline must be rejected") } // A user comment that itself begins with the configured prefix must survive the // round-trip intact: GetRules strips the prefix exactly once, so a comment of // "myapp is great" (stored as "myapp myapp is great") must read back whole and // not be truncated to "is great". func TestIPTablesCommentBeginningWithPrefix(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} ctx := context.Background() require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 8080, Action: Accept, Comment: "myapp is great"})) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1) require.Equal(t, "myapp is great", rules[0].Comment, "prefix must be stripped exactly once") require.True(t, rules[0].HasPrefix) } // iptables-save canonicalizes a bare host address to its /32 (or /128) form, so // a rule this library wrote as a bare address must still match the canonicalized // read-back. func TestIPTablesHostPrefixRoundTrip(t *testing.T) { orig := &Rule{Family: IPv4, Proto: TCP, Source: "192.168.1.1", Port: 22, Action: Accept} got, err := unmarshalIPTablesRule("-A INPUT -s 192.168.1.1/32 -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) require.NoError(t, err) require.True(t, got.EqualBase(orig, true), "bare host must match iptables-save /32 form; got %q", got.Source) } // A single contiguous port range on a `-m tcp/udp/sctp --dport` — the form // iptables-save emits and ufw's before.rules use — must parse. The module-match // handlers previously accepted only a scalar port, so GetRules silently dropped // the rule (re-adding a duplicate every reconcile). func TestIPTablesModulePortRangeParse(t *testing.T) { cases := []struct { spec string src bool }{ {`-A INPUT -p tcp -m tcp --dport 1000:2000 -j ACCEPT`, false}, {`-A INPUT -p tcp -m tcp --sport 1000:2000 -j ACCEPT`, true}, {`-A INPUT -p udp -m udp --dport 1000:2000 -j ACCEPT`, false}, {`-A INPUT -p sctp -m sctp --dport 1000:2000 -j ACCEPT`, false}, } for _, c := range cases { r, err := unmarshalIPTablesRule(c.spec, IPv4) require.NoError(t, err, "range on module match must parse: %s", c.spec) specs := r.PortSpecs() if c.src { specs = r.SourcePortSpecs() } require.Equal(t, []PortRange{{Start: 1000, End: 2000}}, specs, "range not captured: %s", c.spec) } } // iptables applies and prints a default --limit-burst 5 on every -m limit match. // A rule added with Burst 0 must still compare equal to the one iptables-save // lists back with burst 5 (mirrors the nft burst-5 normalization). func TestIPTablesRateBurstDefaultNormalized(t *testing.T) { orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} saved := `-A INPUT -p tcp -m tcp --dport 22 -m limit --limit 10/min --limit-burst 5 -j ACCEPT` got, err := unmarshalIPTablesRule(saved, IPv4) require.NoError(t, err) require.NotNil(t, got.RateLimit) require.Equal(t, uint(0), got.RateLimit.Burst, "iptables' default burst of 5 must normalize to the unset 0") require.True(t, got.EqualBase(orig, true), "a burst-5 read-back must equal the burst-0 original") } // A DNAT rule on SCTP (which carries ports) must round-trip: the `-m sctp // --dport` match iptables emits was previously not parsed back. func TestIPTablesSCTPNATRoundTrip(t *testing.T) { f := &IPTables{rulePrefix: "myapp"} orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 132, ToAddress: "10.0.0.5", ToPort: 132} spec, err := f.MarshalNATRule(orig) require.NoError(t, err) got, err := f.UnmarshalNATRule(spec, IPv4) require.NoError(t, err) require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got) } // iptables labels an ICMPv6 type with an ICMPv6 name, several of which mean a // different number under ICMPv4 (echo-request is 128 vs 8, destination-unreachable // is 1 vs 3). The `--icmpv6-type`/`-m icmp6` flags must resolve names through the // ICMPv6 table, not the ICMPv4 one. Library-written rules emit the number, so the // round-trip tests never exercised the name path — but ufw's before6.rules do. func TestIPTablesICMPv6TypeNameParse(t *testing.T) { // -m icmp6 module form. r, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type echo-request -j ACCEPT", IPv6) require.NoError(t, err) require.NotNil(t, r.ICMPType) require.Equal(t, uint8(128), *r.ICMPType, "icmpv6 echo-request is type 128") // Bare --icmpv6-type form (no -m icmp6). r2, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT", IPv6) require.NoError(t, err) require.NotNil(t, r2.ICMPType) require.Equal(t, uint8(1), *r2.ICMPType, "icmpv6 destination-unreachable is type 1") // The ICMPv4 name path must be unchanged. r3, err := unmarshalIPTablesRule("-A INPUT -p icmp --icmp-type echo-request -j ACCEPT", IPv4) require.NoError(t, err) require.NotNil(t, r3.ICMPType) require.Equal(t, uint8(8), *r3.ICMPType, "icmp echo-request is type 8") } // iptables keeps each family in its own save file, so an IPv6 rule's position counts // only the ip6tables chain it lives in. GetRules must number the two families // independently — numbering the concatenation would offset every IPv6 rule by the // length of the IPv4 chain and send InsertRule/MoveRule to the wrong line. func TestIPTablesNumbersEachFamilyChainIndependently(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // Two rules per family, all input direction. for _, r := range []*Rule{ {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv6, Port: 443, Proto: TCP, Action: Accept}, } { require.NoError(t, f.AddRule(ctx, "", r)) } got, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, got, 4, "each save-file line is its own rule; nothing is collapsed") // Within each family's INPUT chain the numbers run 1..2, and no rule is reported // as FamilyAny — a save-file line always belongs to exactly one family. perFamily := map[Family][]int{} for _, r := range got { require.NotEqual(t, FamilyAny, r.Family, "an iptables line always names one family") perFamily[r.Family] = append(perFamily[r.Family], r.Number) } require.Equal(t, []int{1, 2}, perFamily[IPv4], "the IPv4 INPUT chain numbers 1..2") require.Equal(t, []int{1, 2}, perFamily[IPv6], "the IPv6 INPUT chain numbers 1..2, not 3..4") } // GetDefaultPolicy reads both family save files: it returns the shared policy // when they agree and errors when they diverge, rather than silently reporting // only the IPv4 policy. func TestIPTablesGetDefaultPolicyBothFamilies(t *testing.T) { dir := t.TempDir() write := func(name, in string) string { p := filepath.Join(dir, name) body := "*filter\n:INPUT " + in + " [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" require.NoError(t, os.WriteFile(p, []byte(body), 0644)) return p } ctx := context.Background() // Agreement: both files DROP input -> policy reports DROP. f := &IPTables{IP4Path: write("iptables", "DROP"), IP6Path: write("ip6tables", "DROP")} pol, err := f.GetDefaultPolicy(ctx, "") require.NoError(t, err) require.Equal(t, Drop, pol.Input) // Divergence: v4 ACCEPT, v6 DROP -> error, because there is no single policy. f = &IPTables{IP4Path: write("iptables2", "ACCEPT"), IP6Path: write("ip6tables2", "DROP")} _, err = f.GetDefaultPolicy(ctx, "") require.Error(t, err, "a v4/v6 policy mismatch must be surfaced, not hidden") } // iptables Backup captures only the INPUT/OUTPUT filter rules and the nat rules, // so Restore must splice those back into the existing save file and leave // everything it did not capture untouched: chain default policies, the FORWARD // chain, user-defined chains, and the *mangle/*raw tables. The old scaffold-based // Restore silently reset a DROP policy to ACCEPT and deleted all of that. func TestIPTablesRestorePreservesUnmanaged(t *testing.T) { dir := t.TempDir() // A realistic save file: hardened DROP policies, a FORWARD rule, a *mangle // table, and a *nat table with a managed DNAT plus a foreign DOCKER chain. save := "*mangle\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + "-A PREROUTING -j MARK --set-mark 1\nCOMMIT\n" + "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n:DOCKER - [0:0]\n" + "-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.0.0.5:8080\n-A DOCKER -j RETURN\nCOMMIT\n" + "*filter\n:INPUT DROP [0:0]\n:OUTPUT DROP [0:0]\n:FORWARD DROP [0:0]\n" + "-A INPUT -p tcp --dport 22 -j ACCEPT\n-A FORWARD -s 10.0.0.0/8 -j ACCEPT\nCOMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() backup, err := f.Backup(ctx, "") require.NoError(t, err) require.NoError(t, f.Restore(ctx, "", backup)) out, err := os.ReadFile(p4) require.NoError(t, err) got := string(out) // Policies and unmanaged content Backup never captured must survive. require.Contains(t, got, ":INPUT DROP", "the INPUT DROP policy must not flip to ACCEPT") require.Contains(t, got, ":FORWARD DROP", "the FORWARD DROP policy must survive") require.Contains(t, got, "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", "the FORWARD rule must survive") require.Contains(t, got, "*mangle", "the mangle table must survive") require.Contains(t, got, "MARK --set-mark 1", "the mangle rule must survive") require.Contains(t, got, ":DOCKER", "the foreign nat chain must survive") require.Contains(t, got, "-A DOCKER -j RETURN", "the foreign nat chain's rule must survive") // The managed rules Backup captured must be re-applied. require.Contains(t, got, "--dport 22", "the managed INPUT rule must be restored") require.Contains(t, got, "DNAT", "the managed nat rule must be restored") // Restore must be idempotent: a second Backup/Restore reproduces the same file. backup2, err := f.Backup(ctx, "") require.NoError(t, err) require.NoError(t, f.Restore(ctx, "", backup2)) out2, err := os.ReadFile(p4) require.NoError(t, err) require.Equal(t, got, string(out2), "Restore must be idempotent") } // TestIPTablesInsertForeignChainPosition guards the InsertRule position counting // against a foreign chain whose name merely starts with the target chain name // (e.g. firewalld's "INPUT_direct"). GetRules numbers only exact INPUT/OUTPUT // rules, so the insert path must count the same way. With the prefix-match bug // the foreign line is miscounted and the new rule lands one slot too early. func TestIPTablesInsertForeignChainPosition(t *testing.T) { dir := t.TempDir() // A foreign INPUT_direct chain precedes two managed INPUT rules. GetRules // reports the INPUT rules as #1 (dport 22) and #2 (dport 80); the // INPUT_direct line is not an INPUT rule and must not be counted. save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT_direct -j DROP\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "COMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // Insert at INPUT position 2, i.e. between dport 22 (#1) and dport 80 (#2). require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) var got *Rule for _, r := range rules { if r.Port == 443 { got = r } } require.NotNil(t, got, "the inserted rule should be present after InsertRule") require.Equal(t, 2, got.Number, "the rule must land at INPUT position 2, not be miscounted past the foreign INPUT_direct chain") } // TestIPTablesMoveForeignChainPosition is the MoveRule analogue: moving a rule to // a 1-based position must count only exact INPUT rules, ignoring a foreign chain // whose name starts with INPUT. func TestIPTablesMoveForeignChainPosition(t *testing.T) { dir := t.TempDir() // INPUT rules on read: #1 dport 22, #2 dport 80, #3 dport 443. Move dport 443 // to position 1; it must become #1 with 22 and 80 shifting down. save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT_direct -j DROP\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT\n" + "COMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() require.NoError(t, fw.MoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, 1)) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]int{} for _, r := range rules { byPort[r.Port] = r.Number } require.Equal(t, 1, byPort[443], "moved rule must land at INPUT position 1, ahead of the foreign chain miscount") require.Equal(t, 2, byPort[22], "the previously-first rule shifts to position 2") require.Equal(t, 3, byPort[80], "the previously-second rule shifts to position 3") } // TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A` // lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with // its action is one logical rule beginning at the LOG line, an orphan LOG line // begins none, and a foreign line the parser rejects (nil) is an ordinary rule. func TestIPTablesLogicalStarts(t *testing.T) { f := new(IPTables) logRule := func(proto Protocol, port uint16) *Rule { return &Rule{Proto: proto, Port: port, Log: true, Action: ActionInvalid} } act := func(proto Protocol, port uint16) *Rule { return &Rule{Proto: proto, Port: port, Action: Accept} } cases := []struct { name string rules []*Rule want []int }{ {"plain rules", []*Rule{act(TCP, 22), act(TCP, 80)}, []int{1, 2}}, {"logged pair is one logical rule", []*Rule{logRule(TCP, 22), act(TCP, 22), act(TCP, 80)}, []int{1, 0, 2}}, {"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}}, {"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}}, {"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}}, {"foreign nil line counts as a rule", []*Rule{nil, act(TCP, 22)}, []int{1, 2}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { require.Equal(t, tc.want, f.logicalStarts(tc.rules)) }) } } // TestIPTablesInsertPastConsecutiveOrphanLogs pins the walker fix: two foreign // orphan LOG lines (no matching action) that GetRules drops must not shift the // 1-based insert position. The old stateful walker swallowed the line after each // LOG line, so with two consecutive orphan LOGs an insert drifted one position. func TestIPTablesInsertPastConsecutiveOrphanLogs(t *testing.T) { dir := t.TempDir() save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"\n" + "-A INPUT -p udp -m udp --dport 123 -j LOG --log-prefix \"ntp: \"\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "COMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // GetRules reports #1 dport 22, #2 dport 80 (the orphan LOGs are not rules). // Insert at position 2, i.e. between them. require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]int{} for _, r := range rules { byPort[r.Port] = r.Number } require.Equal(t, 1, byPort[22], "the first rule stays at position 1") require.Equal(t, 2, byPort[443], "inserted rule must land at INPUT position 2, past the two orphan LOG lines") require.Equal(t, 3, byPort[80], "the previously-second rule shifts to position 3") // The foreign orphan LOG lines must survive the insert. got, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(got), "dns: ", "orphan LOG line must be preserved") require.Contains(t, string(got), "ntp: ", "orphan LOG line must be preserved") } // TestIPTablesMovePastConsecutiveOrphanLogs is the MoveRule analogue: moving a // rule to a position after two orphan LOG lines must count only logical rules. func TestIPTablesMovePastConsecutiveOrphanLogs(t *testing.T) { dir := t.TempDir() save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"\n" + "-A INPUT -p udp -m udp --dport 123 -j LOG --log-prefix \"ntp: \"\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "COMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // Move dport 22 (currently #1) to position 2; it must end up #2 with dport 80 // at #1, not be miscounted onto the orphan LOG lines. require.NoError(t, fw.MoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, 2)) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]int{} for _, r := range rules { byPort[r.Port] = r.Number } require.Equal(t, 1, byPort[80], "the previously-second rule becomes position 1") require.Equal(t, 2, byPort[22], "the moved rule must land at position 2, past the two orphan LOG lines") } // A counter-annotated (iptables-save -c) nat line must parse, matching the // filter parser. The nat parser previously did not strip the leading // [pkts:bytes] prefix, so a counter-annotated save file's NAT rules were // silently dropped from GetNATRules/Backup and could never be removed. func TestIPTablesNATCounterPrefix(t *testing.T) { fw := new(IPTables) plain := `-A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.1` counter := `[0:0] -A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.1` rp, err := fw.UnmarshalNATRule(plain, IPv4) require.NoError(t, err) rc, err := fw.UnmarshalNATRule(counter, IPv4) require.NoError(t, err, "counter-prefixed NAT rule should parse") require.True(t, rp.EqualBase(rc), "counter-prefixed NAT rule should equal the plain one") } // A "replace" must remove a pre-existing counter-annotated filter rule. The // filter parser reads such lines (populating Packets/Bytes), so the file-rewrite // path must recognise them as rules too — otherwise ReplaceRulesBatch/Restore // leaves stale foreign rules behind. func TestIPTablesReplaceStripsCounterRules(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") v4 := strings.Join([]string{ "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]", "[7:420] -A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", "COMMIT", "", }, "\n") require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) f := &IPTables{IP4Path: p4, IP6Path: p6} require.NoError(t, f.ReplaceRulesBatch(context.Background(), "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})) got, err := os.ReadFile(p4) require.NoError(t, err) require.NotContains(t, string(got), "9999", "stale counter-annotated rule must be removed by replace; file:\n%s", got) require.Contains(t, string(got), "--dport 22", "desired rule must be present") } // A batch rewrite must preserve rules in chains the library does not model. The // backend manages only INPUT/OUTPUT, and parseFilterFile drops every other chain // (FORWARD, custom chains) on read — so if the file-rewrite path deletes all -A // lines, an AddRulesBatch/ReplaceRulesBatch/Sync silently destroys FORWARD rules // it never surfaced and the caller could not have listed to keep. // The FORWARD chain is now a modeled direction: GetRules surfaces its rules and a // batch reconcile manages them like INPUT/OUTPUT, while a rule in a user-defined // chain is still preserved verbatim. func TestIPTablesBatchManagesForwardRules(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") v4 := strings.Join([]string{ "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]", "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", "-A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", "-A CUSTOM -j ACCEPT", "COMMIT", "", }, "\n") require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) f := &IPTables{IP4Path: p4, IP6Path: p6} // The FORWARD rule now surfaces as a modeled forward-direction rule. rules, err := f.GetRules(context.Background(), "") require.NoError(t, err) fwd := &Rule{Direction: DirForward, Family: IPv4, Source: "10.0.0.0/8", Action: Accept} found := false for _, r := range rules { if r.Equal(fwd, true) { found = true } } require.True(t, found, "the FORWARD rule should be modeled; got %+v", rules) // Additive batch (AddRules → AddRulesBatch): existing rules survive. require.NoError(t, f.AddRulesBatch(context.Background(), "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})) got, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(got), "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", "AddRulesBatch keeps existing FORWARD rules; file:\n%s", got) require.Contains(t, string(got), "--dport 22", "desired rule must be present") // Full replace (ReplaceRulesBatch): every modeled chain — INPUT, OUTPUT and // FORWARD — is reconciled to exactly the desired set, so the unlisted FORWARD and // INPUT rules are removed and a desired FORWARD rule is added; a rule in a // user-defined chain is still preserved verbatim. desired := []*Rule{ {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Direction: DirForward, Family: IPv4, Source: "192.168.0.0/16", Action: Accept}, } require.NoError(t, f.ReplaceRulesBatch(context.Background(), "", desired)) got, err = os.ReadFile(p4) require.NoError(t, err) require.NotContains(t, string(got), "10.0.0.0/8", "ReplaceRulesBatch reconciles away the unwanted FORWARD rule; file:\n%s", got) require.NotContains(t, string(got), "9999", "ReplaceRulesBatch reconciles away the unwanted INPUT rule; file:\n%s", got) require.Contains(t, string(got), "-A FORWARD -s 192.168.0.0/16 -j ACCEPT", "the desired FORWARD rule is added; file:\n%s", got) require.Contains(t, string(got), "--dport 22", "desired input rule present") require.Contains(t, string(got), "-A CUSTOM -j ACCEPT", "a user-defined chain rule is preserved; file:\n%s", got) } // A negated match in a -m udp/sctp block must not be mis-parsed. Grouping "!" // with --source-port consumed the following token and parsed the option name as a // port; the loop must reject the negation like the tcp loop and still parse a // normal udp source/destination port. func TestIPTablesUDPNegationParse(t *testing.T) { fw := new(IPTables) // A normal udp source-port rule still round-trips (regression guard). got, err := fw.UnmarshalRule("-A INPUT -p udp -m udp --sport 53 -j ACCEPT", IPv4) require.NoError(t, err) require.Equal(t, uint16(53), got.SourcePort) // A negated port match cannot be represented and must be rejected cleanly. _, err = fw.UnmarshalRule("-A INPUT -p udp -m udp ! --dport 80 -j ACCEPT", IPv4) require.Error(t, err, "a negated udp port match must be rejected, not mis-parsed") } // A batch rewrite must preserve a standalone LOG rule (no terminal action). Such a // rule cannot be modeled as a Rule, so GetRules drops it; if the file-rewrite path // also drops it, an AddRulesBatch silently deletes a foreign audit-log rule. func TestIPTablesBatchPreservesOrphanLog(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") v4 := strings.Join([]string{ "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]", `-A INPUT -j LOG --log-prefix "audit "`, "-A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", "COMMIT", "", }, "\n") require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) f := &IPTables{IP4Path: p4, IP6Path: p6} require.NoError(t, f.AddRulesBatch(context.Background(), "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})) got, err := os.ReadFile(p4) require.NoError(t, err) require.Contains(t, string(got), "-j LOG", "AddRulesBatch must not delete a standalone LOG rule; file:\n%s", got) require.Contains(t, string(got), "--dport 9999", "existing INPUT rule must survive an additive batch") require.Contains(t, string(got), "--dport 22", "desired rule must be present") } // iptables-save always appends the connlimit counting key (--connlimit-saddr by // default, or --connlimit-daddr) after a connlimit match. The parser did not // consume that trailing flag, so it errored ("unsupported option") and // parseFilterFile silently dropped the whole rule: a per-source connection-limit // rule vanished from GetRules/Backup and could never be reconciled or removed. func TestIPTablesConnlimitSaddrRoundTrip(t *testing.T) { fw := new(IPTables) // The exact form iptables-save emits for a per-source connlimit rule. saved := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 20 --connlimit-mask 32 --connlimit-saddr -j REJECT --reject-with icmp-port-unreachable" got, err := fw.UnmarshalRule(saved, IPv4) require.NoError(t, err, "connlimit rule with --connlimit-saddr must parse") want := &Rule{Family: IPv4, Proto: TCP, Port: 80, ConnLimit: &ConnLimit{Count: 20, PerSource: true}, Action: Reject} require.True(t, want.Equal(got, false), "per-source connlimit must round-trip: got %+v", got.ConnLimit) // --connlimit-daddr must also be consumed rather than dropping the rule. daddr := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 5 --connlimit-mask 24 --connlimit-daddr -j DROP" got, err = fw.UnmarshalRule(daddr, IPv4) require.NoError(t, err, "connlimit rule with --connlimit-daddr must parse") require.NotNil(t, got.ConnLimit) // A global (mask 0) connlimit still parses and counts globally. global := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 100 --connlimit-mask 0 --connlimit-saddr -j DROP" got, err = fw.UnmarshalRule(global, IPv4) require.NoError(t, err) require.False(t, got.ConnLimit.PerSource, "mask 0 must count globally") } // iptables-save spells an ICMP type carrying a code as `type/code` (e.g. `3/1`). // The parser rejected the token as an invalid type and dropped the whole rule; // the Rule model has no code field, so the type is taken and the code ignored. func TestIPTablesICMPTypeCode(t *testing.T) { fw := new(IPTables) got, err := fw.UnmarshalRule("-A INPUT -p icmp -m icmp --icmp-type 3/1 -j DROP", IPv4) require.NoError(t, err, "icmp type/code rule must parse") require.NotNil(t, got.ICMPType) require.Equal(t, uint8(3), *got.ICMPType) require.Equal(t, Drop, got.Action) } // MoveRule must operate only on the *filter table. A full iptables-save dump also // carries INPUT/OUTPUT chains in *nat/*mangle; extractRuleLines must not pull a // foreign rule out of one of those tables and splice it into *filter (which both // corrupts the source table and installs a foreign rule as a filter rule). func TestIPTablesMoveRuleFilterScope(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "COMMIT\n" + "*mangle\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 25 -j DROP\n" + "COMMIT\n" require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p4} // The dport-25 rule lives only in *mangle. Moving it must be a no-op on *filter. af, err := fw.prepareMoveRuleFile(p4, &Rule{Proto: TCP, Port: 25, Action: Drop}, 1) require.NoError(t, err) if af == nil { return // no-op: correct — the mangle rule was not matched as a filter rule } require.NoError(t, af.Commit()) data, err := os.ReadFile(p4) require.NoError(t, err) text := string(data) filterPart := text[strings.Index(text, "*filter"):strings.Index(text, "*mangle")] require.NotContains(t, filterPart, "--dport 25", "the *mangle rule must not be spliced into *filter") manglePart := text[strings.Index(text, "*mangle"):] require.Contains(t, manglePart, "--dport 25", "the *mangle rule must be preserved in *mangle") } // ipsetParseType decodes the family and set type from an `ipset save` create // line's fields, defaulting to IPv4 hash:ip. func TestIPSetParseType(t *testing.T) { f := new(IPTables) fam, typ := f.ipsetParseType([]string{"create", "s", "hash:ip"}) require.Equal(t, IPv4, fam) require.Equal(t, SetHashIP, typ) fam, typ = f.ipsetParseType([]string{"create", "s", "hash:net", "family", "inet6", "hashsize", "1024"}) require.Equal(t, IPv6, fam, "family inet6 must decode to IPv6") require.Equal(t, SetHashNet, typ) fam, typ = f.ipsetParseType([]string{"create", "s", "hash:ip", "family", "inet"}) require.Equal(t, IPv4, fam, "family inet stays IPv4") require.Equal(t, SetHashIP, typ) } // A foreign, standalone `-j LOG` audit line that does not belong to the rule // being removed must be preserved. The remove path holds a LOG line back as // "pending" and only folds it into the following action line when their match // fields agree (iptSameMatch). When they do not agree, removing the action line // must not also drop the unrelated LOG line. func TestRemoveRulePreservesUnrelatedLogLine(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "iptables.save") content := strings.Join([]string{ "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", `-A INPUT -j LOG --log-prefix "audit "`, "-A INPUT -p tcp --dport 22 -j ACCEPT", "COMMIT", "", }, "\n") require.NoError(t, os.WriteFile(path, []byte(content), 0644)) f := &IPTables{} // Remove the unlogged tcp/22 rule; the standalone audit LOG line is unrelated. target := &Rule{Proto: TCP, Port: 22, Action: Accept} af, err := f.prepareRemoveRuleFile(path, target) require.NoError(t, err) require.NotNil(t, af, "the rule should have been found and removed") require.NoError(t, af.Commit()) got, err := os.ReadFile(path) require.NoError(t, err) out := string(got) require.NotContains(t, out, "--dport 22", "the targeted rule must be removed") require.Contains(t, out, `LOG --log-prefix "audit "`, "an unrelated standalone LOG line must not be removed with the rule") } // The MoveRule extraction path has the mirror defect: a standalone `-j LOG` line // that does not coalesce with the moved rule must not be extracted and dragged to // the new position. func TestMoveRuleDoesNotDragUnrelatedLogLine(t *testing.T) { f := &IPTables{} lines := []string{ "*filter", ":INPUT ACCEPT [0:0]", `-A INPUT -j LOG --log-prefix "audit "`, "-A INPUT -p tcp --dport 22 -j ACCEPT", "COMMIT", } target := &Rule{Proto: TCP, Port: 22, Action: Accept} extracted, idx, err := f.extractRuleLines(lines, target) require.NoError(t, err) require.GreaterOrEqual(t, idx, 0, "the rule should have been found") require.Equal(t, []string{"-A INPUT -p tcp --dport 22 -j ACCEPT"}, extracted, "only the targeted action line must be extracted, not the unrelated LOG line") } func TestIPTablesLogLimitRoundTrip(t *testing.T) { f := &IPTables{} // A logged rule is two lines that coalesce back to one logical rule. logged := &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"} lines, err := f.marshalRuleLines(logged) require.NoError(t, err) require.Len(t, lines, 2, "a logged rule should be a LOG line plus an action line") var parsed []*Rule for _, l := range lines { r, perr := f.UnmarshalRule(l, IPv4) require.NoError(t, perr, "line %q", l) parsed = append(parsed, r) } coalesced := coalesceLoggedRules(parsed) require.Len(t, coalesced, 1) require.True(t, coalesced[0].EqualBase(logged, true), "want %+v got %+v", logged, coalesced[0]) // Rate and connection limits round-trip on a single line. for _, orig := range []*Rule{ // A non-default burst round-trips; the default of 5 is normalized to 0 (it // is indistinguishable from iptables' applied default), covered separately // by TestIPTablesRateBurstDefaultNormalized. {Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 3}}, {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}, {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: false}}, {Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, {Proto: TCP, Port: 80, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, } { spec, err := f.MarshalRule(orig) require.NoError(t, err) got, err := f.UnmarshalRule(spec, IPv4) require.NoError(t, err, "spec %q", spec) require.True(t, got.EqualBase(orig, true), "spec %q: want %+v got %+v", spec, orig, got) } } func TestIPTablesInsertAndMove(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() r1 := &Rule{Family: IPv4, Port: 1, Proto: TCP, Action: Accept} r2 := &Rule{Family: IPv4, Port: 2, Proto: TCP, Action: Accept} r3 := &Rule{Family: IPv4, Port: 3, Proto: TCP, Action: Accept} require.NoError(t, f.AddRule(ctx, "", r1)) require.NoError(t, f.AddRule(ctx, "", r2)) require.NoError(t, f.AddRule(ctx, "", r3)) // Move the first rule (r1) to the end (position 3). require.NoError(t, f.MoveRule(ctx, "", r1, 3)) rules, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 3) require.EqualValues(t, 3, rules[0].Port) require.EqualValues(t, 2, rules[1].Port) require.EqualValues(t, 1, rules[2].Port) // Move the first rule (r3) to position 2 (between r2 and r1). require.NoError(t, f.MoveRule(ctx, "", r3, 2)) rules, err = f.GetRules(ctx, "") require.NoError(t, err) require.EqualValues(t, 2, rules[0].Port) require.EqualValues(t, 3, rules[1].Port) require.EqualValues(t, 1, rules[2].Port) } // TestIPTablesRuleNumber verifies GetRules/GetNATRules populate the 1-based Number // per chain, and that a Number read back round-trips through MoveRule. func TestIPTablesRuleNumber(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // Two input rules and two output rules; each direction numbers from 1. in1 := &Rule{Family: IPv4, Port: 21, Proto: TCP, Action: Accept} in2 := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept} out1 := &Rule{Family: IPv4, Direction: DirOutput, Port: 80, Proto: TCP, Action: Accept} out2 := &Rule{Family: IPv4, Direction: DirOutput, Port: 443, Proto: TCP, Action: Accept} for _, r := range []*Rule{in1, in2, out1, out2} { require.NoError(t, f.AddRule(ctx, "", r)) } numByPort := func() map[uint16]int { rules, err := f.GetRules(ctx, "") require.NoError(t, err) m := map[uint16]int{} for _, r := range rules { m[r.Port] = r.Number } return m } // iptables AddRule prepends, so the most recently added rule in each chain is // number 1. Input and output chains are numbered independently. n := numByPort() require.Equal(t, 1, n[22], "last-added input rule is number 1") require.Equal(t, 2, n[21], "first-added input rule is number 2") require.Equal(t, 1, n[443], "output chain numbers independently from 1") require.Equal(t, 2, n[80]) // Number round-trips through MoveRule: move input rule 21 to the front (its // read-back Number was 2). require.NoError(t, f.MoveRule(ctx, "", in1, 1)) n = numByPort() require.Equal(t, 1, n[21], "input rule 21 moved to the front") require.Equal(t, 2, n[22], "input rule 22 shifted to number 2") require.Equal(t, 1, n[443], "an input-chain move leaves output numbering untouched") require.Equal(t, 2, n[80]) // NAT rules number per nat chain: dnat in prerouting, snat in postrouting. d1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 81, ToAddress: "10.0.0.1", ToPort: 8081} d2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 82, ToAddress: "10.0.0.2", ToPort: 8082} s1 := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"} for _, r := range []*NATRule{d1, d2, s1} { require.NoError(t, f.AddNATRule(ctx, "", r)) } natRules, err := f.GetNATRules(ctx, "") require.NoError(t, err) natByPort := map[uint16]int{} var snatNum int for _, r := range natRules { if r.Kind == SNAT { snatNum = r.Number continue } natByPort[r.Port] = r.Number } require.Equal(t, 1, natByPort[81], "first prerouting rule is number 1") require.Equal(t, 2, natByPort[82]) require.Equal(t, 1, snatNum, "postrouting chain numbers independently from 1") } // TestIPTablesSetReference verifies a non-address Source/Destination is written as // an ipset match (-m set --match-set) and round-trips through the save file. func TestIPTablesSetReference(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // A non-address Source names an ipset. r := &Rule{Family: IPv4, Source: "blocklist", Proto: TCP, Port: 22, Action: Drop} line, err := f.MarshalRule(r) require.NoError(t, err) require.Contains(t, line, "-m set --match-set blocklist src") require.NoError(t, f.AddRule(ctx, "", r)) rules, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1) require.Equal(t, "blocklist", rules[0].Source) require.True(t, rules[0].EqualBase(r, true), "set-reference rule must round-trip") // A negated source set uses the set match's internal `!`. neg := &Rule{Family: IPv4, Source: "!blocklist", Proto: TCP, Port: 25, Action: Drop} negLine, err := f.MarshalRule(neg) require.NoError(t, err) require.Contains(t, negLine, "-m set ! --match-set blocklist src") gotNeg, err := f.UnmarshalRule(negLine, IPv4) require.NoError(t, err) require.Equal(t, "!blocklist", gotNeg.Source) // A negated destination set on a NAT rule. nat := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, Destination: "!allowlist", ToAddress: "10.0.0.5", ToPort: 8080} spec, err := f.MarshalNATRule(nat) require.NoError(t, err) require.Contains(t, spec, "-m set ! --match-set allowlist dst") got, err := f.UnmarshalNATRule(spec, IPv4) require.NoError(t, err) require.Equal(t, "!allowlist", got.Destination) require.True(t, got.EqualBase(nat), "set-reference NAT rule must round-trip") } func TestIPTablesBackupRestore(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() r1 := &Rule{Family: IPv4, Port: 80, Proto: TCP, Action: Accept} r2 := &Rule{Family: IPv4, Port: 443, Proto: TCP, Action: Accept} require.NoError(t, f.AddRule(ctx, "", r1)) require.NoError(t, f.AddRule(ctx, "", r2)) backup, err := f.Backup(ctx, "") require.NoError(t, err) require.Len(t, backup.Rules, 2) require.NoError(t, f.RemoveRule(ctx, "", r1)) rules, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1) require.NoError(t, f.Restore(ctx, "", backup)) rules, err = f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 2) } func TestIPTablesNATRoundTrip(t *testing.T) { f := &IPTables{} cases := []*NATRule{ {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "eth0"}, {Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}, {Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "eth1"}, {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, } for _, orig := range cases { spec, err := f.MarshalNATRule(orig) require.NoError(t, err) got, err := f.UnmarshalNATRule(spec, IPv4) require.NoError(t, err, "spec %q", spec) require.True(t, got.EqualBase(orig), "spec %q: want %+v got %+v", spec, orig, got) } } // TestIPTablesLoggedRuleFile exercises the whole add/read/remove cycle for a // logged rule through the save files, covering the LOG+action pairing. func TestIPTablesLoggedRuleFile(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() logged := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"} require.NoError(t, f.AddRule(ctx, "", logged)) rules, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1, "logged rule should read back as one logical rule, got %+v", rules) require.True(t, rules[0].Log) require.True(t, rules[0].EqualBase(logged, true)) // A duplicate add is a no-op. require.NoError(t, f.AddRule(ctx, "", logged)) rules, _ = f.GetRules(ctx, "") require.Len(t, rules, 1, "duplicate add should not double the rule") // Removing drops both underlying lines. require.NoError(t, f.RemoveRule(ctx, "", logged)) rules, err = f.GetRules(ctx, "") require.NoError(t, err) require.Empty(t, rules, "expected no rules after removal, got %+v", rules) } // TestIPTablesNATFile exercises the nat-table add/read/remove cycle. func TestIPTablesNATFile(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() nat := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} require.NoError(t, f.AddNATRule(ctx, "", nat)) rules, err := f.GetNATRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1, "nat rule should read back, got %+v", rules) require.True(t, rules[0].EqualBase(nat)) require.NoError(t, f.RemoveNATRule(ctx, "", nat)) rules, err = f.GetNATRules(ctx, "") require.NoError(t, err) require.Empty(t, rules) } // TestIPTablesNATInsert exercises positioned NAT inserts through the save file: // position counts only rules in the target chain, so a PREROUTING insert never // disturbs the POSTROUTING order and a past-the-end position appends. func TestIPTablesNATInsert(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // dnat matches land in PREROUTING; the snat lands in POSTROUTING. d1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 81, ToAddress: "10.0.0.1", ToPort: 8081} d2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 82, ToAddress: "10.0.0.2", ToPort: 8082} d3 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 83, ToAddress: "10.0.0.3", ToPort: 8083} d4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 84, ToAddress: "10.0.0.4", ToPort: 8084} snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"} require.NoError(t, f.AddNATRule(ctx, "", d1)) require.NoError(t, f.AddNATRule(ctx, "", d2)) require.NoError(t, f.AddNATRule(ctx, "", snat)) // Insert at the front of PREROUTING. require.NoError(t, f.InsertNATRule(ctx, "", 1, d3)) // A position past the chain's end appends after its last rule. require.NoError(t, f.InsertNATRule(ctx, "", 100, d4)) // PREROUTING order reflects the inserts; POSTROUTING is untouched. require.Equal(t, []uint16{83, 81, 82, 84}, natMatchPorts(t, ctx, f, DNAT)) require.Equal(t, 1, natKindCount(t, ctx, f, SNAT), "snat should survive the prerouting inserts") // A duplicate insert is a no-op. require.NoError(t, f.InsertNATRule(ctx, "", 1, d1)) require.Equal(t, []uint16{83, 81, 82, 84}, natMatchPorts(t, ctx, f, DNAT)) } // natMatchPorts returns, in backend order, the matched ports of the NAT rules of // the given kind. func natMatchPorts(t *testing.T, ctx context.Context, m Manager, kind NATKind) []uint16 { t.Helper() rules, err := m.GetNATRules(ctx, "") require.NoError(t, err) var out []uint16 for _, r := range rules { if r.Kind == kind { out = append(out, r.Port) } } return out } // natKindCount returns the number of NAT rules of the given kind. func natKindCount(t *testing.T, ctx context.Context, m Manager, kind NATKind) int { t.Helper() rules, err := m.GetNATRules(ctx, "") require.NoError(t, err) n := 0 for _, r := range rules { if r.Kind == kind { n++ } } return n } func TestIPTablesProtocolAndComment(t *testing.T) { f := &IPTables{} cases := []*Rule{ {Proto: SCTP, Port: 9000, Action: Accept}, {Proto: GRE, Action: Accept}, {Proto: ESP, Action: Accept}, {Proto: AH, Action: Drop}, {Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"}, } for _, orig := range cases { spec, err := f.MarshalRule(orig) require.NoError(t, err) got, err := f.UnmarshalRule(spec, IPv4) require.NoError(t, err, "spec %q", spec) require.True(t, got.EqualBase(orig, true), "spec %q: want %+v got %+v", spec, orig, got) require.Equal(t, orig.Comment, got.Comment, "spec %q comment", spec) } } // tempIPTables builds an iptables backend backed by scratch save files. func tempIPTables(t *testing.T) *IPTables { t.Helper() dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) return &IPTables{IP4Path: p4, IP6Path: p6} } // AddRules (via the RuleBatcher path), Sync's minimal diff, and ReplaceRules all // operate correctly against the iptables backend. func TestAddRulesSyncReplace(t *testing.T) { f := tempIPTables(t) ctx := context.Background() // AddRules applies the whole batch, preserving a comment. rules := []*Rule{ {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 443, Proto: TCP, Action: Accept, Comment: "https"}, } require.NoError(t, AddRules(ctx, f, "", rules)) got, err := f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, got, 3) // A duplicate AddRules is a no-op. require.NoError(t, AddRules(ctx, f, "", rules)) got, err = f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, got, 3, "batch add should skip duplicates") // Sync toward a new set: keep 22, drop 80 and 443, add 8080. desired := []*Rule{ {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, {Family: IPv4, Port: 8080, Proto: TCP, Action: Accept}, } added, removed, err := Sync(ctx, f, "", desired) require.NoError(t, err) require.Equal(t, 1, added) require.Equal(t, 2, removed) got, err = f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, got, 2) // A second Sync to the same set is a no-op. added, removed, err = Sync(ctx, f, "", desired) require.NoError(t, err) require.Equal(t, 0, added) require.Equal(t, 0, removed) // ReplaceRules (atomic batcher) sets exactly one rule. require.NoError(t, ReplaceRules(ctx, f, "", []*Rule{{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}})) got, err = f.GetRules(ctx, "") require.NoError(t, err) require.Len(t, got, 1) require.EqualValues(t, 22, got[0].Port) } // iptables end-to-end: GetRules surfaces a foreign rule alongside ours with // HasPrefix flagging which carries our prefix. As a firewall interface, Backup // captures the full rule state and Sync reconciles every rule, not just ours. func TestIPTablesHasPrefixAndSync(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") scaffold6 := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" // One rule tagged with our prefix, one untagged rule with no prefix. body4 := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 22 -m comment --comment \"myapp\" -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 8080 -j ACCEPT\n" + "COMMIT\n" require.NoError(t, os.WriteFile(p4, []byte(body4), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold6), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} ctx := context.Background() rules, err := f.GetRules(ctx, "") require.NoError(t, err) byPort := map[uint16]*Rule{} for _, r := range rules { byPort[r.Port] = r } require.Contains(t, byPort, uint16(22)) require.Contains(t, byPort, uint16(8080)) require.True(t, byPort[22].HasPrefix, "the tagged rule carries our prefix") require.False(t, byPort[8080].HasPrefix, "the untagged rule does not") // Backup captures the full rule state, prefixed or not. backup, err := f.Backup(ctx, "") require.NoError(t, err) require.Len(t, backup.Rules, 2) // Sync toward an empty set reconciles the whole firewall: both rules go. _, removed, err := Sync(ctx, f, "", nil) require.NoError(t, err) require.Equal(t, 2, removed) after, err := f.GetRules(ctx, "") require.NoError(t, err) require.Empty(t, after, "Sync toward nil removes every rule") } // A NAT rule tagged with the prefix reads back with HasPrefix set; an untagged // one does not, and Backup captures the full NAT rule state. func TestIPTablesNATHasPrefixFlag(t *testing.T) { dir := t.TempDir() p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") empty := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" body4 := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A PREROUTING -p tcp -m tcp --dport 80 -m comment --comment \"myapp\" -j DNAT --to-destination 10.0.0.5:8080\n" + "-A PREROUTING -p tcp -m tcp --dport 443 -j DNAT --to-destination 10.0.0.6:8443\n" + "COMMIT\n" + empty require.NoError(t, os.WriteFile(p4, []byte(body4), 0644)) require.NoError(t, os.WriteFile(p6, []byte(empty), 0644)) f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} ctx := context.Background() nat, err := f.GetNATRules(ctx, "") require.NoError(t, err) byPort := map[uint16]*NATRule{} for _, r := range nat { byPort[r.Port] = r } require.Contains(t, byPort, uint16(80)) require.Contains(t, byPort, uint16(443)) require.True(t, byPort[80].HasPrefix, "the tagged NAT rule carries our prefix") require.False(t, byPort[443].HasPrefix, "the untagged NAT rule does not") backup, err := f.Backup(ctx, "") require.NoError(t, err) require.Len(t, backup.NATRules, 2) } // prefixedComment splits a stored comment into user text and a prefix signal: a // prefix-only tag has the prefix with empty text, a "prefix text" comment has the // prefix with the remainder, anything else lacks the prefix and is unchanged, and // an empty prefix yields no prefix signal (callers treat that as covering // everything). func TestPrefixedComment(t *testing.T) { cases := []struct { prefix, stored string wantText string wantHasPrefix bool }{ {"myapp", "myapp", "", true}, {"myapp", "myapp https", "https", true}, {"myapp", "someone else's note", "someone else's note", false}, {"myapp", "", "", false}, {"", "anything at all", "anything at all", false}, } for _, c := range cases { text, hasPrefix := prefixedComment(c.prefix, c.stored) require.Equal(t, c.wantText, text, "text for (%q,%q)", c.prefix, c.stored) require.Equal(t, c.wantHasPrefix, hasPrefix, "hasPrefix for (%q,%q)", c.prefix, c.stored) } } // A TCPUDP rule has no both-transports match in iptables, so AddRule must fan it // out into a `-p tcp` line and a `-p udp` line in each family file it applies to. // GetRules reports each line as its own rule; together they cover the rule. func TestIPTablesTCPUDPFanOutRoundTrip(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // A FamilyAny TCPUDP rule materializes as a tcp row plus a udp row in both files. require.NoError(t, fw.AddRule(ctx, "", &Rule{Proto: TCPUDP, Port: 22, Action: Accept})) for _, p := range []string{p4, p6} { data, err := os.ReadFile(p) require.NoError(t, err) require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", "tcp row missing in %s", p) require.Contains(t, string(data), "-A INPUT -p udp -m udp --dport 22 -j ACCEPT", "udp row missing in %s", p) } // GetRules reports the four physical rows as four rules — that is the firewall's // actual state — and together they cover exactly the rule that was written. rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 4, "a FamilyAny TCPUDP rule occupies four save-file lines") want := &Rule{Proto: TCPUDP, Port: 22, Action: Accept} require.True(t, want.CoveredBy(rules), "the four rows cover the rule that was added") for _, r := range rules { require.True(t, want.Covers(r), "no row may widen the rule: %+v", r) } } // Re-adding a TCPUDP rule is idempotent (each fanned-out row dedups), and one // RemoveRule of the TCPUDP rule clears both the tcp and the udp rows. func TestIPTablesTCPUDPIdempotentAddAndRemove(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() rule := &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept} require.NoError(t, fw.AddRule(ctx, "", rule)) require.NoError(t, fw.AddRule(ctx, "", rule)) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 2, "re-adding a TCPUDP rule must not duplicate its two rows") require.True(t, rule.CoveredBy(rules)) // One RemoveRule clears both fanned-out rows. require.NoError(t, fw.RemoveRule(ctx, "", rule)) data, err := os.ReadFile(p4) require.NoError(t, err) require.NotContains(t, string(data), "--dport 22", "both the tcp and udp rows must be removed") rules, err = fw.GetRules(ctx, "") require.NoError(t, err) require.Empty(t, rules, "removing the TCPUDP rule must clear the chain") } // Removing only the TCP half of a merged tcp/udp rule must leave the udp row in // place, and the read-back must report the surviving rule as Proto==UDP. iptables // only ever stores concrete-protocol rows, so this needs no dual-row split: the // concrete TCP target simply deletes the tcp row via EqualForRemoval/EqualBase. func TestIPTablesTCPUDPRemoveOneHalf(t *testing.T) { dir := t.TempDir() scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})) // Remove only the tcp half. require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) data, err := os.ReadFile(p4) require.NoError(t, err) require.NotContains(t, string(data), "-p tcp -m tcp --dport 22", "the tcp row must be removed") require.Contains(t, string(data), "-A INPUT -p udp -m udp --dport 22 -j ACCEPT", "the udp row must survive") rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 1, "only the udp row should remain") require.Equal(t, UDP, rules[0].Proto, "the surviving row must read back as Proto==UDP") } // InsertRule places a fanned-out tcp/udp pair as a block at the requested position. // Each chain line is its own rule, so a TCPUDP rule already in the chain occupies two // positions and the caller counts them both. func TestIPTablesTCPUDPInsertPositionPastTransportPair(t *testing.T) { dir := t.TempDir() // INPUT holds a tcp row and a udp row on port 22 (positions 1 and 2) followed by // a plain tcp rule on port 80 (position 3). save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + "-A INPUT -p udp -m udp --dport 22 -j ACCEPT\n" + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + "COMMIT\n" scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" p4 := filepath.Join(dir, "iptables") p6 := filepath.Join(dir, "ip6tables") require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) fw := &IPTables{IP4Path: p4, IP6Path: p6} ctx := context.Background() // Insert a TCPUDP rule at position 3, i.e. after both port-22 rows and before the // port-80 row. require.NoError(t, fw.InsertRule(ctx, "", 3, &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Action: Accept})) rules, err := fw.GetRules(ctx, "") require.NoError(t, err) require.Len(t, rules, 5, "the inserted pair adds two chain lines") // The port-443 rule now covers both transports, and the port-80 row is last. want := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Action: Accept} require.True(t, want.CoveredBy(rules), "both transports of the inserted rule must be present") require.Equal(t, 5, rules[4].Number, "the chain holds five rules") require.EqualValues(t, 80, rules[4].Port, "the port-80 rule shifts to the end") // Assert the physical placement: both port-22 rows precede both new port-443 // rows, which in turn precede the port-80 row — the insert must not land inside // the port-22 pair. data, err := os.ReadFile(p4) require.NoError(t, err) body := string(data) last22 := strings.LastIndex(body, "--dport 22") first443 := strings.Index(body, "--dport 443") last443 := strings.LastIndex(body, "--dport 443") first80 := strings.Index(body, "--dport 80") require.Less(t, last22, first443, "both port-22 rows must precede the port-443 pair, not be split by it") require.Less(t, last443, first80, "the port-443 pair must precede the port-80 row") } // MarshalRule on a TCPUDP rule must still error: it is the row-level marshaller, // and a TCPUDP rule must be fanned out into tcp and udp rows before reaching it. func TestIPTablesMarshalTCPUDPErrors(t *testing.T) { fw := new(IPTables) _, err := fw.MarshalRule(&Rule{Proto: TCPUDP, Port: 80, Action: Accept}) require.Error(t, err, "MarshalRule must reject an unexpanded TCPUDP rule") }