go-firewall/iptables_linux_test.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

1918 lines
86 KiB
Go

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 <name>` 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 "<prefix> <comment>".
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")
}