go-firewall/iptables_linux_test.go
James Coleman 073c9ad7f0 Add address-set support to APF/CSF and ipset reboot persistence to iptables
iptables: persist ipsets across reboot to match rule persistence.
- Detect the ipset save-file + restore unit (RHEL ipset.service /
  /etc/sysconfig/ipset; Debian netfilter-persistent / /etc/iptables/ipsets),
  non-fatally.
- After each set mutation, `ipset save` into the layout's save-file and
  auto-enable a present-but-disabled restore unit; warn when no mechanism
  exists (sets stay live-only).
- Use ListUnitFiles (not ListUnitFilesByPatterns, which needs systemd >= 230;
  CentOS 7 ships 219).

APF/CSF: gain address sets by persisting ipset commands in the pre-hook.
- The hook carries an `ipset create/flush/add` block ordered ahead of the
  `-m set --match-set` rule lines, so the firewall recreates the set on every
  (re)start before any rule references it.
- Route set-referencing rules (Source/Destination names an ipset) through the
  hook rather than a literal trust-file line (ruleNeedsHook/bareHostShape).
- Implement the six address-set methods, advertise AddressSets, and wire sets
  into Backup/Restore via captureBackupState/restoreBackupSets.

Validated live: reboot simulation for iptables; generated-hook source for
APF/CSF. Unit tests cover the hook ipset round-trip, ordering, in-use guard and
set-ref routing; the capability-gated integration subtest now covers APF/CSF.
2026-07-08 17:20:38 -05:00

1806 lines
81 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)
require.Equal(t, "/etc/sysconfig/ipset", l.ipsetPath, "the RHEL layout persists sets to the ipset-service compat file")
require.Equal(t, "ipset.service", l.ipsetService)
require.Empty(t, l.ipsetPlugin, "the RHEL layout gates on the ipset.service unit, not a plugin file")
// RHEL layout: v4 present but v6 missing is an incomplete pair, not a match.
root = t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "sysconfig"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "iptables"), nil, 0644))
_, ok = probeRHELLayout(root)
require.False(t, ok, "an incomplete RHEL save-file pair should not be reported as a match")
// Debian layout: both save files present, one shared service for both families.
root = t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "iptables"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v4"), nil, 0644))
require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v6"), nil, 0644))
l, ok = probeDebianLayout(root)
require.True(t, ok, "the Debian layout should be found when both save files are present")
require.Equal(t, "netfilter-persistent.service", l.ip4Service)
require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit")
require.Equal(t, "/etc/iptables/ipsets", l.ipsetPath, "the Debian layout persists sets alongside the rules files")
require.NotEmpty(t, l.ipsetPlugin, "the Debian layout gates ipset persistence on the netfilter-persistent plugin file")
}
func TestCombineSplitComment(t *testing.T) {
// strip returns just the user-facing text of a stored comment, discarding the
// prefix signal (the inverse direction of combineComment).
strip := func(prefix, comment string) string {
text, _ := prefixedComment(prefix, comment)
return text
}
// No prefix: the user comment passes through unchanged.
require.Equal(t, "ssh", combineComment("", "ssh"))
require.Equal(t, "ssh", strip("", "ssh"))
// Prefix-only tag.
require.Equal(t, "myapp", combineComment("myapp", ""))
require.Equal(t, "", strip("myapp", "myapp"))
// Prefix + user comment.
require.Equal(t, "myapp ssh", combineComment("myapp", "ssh"))
require.Equal(t, "ssh", strip("myapp", "myapp ssh"))
// A comment without our prefix belongs to another tool; leave it intact.
require.Equal(t, "external", strip("myapp", "external"))
// Separator is a single space; a prefix-like substring mid-comment is not
// stripped.
require.Equal(t, "not myapp ssh", strip("myapp", "not myapp ssh"))
}
// A rule with a user comment keeps the configured prefix as part of the stored
// comment so rules this library creates stay identifiable.
func TestIPTablesCommentCarriesPrefix(t *testing.T) {
dir := t.TempDir()
scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n"
p4 := filepath.Join(dir, "iptables")
p6 := filepath.Join(dir, "ip6tables")
require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644))
require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644))
fw := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"}
ctx := context.Background()
// No user comment: only the prefix is stored.
require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}))
// User comment: stored comment is "<prefix> <comment>".
require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"}))
// Reading back recovers only the user-facing text.
rules, err := fw.GetRules(ctx, "")
require.NoError(t, err)
byPort := map[uint16]*Rule{}
for _, r := range rules {
byPort[r.Port] = r
}
require.Equal(t, "", byPort[22].Comment, "prefix tag must not surface as a comment")
require.Equal(t, "https", byPort[443].Comment, "user comment should round-trip")
// The saved file carries the prefix alongside the user comment.
data, err := os.ReadFile(p4)
require.NoError(t, err)
require.Contains(t, string(data), `-m comment --comment "myapp https"`)
}
// A comment containing a backslash, an embedded double-quote or a non-ASCII
// rune must round-trip byte-for-byte: strconv.Quote (the previous encoding)
// renders these as Go string-literal escapes that shlex.Split — the parser
// GetRules reads such a line back with — does not interpret, so the comment
// came back mangled (e.g. a literal tab as a two-character "\t") and never
// compared equal to the desired rule, so Sync churned on it forever.
func TestIPTablesCommentSpecialCharsRoundTrip(t *testing.T) {
dir := t.TempDir()
scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n"
p4 := filepath.Join(dir, "iptables")
p6 := filepath.Join(dir, "ip6tables")
require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644))
require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644))
fw := &IPTables{IP4Path: p4, IP6Path: p6}
ctx := context.Background()
cases := []struct {
port uint16
comment string
}{
{22, `back\slash`},
{23, `quote"inside`},
{24, "tab\ttab"},
{25, "unicode ключ"},
}
for _, c := range cases {
require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: c.port, Action: Accept, Comment: c.comment}))
}
rules, err := fw.GetRules(ctx, "")
require.NoError(t, err)
byPort := map[uint16]*Rule{}
for _, r := range rules {
byPort[r.Port] = r
}
for _, c := range cases {
require.Equal(t, c.comment, byPort[c.port].Comment, "comment %q must round-trip unchanged", c.comment)
}
// A literal newline cannot be expressed (it would split the rules file's
// one-line-per-rule format), so it is rejected rather than silently mangled.
err = fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 26, Action: Accept, Comment: "line1\nline2"})
require.Error(t, err, "a comment containing a newline must be rejected")
}
// A user comment that itself begins with the configured prefix must survive the
// round-trip intact: GetRules strips the prefix exactly once, so a comment of
// "myapp is great" (stored as "myapp myapp is great") must read back whole and
// not be truncated to "is great".
func TestIPTablesCommentBeginningWithPrefix(t *testing.T) {
dir := t.TempDir()
scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n"
p4 := filepath.Join(dir, "iptables")
p6 := filepath.Join(dir, "ip6tables")
require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644))
require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644))
fw := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"}
ctx := context.Background()
require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 8080, Action: Accept, Comment: "myapp is great"}))
rules, err := fw.GetRules(ctx, "")
require.NoError(t, err)
require.Len(t, rules, 1)
require.Equal(t, "myapp is great", rules[0].Comment, "prefix must be stripped exactly once")
require.True(t, rules[0].HasPrefix)
}
// iptables-save canonicalizes a bare host address to its /32 (or /128) form, so
// a rule this library wrote as a bare address must still match the canonicalized
// read-back.
func TestIPTablesHostPrefixRoundTrip(t *testing.T) {
orig := &Rule{Family: IPv4, Proto: TCP, Source: "192.168.1.1", Port: 22, Action: Accept}
got, err := unmarshalIPTablesRule("-A INPUT -s 192.168.1.1/32 -p tcp -m tcp --dport 22 -j ACCEPT", IPv4)
require.NoError(t, err)
require.True(t, got.EqualBase(orig, true), "bare host must match iptables-save /32 form; got %q", got.Source)
}
// A single contiguous port range on a `-m tcp/udp/sctp --dport` — the form
// iptables-save emits and ufw's before.rules use — must parse. The module-match
// handlers previously accepted only a scalar port, so GetRules silently dropped
// the rule (re-adding a duplicate every reconcile).
func TestIPTablesModulePortRangeParse(t *testing.T) {
cases := []struct {
spec string
src bool
}{
{`-A INPUT -p tcp -m tcp --dport 1000:2000 -j ACCEPT`, false},
{`-A INPUT -p tcp -m tcp --sport 1000:2000 -j ACCEPT`, true},
{`-A INPUT -p udp -m udp --dport 1000:2000 -j ACCEPT`, false},
{`-A INPUT -p sctp -m sctp --dport 1000:2000 -j ACCEPT`, false},
}
for _, c := range cases {
r, err := unmarshalIPTablesRule(c.spec, IPv4)
require.NoError(t, err, "range on module match must parse: %s", c.spec)
specs := r.PortSpecs()
if c.src {
specs = r.SourcePortSpecs()
}
require.Equal(t, []PortRange{{Start: 1000, End: 2000}}, specs, "range not captured: %s", c.spec)
}
}
// iptables applies and prints a default --limit-burst 5 on every -m limit match.
// A rule added with Burst 0 must still compare equal to the one iptables-save
// lists back with burst 5 (mirrors the nft burst-5 normalization).
func TestIPTablesRateBurstDefaultNormalized(t *testing.T) {
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
saved := `-A INPUT -p tcp -m tcp --dport 22 -m limit --limit 10/min --limit-burst 5 -j ACCEPT`
got, err := unmarshalIPTablesRule(saved, IPv4)
require.NoError(t, err)
require.NotNil(t, got.RateLimit)
require.Equal(t, uint(0), got.RateLimit.Burst, "iptables' default burst of 5 must normalize to the unset 0")
require.True(t, got.EqualBase(orig, true), "a burst-5 read-back must equal the burst-0 original")
}
// A DNAT rule on SCTP (which carries ports) must round-trip: the `-m sctp
// --dport` match iptables emits was previously not parsed back.
func TestIPTablesSCTPNATRoundTrip(t *testing.T) {
f := &IPTables{rulePrefix: "myapp"}
orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 132, ToAddress: "10.0.0.5", ToPort: 132}
spec, err := f.MarshalNATRule(orig)
require.NoError(t, err)
got, err := f.UnmarshalNATRule(spec, IPv4)
require.NoError(t, err)
require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got)
}
// iptables labels an ICMPv6 type with an ICMPv6 name, several of which mean a
// different number under ICMPv4 (echo-request is 128 vs 8, destination-unreachable
// is 1 vs 3). The `--icmpv6-type`/`-m icmp6` flags must resolve names through the
// ICMPv6 table, not the ICMPv4 one. Library-written rules emit the number, so the
// round-trip tests never exercised the name path — but ufw's before6.rules do.
func TestIPTablesICMPv6TypeNameParse(t *testing.T) {
// -m icmp6 module form.
r, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type echo-request -j ACCEPT", IPv6)
require.NoError(t, err)
require.NotNil(t, r.ICMPType)
require.Equal(t, uint8(128), *r.ICMPType, "icmpv6 echo-request is type 128")
// Bare --icmpv6-type form (no -m icmp6).
r2, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT", IPv6)
require.NoError(t, err)
require.NotNil(t, r2.ICMPType)
require.Equal(t, uint8(1), *r2.ICMPType, "icmpv6 destination-unreachable is type 1")
// The ICMPv4 name path must be unchanged.
r3, err := unmarshalIPTablesRule("-A INPUT -p icmp --icmp-type echo-request -j ACCEPT", IPv4)
require.NoError(t, err)
require.NotNil(t, r3.ICMPType)
require.Equal(t, uint8(8), *r3.ICMPType, "icmp echo-request is type 8")
}
// 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)
}
}