1801 lines
80 KiB
Go
1801 lines
80 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")
|
|
}
|
|
|
|
func TestIPSetTypeSpec(t *testing.T) {
|
|
f := new(IPTables)
|
|
require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(IPv4, SetHashIP))
|
|
require.Equal(t, "hash:net family inet6", f.ipsetTypeSpec(IPv6, SetHashNet))
|
|
// FamilyAny resolves to IPv4.
|
|
require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(FamilyAny, SetHashIP))
|
|
}
|
|
|
|
// 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.service", l.ip4Service)
|
|
require.Equal(t, "ip6tables.service", l.ip6Service)
|
|
|
|
// 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.service", l.ip4Service)
|
|
require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit")
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// After GetRules merges an IPv4/IPv6 pair, the derived Number sequence must stay
|
|
// contiguous and unique within a direction rather than carrying the per-family
|
|
// numbering forward (which left gaps/duplicates once families were combined).
|
|
func TestIPTablesMergedRulesRenumberContiguously(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 mergeable v4/v6 pair (port 22) plus a v4-only and a v6-only rule. All
|
|
// input direction. After merge there are three rules: one FamilyAny, one IPv4,
|
|
// one IPv6.
|
|
rules := []*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},
|
|
}
|
|
for _, r := range rules {
|
|
require.NoError(t, f.AddRule(ctx, "", r))
|
|
}
|
|
|
|
got, err := f.GetRules(ctx, "")
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 3, "the v4/v6 port-22 pair should collapse to one rule")
|
|
|
|
// Every input rule's Number must be distinct and form the contiguous set
|
|
// {1,2,3} — no gaps, no cross-family duplicates.
|
|
nums := map[int]bool{}
|
|
var family22 Family = 255
|
|
for _, r := range got {
|
|
require.False(t, nums[r.Number], "duplicate Number %d across merged families", r.Number)
|
|
nums[r.Number] = true
|
|
if r.Port == 22 {
|
|
family22 = r.Family
|
|
}
|
|
}
|
|
require.Equal(t, FamilyAny, family22, "the merged port-22 rule is family-agnostic")
|
|
require.Equal(t, map[int]bool{1: true, 2: true, 3: true}, nums, "numbers must be contiguous 1..3")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// The configured prefix comment is a tag, not a user label: it does not surface
|
|
// as a Rule.Comment on read, but a genuine user comment does.
|
|
func TestIPTablesPrefixCommentStripped(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, rulePrefix: "myapp"}
|
|
ctx := context.Background()
|
|
|
|
// A rule with no user comment is tagged with the prefix only.
|
|
require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}))
|
|
// A rule with a user comment carries it.
|
|
require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 443, Proto: TCP, Action: Accept, Comment: "https"}))
|
|
|
|
rules, err := f.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")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|