go-firewall/iptables_linux_test.go
2026-08-10 17:17:03 -05:00

1534 lines
71 KiB
Go

package firewall
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/vishvananda/netlink"
)
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 203.0.113.10 -p tcp -m tcp --dport 4789 -j ACCEPT`,
`-A OUTPUT -d 203.0.113.10 -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 validateRule
// must reject it before the encoder emits an invalid rule.
require.Error(t, fw.validateRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}),
"expected a port with no protocol to be rejected")
}
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")
}
// 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")
}
// TestIPTablesAddRulePreservesUnmodeledRule verifies an additive AddRule keeps a
// pre-existing INPUT/OUTPUT rule the parser cannot model (here an -m recent
// rate-limit rule). GetRules cannot represent such a rule, so a rewrite that
// dropped it would silently delete a foreign rule the read-modify-write add must
// keep.
func TestIPTablesAddRulePreservesUnmodeledRule(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 the FORWARD
// dport 8080, the forward chain being modeled too.
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.AddRule(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 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")
}
// 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)
}
// 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 must accept the range form, so GetRules reports the rule rather than
// dropping it (and 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 must parse 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")
}
// 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))
// A staging path keeps Backup's address-set read on the file rather than the
// live kernel, which an unprivileged test cannot query.
f := &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: filepath.Join(dir, "ipsets")}
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 displaced first rule shifts to position 2")
require.Equal(t, 3, byPort[80], "the displaced second rule shifts to position 3")
}
// scanSaveGroups numbers each chain's groups the way GetRules does, and the
// InsertRule/MoveRule position math reads those numbers straight off the same
// scan — so a divergence here breaks Rule.Number's promise to mirror the position
// argument. It pins the four cases that decide a number: a LOG line paired with
// the action line directly under it is one logical rule carrying both lines, an
// orphan LOG line resolves to no rule, a line the parser rejects resolves to no
// rule, and an intervening line of either kind breaks a pair that would otherwise
// have formed. Chains are numbered independently.
func TestIPTablesScanSaveGroupsNumbering(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 -p tcp -m tcp --dport 22 -j ACCEPT",
// A bare LOG line whose match fields agree with nothing below it.
`-A INPUT -j LOG --log-prefix "audit "`,
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT",
// A LOG line and the action line directly under it: one logged rule.
`-A INPUT -p tcp -m tcp --dport 443 -j LOG --log-prefix "https "`,
"-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT",
// A foreign line the model cannot hold, wedged between a LOG line and the
// action it would otherwise have paired with.
`-A INPUT -p tcp -m tcp --dport 8443 -j LOG --log-prefix "alt "`,
"-A INPUT -p tcp -m tcp --tcp-flags SYN,ACK SYN -j DROP",
"-A INPUT -p tcp -m tcp --dport 8443 -j ACCEPT",
"-A OUTPUT -p tcp -m tcp --dport 25 -j ACCEPT",
"COMMIT",
"",
}, "\n")
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
type got struct {
chain string
number int
resolve bool // The group resolved to a rule GetRules would report.
lines int
}
var seen []got
f := &IPTables{}
require.NoError(t, f.scanSaveFile(path, IPv4, func(g iptGroup) error {
if g.chain != "" {
seen = append(seen, got{g.chain, g.number, g.rule != nil, len(g.raw)})
}
return nil
}))
require.Equal(t, []got{
{"INPUT", 1, true, 1}, // dport 22
{"INPUT", 0, false, 1}, // orphan LOG: no rule, no number
{"INPUT", 2, true, 1}, // dport 80
{"INPUT", 3, true, 2}, // the logged pair, both lines in one group
{"INPUT", 0, false, 1}, // LOG line the foreign line below orphaned
{"INPUT", 0, false, 1}, // the unmodeled --tcp-flags line
{"INPUT", 4, true, 1}, // dport 8443, its LOG partnership broken
{"OUTPUT", 1, true, 1}, // OUTPUT numbers from 1 independently
}, seen)
}
// 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 displaced 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 displaced 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 must strip the leading [pkts:bytes] prefix, so
// a counter-annotated save file's NAT rules survive in GetNATRules/Backup and
// can 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 a Sync-based replace or
// 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}
_, _, err := Sync(context.Background(), f, "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})
require.NoError(t, err)
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 reconcile must preserve rules in chains the library does not model. A rule in
// a user-defined chain (CUSTOM) is never surfaced by GetRules, so the file-rewrite
// add and remove paths must keep it verbatim. The FORWARD chain, by contrast, is a
// modeled direction: GetRules surfaces its rules and a Sync-based reconcile manages
// them like INPUT/OUTPUT.
func TestIPTablesManagesForwardRules(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 add: existing rules survive.
require.NoError(t, f.AddRule(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", "an additive add keeps existing FORWARD rules; file:\n%s", got)
require.Contains(t, string(got), "--dport 22", "desired rule must be present")
// Full replace via Sync: 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},
}
_, _, err = Sync(context.Background(), f, "", desired)
require.NoError(t, err)
got, err = os.ReadFile(p4)
require.NoError(t, err)
require.NotContains(t, string(got), "10.0.0.0/8", "the replace reconciles away the unwanted FORWARD rule; file:\n%s", got)
require.NotContains(t, string(got), "9999", "the replace 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.
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")
}
// An additive add 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 AddRule silently deletes a foreign audit-log rule.
func TestIPTablesAddPreservesOrphanLog(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.AddRule(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", "an additive add 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 add")
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; the move 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}
// The dport-25 rule lives only in *mangle. Moving it must be a no-op on *filter
// and leave the file untouched.
require.NoError(t, fw.MoveRule(context.Background(), "", &Rule{Proto: TCP, Port: 25, Action: Drop}, 1))
data, err := os.ReadFile(p4)
require.NoError(t, err)
require.Equal(t, save, string(data), "a rule living only in *mangle must not be moved as a filter rule")
}
// 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{IP4Path: path}
// Remove the unlogged tcp/22 rule; the standalone audit LOG line is unrelated.
target := &Rule{Proto: TCP, Port: 22, Action: Accept}
require.NoError(t, f.RemoveRule(context.Background(), "", target))
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 path has the mirror defect: a standalone `-j LOG` line that does
// not coalesce with the moved rule must not be lifted and dragged to the new
// position. Moving dport 80 to position 1 must reorder it ahead of dport 22 while
// the audit line stays exactly where it was — it begins no logical rule, so it
// neither takes a position nor travels with one.
func TestMoveRuleDoesNotDragUnrelatedLogLine(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "iptables.save")
content := strings.Join([]string{
"*filter",
":INPUT ACCEPT [0:0]",
`-A INPUT -j LOG --log-prefix "audit "`,
"-A INPUT -p tcp --dport 22 -j ACCEPT",
"-A INPUT -p tcp --dport 80 -j ACCEPT",
"COMMIT",
"",
}, "\n")
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
f := &IPTables{IP4Path: path}
require.NoError(t, f.MoveRule(context.Background(), "", &Rule{Proto: TCP, Port: 80, Action: Accept}, 1))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, []string{
"*filter",
":INPUT ACCEPT [0:0]",
`-A INPUT -j LOG --log-prefix "audit "`,
"-A INPUT -p tcp --dport 80 -j ACCEPT",
"-A INPUT -p tcp --dport 22 -j ACCEPT",
"COMMIT",
}, strings.Split(strings.TrimRight(string(data), "\n"), "\n"))
}
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)
}
}
// Address sets are staged in the ipset file and reach the kernel only on Reload,
// so every mutation is a file edit and every read comes back off the file. None
// of this shells out to ipset, which is what lets the test run anywhere.
func TestIPTablesAddressSetStaging(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ipsets")
f := &IPTables{IPSetPath: path}
set := &AddressSet{Name: "block", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8::/64"}}
require.NoError(t, f.AddAddressSet(ctx, set))
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "create block hash:net family inet6\nadd block 2001:db8::/64\n", string(data),
"the staging file holds ipset save format, not a dump of live state")
// Entries are staged into the file, and adding one already there is a no-op.
require.NoError(t, f.AddAddressSetEntry(ctx, "block", "2001:db8:1::/64"))
require.NoError(t, f.AddAddressSetEntry(ctx, "block", "2001:db8:1::/64"))
got, err := f.GetAddressSet(ctx, "block")
require.NoError(t, err)
require.Equal(t, []string{"2001:db8::/64", "2001:db8:1::/64"}, got.Entries)
require.Equal(t, IPv6, got.Family)
require.Equal(t, SetHashNet, got.Type)
require.NoError(t, f.RemoveAddressSetEntry(ctx, "block", "2001:db8::/64"))
// A missing entry, and an entry on a set that is not staged, are both no-ops.
require.NoError(t, f.RemoveAddressSetEntry(ctx, "block", "192.0.2.1"))
require.NoError(t, f.RemoveAddressSetEntry(ctx, "ghost", "192.0.2.1"))
got, err = f.GetAddressSet(ctx, "block")
require.NoError(t, err)
require.Equal(t, []string{"2001:db8:1::/64"}, got.Entries)
// Re-declaring a staged set merges entries; re-declaring it with a different
// family or type is a conflict rather than a silent rewrite.
require.NoError(t, f.AddAddressSet(ctx, &AddressSet{
Name: "block", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8:2::/64"}}))
got, err = f.GetAddressSet(ctx, "block")
require.NoError(t, err)
require.Equal(t, []string{"2001:db8:1::/64", "2001:db8:2::/64"}, got.Entries)
require.ErrorContains(t,
f.AddAddressSet(ctx, &AddressSet{Name: "block", Family: IPv4, Type: SetHashNet}),
"already staged")
// An entry on a set that was never staged reports what ipset itself would.
require.ErrorContains(t, f.AddAddressSetEntry(ctx, "ghost", "192.0.2.1"), "does not exist")
// Removal drops the set from the file and queues the kernel-side destroy for
// Reload; removing an unstaged set is a no-op that queues nothing.
require.NoError(t, f.RemoveAddressSet(ctx, "block"))
require.Equal(t, []string{"block"}, f.pendingSetRemovals)
require.NoError(t, f.RemoveAddressSet(ctx, "ghost"))
require.Equal(t, []string{"block"}, f.pendingSetRemovals)
sets, err := f.GetAddressSets(ctx)
require.NoError(t, err)
require.Empty(t, sets)
// Staging the set again before that Reload cancels the queued destroy, which
// would otherwise delete the set the caller just asked for.
require.NoError(t, f.AddAddressSet(ctx, set))
require.Empty(t, f.pendingSetRemovals)
}
// A FamilyAny set is staged as IPv4: a set is family-typed, so the file always
// names a concrete family for `ipset restore` to create it with.
func TestIPTablesAddressSetFamilyAnyStagedAsIPv4(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "ipsets")
f := &IPTables{IPSetPath: path}
require.NoError(t, f.AddAddressSet(ctx, &AddressSet{Name: "any", Type: SetHashIP}))
got, err := f.GetAddressSet(ctx, "any")
require.NoError(t, err)
require.Equal(t, IPv4, got.Family)
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Contains(t, string(data), "family inet")
}
// The save-format decode is single-pass and must survive the oddities a real
// staging file carries, since a misread here silently changes what Reload loads.
func TestIPTablesIPSetSaveDecode(t *testing.T) {
f := &IPTables{}
lines := []string{
"create v4 hash:ip family inet hashsize 1024 maxelem 65536",
"add v4 192.0.2.1 timeout 600",
`add v4 192.0.2.2 comment "note"`,
"create v6 hash:net family inet6",
"add v6 2001:db8::/64",
// A second create for a name already decoded, and an add naming a set this
// file never declares, are both dropped rather than corrupting the decode.
"create v4 hash:net family inet6",
"add ghost 198.51.100.1",
"",
"# a comment",
}
want := []*AddressSet{
{Name: "v4", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1", "192.0.2.2"}},
{Name: "v6", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8::/64"}},
}
path := filepath.Join(t.TempDir(), "ipsets")
require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0600))
scanned, err := f.scanIPSetSave(path)
require.NoError(t, err)
require.Equal(t, want, scanned)
// What the backend renders round-trips through the decode unchanged.
rendered := filepath.Join(t.TempDir(), "rendered")
require.NoError(t, os.WriteFile(rendered, f.marshalIPSetSave(want), 0600))
round, err := f.scanIPSetSave(rendered)
require.NoError(t, err)
require.Equal(t, want, round)
// A staging file that was never written decodes as no sets, not an error.
missing, err := f.scanIPSetSave(filepath.Join(t.TempDir(), "absent"))
require.NoError(t, err)
require.Empty(t, missing)
}
// Encoding entries for the kernel is work the `ipset` binary used to do for us,
// so the address/CIDR forms a set can hold are pinned here. Decoding must land
// back on the exact string the save-format decode produces for the same entry,
// or a set read live and the same set read from a staging file would not compare
// equal.
func TestIPSetEntryEncoding(t *testing.T) {
for _, tc := range []struct {
entry string
ip string
cidr uint8
// decoded is the entry as it reads back, which differs from entry only
// where host bits were supplied and the kernel masks them off.
decoded string
}{
{entry: "192.0.2.10", ip: "192.0.2.10", cidr: 0, decoded: "192.0.2.10"},
{entry: "10.0.0.0/8", ip: "10.0.0.0", cidr: 8, decoded: "10.0.0.0/8"},
{entry: "192.0.2.5/24", ip: "192.0.2.0", cidr: 24, decoded: "192.0.2.0/24"},
{entry: "2001:db8::1", ip: "2001:db8::1", cidr: 0, decoded: "2001:db8::1"},
{entry: "2001:db8::/64", ip: "2001:db8::", cidr: 64, decoded: "2001:db8::/64"},
} {
got, err := ipsetEncodeEntry(tc.entry)
require.NoError(t, err, "entry %q", tc.entry)
require.Equal(t, tc.ip, got.IP.String(), "entry %q", tc.entry)
require.Equal(t, tc.cidr, got.CIDR, "entry %q", tc.entry)
require.True(t, got.Replace, "entry %q must carry the kernel's exist flag", tc.entry)
require.Equal(t, tc.decoded, ipsetDecodeEntry(*got), "entry %q", tc.entry)
}
// A prefix covering the whole address is dropped, which is how `ipset save`
// writes a single host held in a hash:net set.
require.Equal(t, "192.0.2.1", ipsetDecodeEntry(netlink.IPSetEntry{IP: net.ParseIP("192.0.2.1"), CIDR: 32}))
require.Equal(t, "2001:db8::1", ipsetDecodeEntry(netlink.IPSetEntry{IP: net.ParseIP("2001:db8::1"), CIDR: 128}))
// An entry the kernel sent without an address has no representation.
require.Empty(t, ipsetDecodeEntry(netlink.IPSetEntry{}))
for _, bad := range []string{"", "not-an-ip", "192.0.2.0/33", "example.com", "192.0.2.1-192.0.2.9"} {
_, err := ipsetEncodeEntry(bad)
require.Error(t, err, "entry %q must be rejected rather than sent to the kernel", bad)
}
}
// setRefFamily consults the staged sets first and falls back to the live kernel
// for a set that exists only there, and a rule that already carries a family
// bypasses resolution entirely.
func TestIPTablesSetRefFamilyFallbackChain(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))
ipsets := filepath.Join(dir, "ipsets")
require.NoError(t, os.WriteFile(ipsets, []byte(
"create bootset hash:ip family inet6\nadd bootset 2001:db8::1\n"), 0644))
f := &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: ipsets}
ctx := context.Background()
// The live query knows no sets; "errored" additionally exercises the
// netlink-failure path, which falls through to the declared sets.
prev := ipsetLiveFamily
t.Cleanup(func() { ipsetLiveFamily = prev })
ipsetLiveFamily = func(name string) (Family, bool, error) {
if name == "errored" {
return FamilyAny, false, fmt.Errorf("netlink unavailable")
}
return FamilyAny, false, nil
}
// Declared only in the persistence file: pinned to its inet6 family.
require.NoError(t, f.AddRule(ctx, "", &Rule{Proto: TCP, Port: 4220, Source: "bootset", Action: Accept}))
d4, err := os.ReadFile(p4)
require.NoError(t, err)
d6, err := os.ReadFile(p6)
require.NoError(t, err)
require.Contains(t, string(d6), "--match-set bootset src")
require.NotContains(t, string(d4), "bootset",
"a set declared inet6 in boot config must not produce an iptables line")
// A netlink failure still falls through to the declared file; the set is
// not there either, so the add fails as unknown rather than writing a
// blind line.
err = f.AddRule(ctx, "", &Rule{Proto: TCP, Port: 4221, Source: "errored", Action: Accept})
require.ErrorContains(t, err, `"errored"`)
// A rule that already carries a family is written as declared: no source
// knows "errored", so success proves resolution was never consulted.
require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 4222, Source: "errored", Action: Accept}))
d4, err = os.ReadFile(p4)
require.NoError(t, err)
require.Contains(t, string(d4), "--match-set errored src")
}
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"},
// SNAT with a source-port translation is now accepted: iptables emits it
// as --to-source addr:port (pf/nft reject it in their own MarshalNATRule).
{Kind: SNAT, Family: IPv4, Proto: TCP, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", ToPort: 8443, 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)
}
}
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)
}
}
// TestIPTablesNATParserRejectsUnmodeledMatches pins the shapes UnmarshalNATRule
// must leave foreign: a negated interface/protocol/port match (reading
// `! -o docker0` as a plain match would invert Docker's stock masquerade rule),
// an unknown protocol token, and a source-or-destination multiport option.
func TestIPTablesNATParserRejectsUnmodeledMatches(t *testing.T) {
fw := new(IPTables)
foreign := []string{
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE",
"-A PREROUTING ! -i eth0 -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 8080",
"-A PREROUTING ! -p tcp -j REDIRECT --to-ports 8080",
"-A PREROUTING -p tcp -m tcp ! --dport 80 -j REDIRECT --to-ports 8080",
"-A PREROUTING -p udplite -j REDIRECT --to-ports 8080",
"-A PREROUTING -p 47 -j REDIRECT --to-ports 8080",
"-A PREROUTING -p udp -m multiport --ports 5060 -j REDIRECT --to-ports 5061",
}
for _, spec := range foreign {
_, err := fw.UnmarshalNATRule(spec, IPv4)
require.Error(t, err, "line must stay foreign: %s", spec)
}
// A negated address still models, and the plain forms still parse.
r, err := fw.UnmarshalNATRule("-A POSTROUTING ! -s 172.17.0.0/16 -o eth0 -j MASQUERADE", IPv4)
require.NoError(t, err)
require.Equal(t, "!172.17.0.0/16", r.Source)
require.Equal(t, "eth0", r.Interface)
r, err = fw.UnmarshalNATRule("-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80", IPv4)
require.NoError(t, err)
require.Equal(t, DNAT, r.Kind)
require.EqualValues(t, 8080, r.Port)
}
// TestIPTablesRewriteNATPreservesUnmodeled pins that a Restore-style nat rewrite
// keeps managed-chain lines the model cannot hold — Docker's addrtype jump — while
// still replacing the modeled rules.
func TestIPTablesRewriteNATPreservesUnmodeled(t *testing.T) {
dir := t.TempDir()
save := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" +
"-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER\n" +
"-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80\n" +
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" +
"COMMIT\n*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n"
p := filepath.Join(dir, "iptables")
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
require.NoError(t, fw.rewriteNATRules(p, []string{"-A PREROUTING -p tcp -m tcp --dport 9090 -j DNAT --to-destination 10.0.0.6:90"}))
got, err := os.ReadFile(p)
require.NoError(t, err)
body := string(got)
require.Contains(t, body, "-j DOCKER", "Docker's unmodeled prerouting jump must survive the rewrite")
require.Contains(t, body, "! -o docker0", "Docker's negated masquerade must survive the rewrite")
require.Contains(t, body, "--dport 9090", "the desired rule must be written")
require.NotContains(t, body, "--dport 8080", "the replaced modeled rule must be gone")
}
// TestIPTablesLogPairSplitByForeignLine pins that a LOG line and an action line
// separated by an unmodeled foreign line are not coalesced into one logged rule:
// the pair reads apart everywhere (GetRules, positions, removal), so reporting a
// merged rule would surface one no removal could ever find.
func TestIPTablesLogPairSplitByForeignLine(t *testing.T) {
dir := t.TempDir()
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
"-A INPUT -p tcp -m tcp --dport 80 -j LOG --log-prefix \"web: \"\n" +
"-A INPUT -m recent --name probe --set -j DROP\n" +
"-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" +
"COMMIT\n"
p := filepath.Join(dir, "iptables")
require.NoError(t, os.WriteFile(p, []byte(save), 0644))
fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")}
rules, err := fw.parseFilterFile(p, IPv4)
require.NoError(t, err)
require.Len(t, rules, 1, "only the plain accept is a modeled rule")
require.False(t, rules[0].Log, "the accept must not inherit the separated LOG line")
// Removing the reported rule must find its line even with the split pair.
ctx := context.Background()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ip6tables"), []byte("*filter\nCOMMIT\n"), 0644))
require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}))
got, err := os.ReadFile(p)
require.NoError(t, err)
require.NotContains(t, string(got), "-j ACCEPT", "the reported rule must be removable")
require.Contains(t, string(got), "-j LOG", "the orphaned LOG line is unmodeled and preserved")
require.Contains(t, string(got), "-m recent", "the foreign line is preserved")
}
// TestIPTablesInsertPositionSkipsForeignLine pins that Insert positions count
// only the rules GetRules reports: an unmodeled foreign line holds no position,
// so Rule.Number and the InsertRule position argument cannot diverge.
func TestIPTablesInsertPositionSkipsForeignLine(t *testing.T) {
dir := t.TempDir()
save := "*filter\n:INPUT ACCEPT [0:0]\n" +
"-A INPUT -m recent --name probe --set -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"
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("*filter\nCOMMIT\n"), 0644))
fw := &IPTables{IP4Path: p4, IP6Path: p6}
ctx := context.Background()
// GetRules reports #1 dport 22, #2 dport 80. Insert at 2 = 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])
require.Equal(t, 2, byPort[443], "position 2 must land between the two modeled rules, not before dport 22")
require.Equal(t, 3, byPort[80])
got, err := os.ReadFile(p4)
require.NoError(t, err)
require.Contains(t, string(got), "-m recent", "the foreign line survives the insert")
}
// dockerSaveFile is a realistic iptables-save dump of a host running Docker: the
// classic DOCKER-* chain layout plus an operator's own INPUT and FORWARD rules
// and a hand-named br-lan bridge, which must stay managed. Only three of Docker's
// lines parse as modeled rules — the two FORWARD accepts and the per-published-
// port hairpin masquerade — and those are the ones the library must not touch.
const dockerSaveFile = "*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 -m addrtype --dst-type LOCAL -j DOCKER\n" +
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" +
"-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE\n" +
"-A POSTROUTING -o eth0 -j MASQUERADE\n" +
"-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80\n" +
"COMMIT\n" +
"*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n" +
":DOCKER - [0:0]\n:DOCKER-USER - [0:0]\n:DOCKER-ISOLATION-STAGE-1 - [0:0]\n" +
"-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" +
"-A FORWARD -j DOCKER-USER\n" +
"-A FORWARD -j DOCKER-ISOLATION-STAGE-1\n" +
"-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n" +
"-A FORWARD -o docker0 -j DOCKER\n" +
"-A FORWARD -i docker0 ! -o docker0 -j ACCEPT\n" +
"-A FORWARD -i docker0 -o docker0 -j ACCEPT\n" +
"-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT\n" +
"-A FORWARD -i br-lan -o eth0 -j ACCEPT\n" +
"-A DOCKER-USER -j RETURN\n" +
"COMMIT\n"
// dockerTestFirewall stages the Docker save file and returns the backend plus the
// IPv4 file path.
func dockerTestFirewall(t *testing.T) (*IPTables, string) {
t.Helper()
dir := t.TempDir()
p4 := filepath.Join(dir, "iptables")
p6 := filepath.Join(dir, "ip6tables")
require.NoError(t, os.WriteFile(p4, []byte(dockerSaveFile), 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))
// A staging path keeps Backup's address-set read on the file rather than the
// live kernel, which an unprivileged test cannot query.
return &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: filepath.Join(dir, "ipsets")}, p4
}
// Docker owns and continuously reconciles its rules, so GetRules must not report
// them: a reported rule enters no desired set, and Sync would delete it and sever
// container networking. The operator's own rules — including one on a hand-named
// br-lan bridge — must still be reported and keep correct Numbers, since a
// container rule consumes no logical position, exactly like an unmodeled line.
func TestIPTablesGetRulesHidesContainerRuntime(t *testing.T) {
f, _ := dockerTestFirewall(t)
rules, err := f.GetRules(context.Background(), "")
require.NoError(t, err)
for _, r := range rules {
require.False(t, isContainerRuntimeIface(r.InInterface),
"a rule on container interface %q must not be reported", r.InInterface)
require.False(t, isContainerRuntimeIface(r.OutInterface),
"a rule on container interface %q must not be reported", r.OutInterface)
}
require.Len(t, rules, 2, "only the operator's two rules are in scope")
require.Equal(t, DirInput, rules[0].Direction)
require.EqualValues(t, 22, rules[0].Port)
require.Equal(t, 1, rules[0].Number)
// The operator's FORWARD rule is the only reported forward rule, so it is
// number 1 in that chain — Docker's rules consume no logical position.
require.Equal(t, DirForward, rules[1].Direction)
require.Equal(t, "br-lan", rules[1].InInterface, "a hand-named bridge stays managed")
require.Equal(t, "eth0", rules[1].OutInterface)
require.Equal(t, 1, rules[1].Number)
}
// The nat counterpart: Docker's hairpin masquerade is the only nat line that
// parses, and it must be hidden, while the operator's egress masquerade is
// reported and numbered as the first POSTROUTING rule.
func TestIPTablesGetNATRulesHidesContainerRuntime(t *testing.T) {
f, _ := dockerTestFirewall(t)
nats, err := f.GetNATRules(context.Background(), "")
require.NoError(t, err)
require.Len(t, nats, 1, "only the operator's masquerade is in scope")
require.Equal(t, "eth0", nats[0].Interface)
require.Equal(t, 1, nats[0].Number)
}
// A Backup/Restore round trip is the sharpest test: Backup captures only what
// GetRules reports, and Restore rewrites the managed chains from that set. Every
// Docker line must survive verbatim, or restoring a backup would tear down
// container networking on a host that was running fine.
func TestIPTablesRestorePreservesContainerRuntime(t *testing.T) {
f, p4 := dockerTestFirewall(t)
ctx := context.Background()
backup, err := f.Backup(ctx, "")
require.NoError(t, err)
for _, r := range backup.Rules {
require.False(t, r.isContainerRuntime(), "a container rule must never reach a backup")
}
require.NoError(t, f.Restore(ctx, "", backup))
out, err := os.ReadFile(p4)
require.NoError(t, err)
got := string(out)
for _, line := range []string{
"-A FORWARD -j DOCKER-USER",
"-A FORWARD -j DOCKER-ISOLATION-STAGE-1",
"-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT",
"-A FORWARD -o docker0 -j DOCKER",
"-A FORWARD -i docker0 ! -o docker0 -j ACCEPT",
"-A FORWARD -i docker0 -o docker0 -j ACCEPT",
"-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT",
"-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE",
"-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE",
"-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER",
"-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80",
} {
require.Contains(t, got, line, "Docker's rule must survive a Backup/Restore round trip")
}
// The operator's own rules are still restored, and the round trip is stable.
require.Contains(t, got, "--dport 22")
require.Contains(t, got, "-A FORWARD -i br-lan -o eth0 -j ACCEPT")
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 with container rules present")
}
// Sync is the path that would actually do the damage: it removes every reported
// rule the desired set does not cover. With Docker's rules hidden, a caller
// syncing only its own INPUT rule must leave every Docker line intact.
func TestIPTablesSyncLeavesContainerRuntimeIntact(t *testing.T) {
f, p4 := dockerTestFirewall(t)
ctx := context.Background()
desired := []*Rule{{Direction: DirInput, Proto: TCP, Port: 443, Action: Accept}}
_, removed, err := Sync(ctx, f, "", desired)
require.NoError(t, err)
require.Equal(t, 2, removed, "only the operator's two rules are reconciled")
out, err := os.ReadFile(p4)
require.NoError(t, err)
got := string(out)
require.Contains(t, got, "-A FORWARD -i docker0 -o docker0 -j ACCEPT", "Sync must not delete Docker's rule")
require.Contains(t, got, "-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT")
require.Contains(t, got, "-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT")
require.Contains(t, got, "--dport 443", "the desired rule is applied")
require.NotContains(t, got, "--dport 22", "the operator's stale rule is reconciled away")
}
// Even a caller that hand-builds a rule matching a Docker line exactly must not
// be able to remove it through RemoveRule.
func TestIPTablesRemoveRuleRefusesContainerRuntime(t *testing.T) {
f, p4 := dockerTestFirewall(t)
target := &Rule{Direction: DirForward, InInterface: "docker0", OutInterface: "docker0", Action: Accept}
require.NoError(t, f.RemoveRule(context.Background(), "", target))
out, err := os.ReadFile(p4)
require.NoError(t, err)
require.Contains(t, string(out), "-A FORWARD -i docker0 -o docker0 -j ACCEPT",
"Docker's rule must survive an exact-match removal attempt")
}