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

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

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

3255 lines
100 KiB
Go

package firewall
import (
"bufio"
"context"
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/anmitsu/go-shlex"
)
const (
// IPTablesNoSave is the error text returned when no iptables save path is found.
IPTablesNoSave = "unable to find iptables save path"
// IPTablesNoService is the error text returned when no iptables service is found.
IPTablesNoService = "unable to find iptables service"
)
// IPTables manages filter and NAT rules through iptables save files and the service that restores them.
type IPTables struct {
IP4Service string
IP4Path string
IP6Path string
IP6Service string
// IPSetPath and IPSetService describe the optional ipset persistence
// mechanism detected for this host. IPSetPath is the save file the sets are
// written to so a reboot restores them; IPSetService is the service that restores
// it before the rules load. Both are empty when no mechanism is installed, in
// which case address sets are created live but not persisted across a reboot.
IPSetPath string
IPSetService string
// rulePrefix, when set, is written as an iptables comment on rules this
// library creates so they can be told apart from pre-existing rules.
rulePrefix string
}
// iptLayout names the save-file paths and restore services a supported iptables
// packaging uses. The Debian layout carries the same service for both families
// (netfilter-persistent restores both rules.v4 and rules.v6), so ip4Service and
// ip6Service are equal there.
type iptLayout struct {
ip4Path, ip6Path string
ip4Service, ip6Service string
// ipsetPath is the save file the ipset restore service reads on boot, and
// ipsetService is the service that restores it before the rules service
// loads the -m set rules that reference the sets. Persisting sets is optional
// (a missing mechanism is not fatal, unlike a missing rules save file), so
// these describe the packaging's convention; NewIPTables confirms the
// mechanism is installed.
ipsetPath, ipsetService string
// ipsetPlugin, when set, is a glob whose presence proves the restore mechanism
// is installed. The Debian layout persists sets through a netfilter-persistent
// plugin rather than a dedicated service, so its absence means the saved file
// would never be restored and the sets are left live-only.
ipsetPlugin string
}
// probeDebianLayout reports the Debian/Ubuntu iptables-persistent layout
// (/etc/iptables/rules.v4 and rules.v6, both restored by the single
// netfilter-persistent service) if its save files are both present under
// root. root is prepended to both paths so tests can point the probe at a
// temp dir; production callers pass "".
func probeDebianLayout(root string) (l iptLayout, ok bool) {
l = iptLayout{
ip4Path: "/etc/iptables/rules.v4", ip6Path: "/etc/iptables/rules.v6",
ip4Service: "netfilter-persistent", ip6Service: "netfilter-persistent",
ipsetPath: "/etc/iptables/ipsets", ipsetService: "netfilter-persistent",
ipsetPlugin: "/usr/share/netfilter-persistent/plugins.d/*ipset*",
}
if _, err := os.Stat(root + l.ip4Path); err != nil {
return iptLayout{}, false
}
if _, err := os.Stat(root + l.ip6Path); err != nil {
return iptLayout{}, false
}
return l, true
}
// probeRHELLayout reports the RHEL/iptables-services layout
// (/etc/sysconfig/iptables and ip6tables, restored by the iptables and
// ip6tables services) if its save files are both present under root. It does
// caller must have already confirmed the RHEL v4 path exists before treating
// an incomplete pair (ok=false) as fatal rather than falling through to
// another layout. root is prepended to both paths so tests can point the
// probe at a temp dir; production callers pass "".
func probeRHELLayout(root string) (l iptLayout, ok bool) {
l = iptLayout{
ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables",
ip4Service: "iptables", ip6Service: "ip6tables",
ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset",
}
if _, err := os.Stat(root + l.ip4Path); err != nil {
return iptLayout{}, false
}
if _, err := os.Stat(root + l.ip6Path); err != nil {
return iptLayout{}, false
}
return l, true
}
// NewIPTables creates an iptables manager, detecting the save-file layout and confirming its restore services are enabled.
func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) {
ipt := new(IPTables)
ipt.rulePrefix = rulePrefix
// Prefer the RHEL layout when its v4 save file is present. A v4 file with
// no v6 partner is still a fatal IPTablesNoSave (not a signal to try the
// Debian layout) — we do not know what may be used for the firewall in that
// case. Only when the RHEL v4 path is entirely absent do we probe the
// Debian iptables-persistent layout instead.
var layout iptLayout
if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil {
l, ok := probeRHELLayout("")
if !ok {
return nil, errors.New(IPTablesNoSave)
}
layout = l
} else {
l, ok := probeDebianLayout("")
if !ok {
return nil, errors.New(IPTablesNoSave)
}
layout = l
}
ipt.IP4Path, ipt.IP6Path = layout.ip4Path, layout.ip6Path
// Confirm the service that restores the rules is enabled, under whatever
// init system the host uses.
ipt.IP4Service = layout.ip4Service
if !serviceEnabled(ctx, ipt.IP4Service) {
return nil, errors.New(IPTablesNoService)
}
// If ip6tables service is missing, we do not want to modify iptables
// as we do not know what may be used for the firewall. Skip the redundant
// check when it is the same service already confirmed enabled above (the
// Debian layout uses one service for both families).
ipt.IP6Service = layout.ip6Service
if ipt.IP6Service != ipt.IP4Service {
if !serviceEnabled(ctx, ipt.IP6Service) {
return nil, errors.New(IPTablesNoService)
}
}
// Detect the optional ipset persistence mechanism. Unlike the rules save file,
// a missing mechanism is not fatal: address sets still work live, they just do
// not survive a reboot (persistIPSets warns when a set is added in that case).
ipt.IPSetPath, ipt.IPSetService = ipt.detectIPSetLayout(ctx, layout)
return ipt, nil
}
// Type returns the manager type.
func (f *IPTables) Type() string {
return IPTablesType
}
// Capabilities returns the set of features this backend can express.
func (f *IPTables) Capabilities() Capabilities {
return Capabilities{
Output: true,
Forward: true,
ICMPv6: true,
PortList: true,
ConnState: true,
InterfaceMatch: true,
Logging: true,
RateLimit: true,
ConnLimit: true,
NAT: true,
RuleOrdering: true,
DefaultPolicy: true,
RuleCounters: true,
AddressSets: true,
Comments: true,
}
}
// GetZone reports no zone: iptables has only policy groups, and rules are
// inserted at the top of the INPUT/OUTPUT policies.
func (f *IPTables) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
return "", nil
}
// IgnoreLine reports whether an iptables-save line is a blank, comment, table, chain or COMMIT line to be skipped.
func (*IPTables) IgnoreLine(line string) bool {
if len(line) == 0 {
return true
}
if line[0] == '#' || line[0] == '*' || line[0] == ':' {
return true
}
if line == "COMMIT" {
return true
}
return false
}
// prefixedComment splits a stored comment into its user-facing text and whether
// the comment carried the configured prefix (marking a rule tagged with this
// library's namespace). A comment equal to the prefix (a prefix-only tag) has the
// prefix with empty text; a comment carrying the prefix followed by a space has
// the prefix with the remainder as text; any other comment lacks the prefix and
// is returned unchanged. An empty prefix gives the library no namespace of its
// own, so the prefix cannot be derived from the comment — hasPrefix is reported
// false and the caller decides (backends treat an empty prefix as covering
// everything, see GetRules).
func prefixedComment(prefix, comment string) (text string, hasPrefix bool) {
if prefix == "" {
return comment, false
}
if comment == prefix {
return "", true
}
if rest, ok := strings.CutPrefix(comment, prefix+" "); ok {
return rest, true
}
return comment, false
}
// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi")
// into PortRange values.
func iptParsePorts(val string) ([]PortRange, error) {
return ParsePortRanges(val, ",")
}
// unmarshalIPTablesRule decodes an iptables rulespec (e.g. an `-A CHAIN ...`
// line) into a rule. It is shared by the iptables backend and the ufw backend,
// whose before/after iptables rules files are in this format.
func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error) {
r = &Rule{
Family: family,
}
not := false
tokens, err := shlex.Split(ruleSpec, true)
if err != nil {
return nil, err
}
// An iptables-save line may carry a leading [pkts:bytes] counter prefix.
// Capture the counters onto the rule and strip the prefix before parsing.
if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") {
inner := strings.TrimSuffix(strings.TrimPrefix(tokens[0], "["), "]")
if pk, bs, ok := strings.Cut(inner, ":"); ok {
if n, e := strconv.ParseUint(pk, 10, 64); e == nil {
r.Packets = n
}
if n, e := strconv.ParseUint(bs, 10, 64); e == nil {
r.Bytes = n
}
}
tokens = tokens[1:]
}
// Start at 2, the command and the chain.
i := 2
if i >= len(tokens) {
return nil, fmt.Errorf("unexpected token length")
}
// Check the chain.
switch tokens[1] {
case "INPUT":
r.Direction = DirInput
case "OUTPUT":
r.Direction = DirOutput
case "FORWARD":
r.Direction = DirForward
default:
return nil, fmt.Errorf("the chain is not INPUT, OUTPUT or FORWARD")
}
// Check the command.
switch tokens[0] {
case "-A", "--append":
case "-I", "--insert":
// If insert rule has an integer rule number, increment i.
if i < len(tokens) {
_, err := strconv.Atoi(tokens[i])
if err == nil {
i++
}
}
case "-R", "--replace":
_, err := strconv.Atoi(tokens[i])
if err != nil {
return nil, fmt.Errorf("the replace command requires an integer rule number")
}
i++
default:
return nil, fmt.Errorf("unsupported command provided")
}
// Process the rule.
for ; i < len(tokens); i++ {
switch tokens[i] {
// A leading "!" negates the match that follows it.
case "!":
not = true
// Continue so the negation is not cleared before the match token is read.
continue
case "-p", "--protocol":
// We do not support negation on this parameter.
if not {
return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support")
}
// Verify the protocol is specified.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid protocol parameter")
}
// Verify the protocol value is valid.
r.Proto = GetProtocol(tokens[i])
if r.Proto == ProtocolAny && !strings.EqualFold(tokens[i], "all") {
return nil, fmt.Errorf("invalid protocol parameter")
}
case "-s", "--source":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid source parameter")
}
// Confirm we can parse the address.
_, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i])
if err != nil && ip == nil {
return nil, fmt.Errorf("invalid source parameter")
}
// Set the source address.
if not {
r.Source = "!" + tokens[i]
} else {
r.Source = tokens[i]
}
case "-d", "--destination":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid destination parameter")
}
// Confirm we can parse the address.
_, _, err := net.ParseCIDR(tokens[i])
ip := net.ParseIP(tokens[i])
if err != nil && ip == nil {
return nil, fmt.Errorf("invalid destination parameter")
}
// Set the destination address.
if not {
r.Destination = "!" + tokens[i]
} else {
r.Destination = tokens[i]
}
case "--icmp-type", "--icmpv6-type":
// A bare icmp-type match (as in `-p icmp --icmp-type echo-request`,
// without an explicit `-m icmp`), common in ufw's iptables rules files.
if not {
return nil, fmt.Errorf("a negated icmp type is not supported")
}
// The flag names the family: --icmpv6-type resolves names through the
// ICMPv6 table, where reused names (e.g. echo-request) map to different
// numbers than in ICMPv4.
v6 := tokens[i] == "--icmpv6-type"
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid icmp-type parameter")
}
n, ok := parseICMPTypeFamily(tokens[i], v6)
if !ok {
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
}
r.ICMPType = Ptr(n)
case "--sport", "--source-port":
// A bare source-port match (as in `-p udp --sport 5353`).
if not {
return nil, fmt.Errorf("a negated source port is not supported")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid sport parameter")
}
pr, perr := ParsePortRange(tokens[i])
if perr != nil {
return nil, perr
}
if pr.Start == pr.End {
r.SourcePort = pr.Start
} else {
r.SourcePorts = []PortRange{pr}
}
case "--dport", "--destination-port":
// A bare destination-port match (as in `-p udp --dport 5353`).
if not {
return nil, fmt.Errorf("a negated port is not supported")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid dport parameter")
}
pr, perr := ParsePortRange(tokens[i])
if perr != nil {
return nil, perr
}
if pr.Start == pr.End {
r.Port = pr.Start
} else {
r.Ports = []PortRange{pr}
}
case "-i", "--in-interface":
if not {
return nil, fmt.Errorf("a negated interface match is not supported")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid in-interface parameter")
}
r.InInterface = tokens[i]
case "-o", "--out-interface":
if not {
return nil, fmt.Errorf("a negated interface match is not supported")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid out-interface parameter")
}
r.OutInterface = tokens[i]
case "-j", "--jump":
// We do not support negation on this parameter.
if not {
return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support")
}
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid jump parameter")
}
// Parse the valid options.
switch tokens[i] {
case "DROP":
r.Action = Drop
case "REJECT":
r.Action = Reject
if i+2 < len(tokens) {
if tokens[i+1] == "--reject-with" {
i += 2
}
}
case "ACCEPT":
r.Action = Accept
case "LOG":
// A LOG target is non-terminal: a logged rule is written as a
// LOG line followed by the action line, coalesced on read. This
// line contributes only the Log flag and prefix.
r.Log = true
for i+1 < len(tokens) {
if tokens[i+1] == "--log-prefix" && i+2 < len(tokens) {
r.LogPrefix = tokens[i+2]
i += 2
} else if tokens[i+1] == "--log-level" && i+2 < len(tokens) {
i += 2
} else {
break
}
}
default:
return nil, fmt.Errorf("unsupported jump option: %s", tokens[i])
}
case "-m", "--match":
// We do not support negation on this parameter (the set match negates
// internally, after `-m set`, so it is handled inside its case).
if not {
return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support")
}
// Verify options are set.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse the valid options.
switch tokens[i] {
case "set":
// -m set [!] --match-set <name> src|dst names an ipset in place of an
// address; the optional `!` negates it. A combined `src,dst` flag
// matches on both, which the Rule model (one set per direction) cannot
// represent, so it is rejected.
i++
setNot := false
if i < len(tokens) && tokens[i] == "!" {
setNot = true
i++
}
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
return nil, fmt.Errorf("unsupported set match")
}
name := tokens[i+1]
dir := tokens[i+2]
i += 2
if setNot {
name = "!" + name
}
switch dir {
case "src":
r.Source = name
case "dst":
r.Destination = name
default:
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
}
case "comment":
if i+2 >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
if tokens[i+1] != "--comment" {
return nil, fmt.Errorf("invalid match parameter")
}
i += 2
// Capture the comment text. The caller strips it when it is the
// configured prefix rather than a user-supplied label.
r.Comment = tokens[i]
case "conntrack":
// Only the conntrack state option is modeled.
if i+2 >= len(tokens) || tokens[i+1] != "--ctstate" {
return nil, fmt.Errorf("unsupported conntrack match")
}
i += 2
state, serr := ParseConnState(tokens[i])
if serr != nil {
return nil, serr
}
r.State = state
case "state":
// Legacy state match: -m state --state NEW,ESTABLISHED.
if i+2 >= len(tokens) || tokens[i+1] != "--state" {
return nil, fmt.Errorf("unsupported state match")
}
i += 2
state, serr := ParseConnState(tokens[i])
if serr != nil {
return nil, serr
}
r.State = state
case "limit":
// -m limit --limit N/unit [--limit-burst B]
if i+2 >= len(tokens) || tokens[i+1] != "--limit" {
return nil, fmt.Errorf("unsupported limit match")
}
i += 2
rate, unit, rerr := parseRateToken(tokens[i])
if rerr != nil {
return nil, rerr
}
rl := &RateLimit{Rate: rate, Unit: unit}
if i+2 < len(tokens) && tokens[i+1] == "--limit-burst" {
b, berr := strconv.ParseUint(tokens[i+2], 10, 32)
if berr != nil {
return nil, fmt.Errorf("invalid limit burst %q", tokens[i+2])
}
rl.Burst = uint(b)
i += 2
}
// Legacy (xtables) iptables prints a default --limit-burst of 5 on
// every -m limit match; treat it as unset so a Burst-0 rule still
// matches on a legacy host (an explicit 5 collapses to 0 too).
if rl.Burst == 5 {
rl.Burst = 0
}
r.RateLimit = rl
case "connlimit":
// -m connlimit --connlimit-above N [--connlimit-mask M]
if i+2 >= len(tokens) || tokens[i+1] != "--connlimit-above" {
return nil, fmt.Errorf("unsupported connlimit match")
}
i += 2
n, nerr := strconv.ParseUint(tokens[i], 10, 32)
if nerr != nil {
return nil, fmt.Errorf("invalid connlimit %q", tokens[i])
}
// The default mask (32/128) counts per source; an explicit mask
// of 0 counts globally.
cl := &ConnLimit{Count: uint(n), PerSource: true}
if i+2 < len(tokens) && tokens[i+1] == "--connlimit-mask" {
if tokens[i+2] == "0" {
cl.PerSource = false
}
i += 2
}
// iptables-save always appends the counting key (--connlimit-saddr
// by default, or --connlimit-daddr) after the match; consume it so
// the trailing flag does not fail the parse and drop the whole rule.
if i+1 < len(tokens) && (tokens[i+1] == "--connlimit-saddr" || tokens[i+1] == "--connlimit-daddr") {
i++
}
r.ConnLimit = cl
case "icmp", "icmp6":
// -m icmp --icmp-type N / -m icmp6 --icmpv6-type N. The type
// qualifier is optional; a bare match just selects the module.
v6 := tokens[i] == "icmp6"
typeFlag := "--icmp-type"
if v6 {
typeFlag = "--icmpv6-type"
}
if i+2 < len(tokens) && tokens[i+1] == typeFlag {
i += 2
// iptables-save spells a type-with-code as `type/code` (e.g.
// `3/1`); the Rule model carries only the type, so drop a trailing
// `/code` before resolving rather than failing the whole rule.
typeTok := tokens[i]
if slash := strings.IndexByte(typeTok, '/'); slash >= 0 {
typeTok = typeTok[:slash]
}
n, ok := parseICMPTypeFamily(typeTok, v6)
if !ok {
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
}
r.ICMPType = Ptr(n)
}
case "multiport":
// -m multiport --dports/--sports 80,443,1000:2000 (also --ports).
if i+2 >= len(tokens) {
return nil, fmt.Errorf("invalid multiport match")
}
switch tokens[i+1] {
case "--dports", "--dport", "--sports", "--sport", "--ports", "--port":
default:
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
}
src := strings.HasPrefix(tokens[i+1], "--s")
i += 2
specs, perr := iptParsePorts(tokens[i])
if perr != nil {
return nil, perr
}
if src {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.SourcePort = specs[0].Start
} else {
r.SourcePorts = specs
}
} else {
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
}
case "tcp":
// Invalid protocol define.
if r.Proto == UDP {
return nil, fmt.Errorf("specifying TCP options for UDP")
}
// Verify options are set.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse options.
tcpTokenLoop:
for ; i < len(tokens); i++ {
switch tokens[i] {
case "!", "--syn", "--tcp-option":
return nil, fmt.Errorf("invalid match parameter")
case "--source-port", "--sport":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse port (iptables-save renders a contiguous range as lo:hi).
pr, err := ParsePortRange(tokens[i])
if err != nil {
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
}
if pr.Start == pr.End {
r.SourcePort = pr.Start
} else {
r.SourcePorts = []PortRange{pr}
}
case "--destination-port", "--dport":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse port (iptables-save renders a contiguous range as lo:hi).
pr, err := ParsePortRange(tokens[i])
if err != nil {
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
}
if pr.Start == pr.End {
r.Port = pr.Start
} else {
r.Ports = []PortRange{pr}
}
default:
i--
break tcpTokenLoop
}
}
case "udp", "sctp":
// SCTP carries ports like UDP and iptables-save spells its port
// match module `-m sctp`, so it shares this branch.
// Invalid protocol define.
if r.Proto == TCP {
return nil, fmt.Errorf("specifying UDP options for TCP")
}
// Verify options are set.
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse options.
udpTokenLoop:
for ; i < len(tokens); i++ {
switch tokens[i] {
case "!":
// A negated match cannot be represented.
return nil, fmt.Errorf("invalid match parameter")
case "--source-port", "--sport":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse port (iptables-save renders a contiguous range as lo:hi).
pr, err := ParsePortRange(tokens[i])
if err != nil {
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
}
if pr.Start == pr.End {
r.SourcePort = pr.Start
} else {
r.SourcePorts = []PortRange{pr}
}
case "--destination-port", "--dport":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
// Parse port (iptables-save renders a contiguous range as lo:hi).
pr, err := ParsePortRange(tokens[i])
if err != nil {
return nil, fmt.Errorf("the port argument %s is invalid", tokens[i])
}
if pr.Start == pr.End {
r.Port = pr.Start
} else {
r.Ports = []PortRange{pr}
}
default:
i--
break udpTokenLoop
}
}
default:
return nil, fmt.Errorf("unsupported match option: %s", tokens[i])
}
default:
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
}
// As rule has been parsed, we may now mark not as false.
not = false
}
// If no action provided, error — unless this is a LOG-only line (the log
// half of a logged rule), which carries the Log flag but no terminal action.
if r.Action == ActionInvalid && !r.Log {
return nil, fmt.Errorf("no valid action was provided")
}
return
}
// UnmarshalRule decodes an iptables rulespec into a firewall rule.
func (f *IPTables) UnmarshalRule(ruleSpec string, family Family) (*Rule, error) {
r, err := unmarshalIPTablesRule(ruleSpec, family)
if err != nil {
return nil, err
}
// The shared parser is prefix-agnostic; strip this backend's configured
// prefix so only the user-facing comment surfaces, and record whether the
// prefix was present so callers can tell our rules from foreign ones. The
// comment is not part of rule identity, so this does not affect dedup or
// removal comparisons. An empty prefix gives us no namespace, so no rule
// reports HasPrefix.
text, hasPrefix := prefixedComment(f.rulePrefix, r.Comment)
r.Comment = text
r.HasPrefix = hasPrefix
return r, nil
}
// iptSameMatch reports whether two rules have identical match fields ignoring
// their action and logging flags. It is used to pair a LOG line with the action
// line that follows it.
func iptSameMatch(a, b *Rule) bool {
ac, bc := *a, *b
ac.Log, bc.Log = false, false
ac.LogPrefix, bc.LogPrefix = "", ""
ac.Action, bc.Action = Accept, Accept
return ac.EqualBase(&bc, true)
}
// logPartner reports whether cur is a standalone LOG line (no terminal action)
// and next is its action partner — the pairing GetRules coalesces into one
// logged rule (see iptSameMatch). next may be nil when cur is the last rule in
// the sequence. It is the shared predicate behind coalesceLoggedRules,
func logPartner(cur, next *Rule) bool {
return cur != nil && cur.Action == ActionInvalid && cur.Log &&
next != nil && next.Action != ActionInvalid && iptSameMatch(cur, next)
}
// mergeLogPair folds a standalone LOG line's prefix into its action partner,
// producing the single logical rule GetRules reports for a logged rule.
func mergeLogPair(logLine, action *Rule) *Rule {
merged := *action
merged.Log = true
merged.LogPrefix = logLine.LogPrefix
return &merged
}
// coalesceLoggedRules merges each LOG-only rule that is immediately followed by
// a matching action rule into a single logical rule with Log set. An orphan LOG
// rule (no matching action after it) is dropped.
func coalesceLoggedRules(rules []*Rule) []*Rule {
out := make([]*Rule, 0, len(rules))
for i := 0; i < len(rules); i++ {
cur := rules[i]
if cur.Action == ActionInvalid && cur.Log {
// A LOG-only line: fold it into the next line if that line is its
// action partner, else drop this orphan LOG line.
var next *Rule
if i+1 < len(rules) {
next = rules[i+1]
}
if logPartner(cur, next) {
out = append(out, mergeLogPair(cur, next))
i++
}
continue
}
out = append(out, cur)
}
return out
}
// parseFilterFile reads a family's iptables-save file and returns its filter
// rules as logical rules, coalescing each LOG line with the action line that
// follows it. Lines that do not parse (nat rules, custom chains) are skipped.
func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) {
fd, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
var perLine []*Rule
scanner := bufio.NewScanner(fd)
// The save file is a full iptables-save dump, so *nat and *mangle also carry
// INPUT/OUTPUT chains whose plain ACCEPT/DROP/LOG rules would parse as filter
// rules. Track table scope and only read the *filter table so a foreign nat or
// mangle rule does not bleed into GetRules (and is never relocated or removed as
// if it were a filter rule).
inFilter := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "*") {
inFilter = line == "*filter"
continue
}
if line == "COMMIT" {
inFilter = false
continue
}
if !inFilter {
continue
}
if f.IgnoreLine(line) {
continue
}
r, err := f.UnmarshalRule(line, family)
if err != nil {
continue
}
// UnmarshalRule already stripped the prefix from the comment and
// set HasPrefix; a second strip here would remove a prefix word a second
// time and truncate a user comment that itself begins with the prefix.
perLine = append(perLine, r)
}
if err := scanner.Err(); err != nil {
return nil, err
}
// Return the coalesced rules; GetRules assigns Number once both save files are
// read (the other callers use the result only for dedup and never read Number).
return coalesceLoggedRules(perLine), nil
}
// GetRules returns the existing filter rules from the zone.
func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
// Each save-file line is its own rule: iptables pins one family (by which file
// holds it), one transport and one direction (by chain) per line, so nothing here
// spans two of anything and nothing is collapsed.
v4, err := f.parseFilterFile(f.IP4Path, IPv4)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
}
v6, err := f.parseFilterFile(f.IP6Path, IPv6)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
}
// Number each family's chains independently: the two families are separate
// rulesets in separate save files, so an IPv6 rule's InsertRule/MoveRule position
// counts only the ip6tables chain it lives in. Numbering the concatenation instead
// would offset every IPv6 rule by the IPv4 chain's length.
numberByDirection(v4)
numberByDirection(v6)
rules = append(rules, v4...)
rules = append(rules, v6...)
return
}
// applyRuleFiles runs prepare against each family file the rule applies to,
// staging all temp files first and committing them only once every file has
// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6
// files, so staging up front avoids leaving the rule half-applied if the second
// file fails to prepare.
func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicFile, error)) error {
// A DirAny rule fans out into an input row plus its role-swapped output row,
// each marshalled into its own chain within the same family file(s). Recurse per
// concrete-direction half; expandDirections returns a single element for a
// concrete rule, so this never recurses more than once.
if subs := expandDirections(r); len(subs) > 1 {
for _, sub := range subs {
if err := f.applyRuleFiles(sub, prepare); err != nil {
return err
}
}
return nil
}
// A TCPUDP rule fans out into a tcp row plus a udp row, each marshalled into
// its own line in the same chain of the same family file(s). iptables has no
// both-transports match, so this split runs after the direction fan-out and
// before the family split; expandProtocols returns a single element for a
// concrete-protocol rule, so this never recurses more than once.
if subs := expandProtocols(r); len(subs) > 1 {
for _, sub := range subs {
if err := f.applyRuleFiles(sub, prepare); err != nil {
return err
}
}
return nil
}
// Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp`
// belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file.
family := r.impliedFamily()
var paths []string
if family == IPv4 || family == FamilyAny {
paths = append(paths, f.IP4Path)
}
if family == IPv6 || family == FamilyAny {
paths = append(paths, f.IP6Path)
}
// Stage every file first.
var staged []*atomicFile
for _, path := range paths {
af, err := prepare(path, r)
if err != nil {
// Discard any temp files already staged.
for _, s := range staged {
s.Abort()
}
return err
}
// A nil handle means no change was needed for this file.
if af != nil {
staged = append(staged, af)
}
}
// Commit each staged file into place, preserving its mode and ownership.
for _, s := range staged {
if err := s.Commit(); err != nil {
return fmt.Errorf("failed to move new firewall rules into place: %s", err)
}
}
return nil
}
// AddRule adds a rule to the zone.
func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error {
return f.applyRuleFiles(r, f.prepareAddRuleFile)
}
// chainOf returns the chain named by an iptables-save rule body such as
// "-A FORWARD -j ACCEPT" (the token after the -A/-I/-R command), or "" when the
// body has no chain token. It lets the file-rewrite paths tell an INPUT/OUTPUT
// rule the library manages from a rule in a chain it does not model.
func (f *IPTables) chainOf(body string) string {
fields := strings.Fields(body)
if len(fields) >= 2 {
return fields[1]
}
return ""
}
// ruleLineBody strips an optional leading [pkts:bytes] counter token from a
// trimmed iptables-save line and returns the remaining rule body. iptables-save
// -c annotates each rule with counters; the library never emits them, but the
// file-rewrite paths must still recognise a counter-prefixed line as a rule when
// operating on a pre-existing save file (matching the read parser, which strips
// the same prefix). A line without a counter is returned unchanged.
func (f *IPTables) ruleLineBody(line string) string {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
if i := strings.IndexByte(line, ']'); i >= 0 {
return strings.TrimSpace(line[i+1:])
}
}
return line
}
// chainRules parses the `-A` lines of chain in the *filter table of lines, in
// file order, returning one entry per line (nil for a line the parser rejects,
// which counts as an ordinary foreign rule). It scopes to the *filter table so a
// *nat/*mangle INPUT/OUTPUT chain is never counted, and feeds logicalStarts.
func (f *IPTables) chainRules(lines []string, chain string) []*Rule {
var rules []*Rule
filterFound := false
for _, raw := range lines {
line := strings.TrimSpace(raw)
if !filterFound {
if line == "*filter" {
filterFound = true
}
continue
}
// Leave the filter table at its COMMIT or the next table header so a
// *nat/*mangle INPUT/OUTPUT chain is not counted.
if strings.HasPrefix(line, "*") || line == "COMMIT" {
break
}
if f.chainOf(f.ruleLineBody(line)) != chain {
continue
}
rule, _ := f.UnmarshalRule(line, FamilyAny)
rules = append(rules, rule)
}
return rules
}
// iptChainForDirection returns the filter chain name (INPUT, OUTPUT or FORWARD)
// a rule of the given direction lives in.
func iptChainForDirection(d Direction) string {
switch d {
case DirOutput:
return "OUTPUT"
case DirForward:
return "FORWARD"
}
return "INPUT"
}
// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain,
// in file order) to the 1-based logical-rule position it begins, or 0 when it is
// not a logical-rule start — an action line coalesced into a preceding LOG line,
// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an
// insert/move position aligns with the per-chain numbering GetRules reports: a
// LOG line paired with its action is one logical rule beginning at the LOG line,
// while an orphan LOG line (no matching action after it) begins none. The
// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and
// prepareMoveRuleFile can translate a caller position into a physical line.
func (f *IPTables) logicalStarts(rules []*Rule) []int {
starts := make([]int, len(rules))
pos := 0
for i := 0; i < len(rules); i++ {
cur := rules[i]
if cur != nil && cur.Action == ActionInvalid && cur.Log {
var next *Rule
if i+1 < len(rules) {
next = rules[i+1]
}
if logPartner(cur, next) {
// A LOG line paired with its action is one logical rule that begins
// at the LOG line; the action partner (starts[i+1]) stays 0.
pos++
starts[i] = pos
i++
continue
}
// An orphan LOG line is dropped by GetRules, so it begins no logical rule.
continue
}
pos++
starts[i] = pos
}
return starts
}
// chainStarts maps each physical `-A` line of chain (in file order) to the 1-based
// Number GetRules reports for the logical rule that begins there, or 0 for a line
// that begins no logical rule — an action line coalesced into a preceding LOG line,
// or a dropped orphan LOG line. Every other line is its own rule: iptables stores one
// family, one transport and one direction per line, so a rule's Number is its line's
// rank within its chain once LOG pairs are folded.
func (f *IPTables) chainStarts(lines []string, chain string) []int {
return f.logicalStarts(f.chainRules(lines, chain))
}
// addrArgs encodes a source or destination match. dir is "src" or "dst". An
// IP/CIDR uses `-s`/`-d`; a non-address token names an ipset, matched with
// `-m set --match-set <name> <dir>`. A leading "!" negation is emitted before the
// match in both cases.
func (f *IPTables) addrArgs(addr, dir string) []string {
neg, bare := splitAddrNeg(addr)
if isSetRef(addr) {
// The set match negates internally: `-m set ! --match-set name dir`.
out := []string{"-m", "set"}
if neg {
out = append(out, "!")
}
return append(out, "--match-set", bare, dir)
}
// An address negates with a leading `!`: `! -s addr`.
var out []string
if neg {
out = append(out, "!")
}
flag := "-s"
if dir == "dst" {
flag = "-d"
}
return append(out, flag, bare)
}
// combineComment joins the configured prefix and an optional user comment into the
// single comment string stored on a rule. The prefix is always carried so rules
// this library creates stay identifiable: when both are present the prefix is
// followed by a space and the user text; when only one is present it is used
// alone; when neither is present the result is empty.
func combineComment(prefix, comment string) string {
if prefix == "" {
return comment
}
if comment == "" {
return prefix
}
return prefix + " " + comment
}
// iptMultiportValue renders port specs for `-m multiport --dports`, using a
// colon for ranges (e.g. "80,443,1000:2000"). The specs are canonicalized
// (sorted, with contiguous/overlapping ranges merged) so that two rules the model
// considers Equal — port-set order and coalescing are not part of rule identity —
// always render to the same string. Backends that match on the exact marshalled
// line (the CSF/APF hook script) rely on this to stay idempotent.
func iptMultiportValue(specs []PortRange) string {
specs = coalescePortRanges(specs)
parts := make([]string, len(specs))
for i, pr := range specs {
if pr.Start == pr.End {
parts[i] = strconv.FormatUint(uint64(pr.Start), 10)
} else {
parts[i] = fmt.Sprintf("%d:%d", pr.Start, pr.End)
}
}
return strings.Join(parts, ",")
}
// iptablesRuleValid reports whether a filter rule can be expressed directly in
// iptables. A port match requires a concrete port-carrying protocol (TCP/UDP/SCTP),
// and an ICMP type is only meaningful on an ICMP protocol, so both are rejected
// rather than silently emitting an invalid iptables-save line.
func iptablesRuleValid(r *Rule) error {
if r.PortNeedsConcreteProtocol() {
return fmt.Errorf("a port requires a tcp, udp, or sctp protocol")
}
if err := r.checkICMPType(); err != nil {
return err
}
return nil
}
// quoteCommentToken double-quotes v for a comment/log-prefix token in an
// iptables-save-format rule line, escaping only backslash and double-quote so it
// round-trips through the shlex.Split reader (strconv.Quote is unusable: its
// \t/\n/\uXXXX escapes are not un-escaped by shlex). A literal newline or
// carriage return is rejected outright, since it would split the one-line rule.
func (f *IPTables) quoteCommentToken(v string) (string, error) {
if strings.ContainsAny(v, "\n\r") {
return "", fmt.Errorf("a comment cannot contain a newline")
}
var b strings.Builder
b.WriteByte('"')
for _, r := range v {
if r == '\\' || r == '"' {
b.WriteByte('\\')
}
b.WriteRune(r)
}
b.WriteByte('"')
return b.String(), nil
}
// stateValue renders a conntrack state set as an upper-case comma list (e.g.
// "NEW,ESTABLISHED").
func (f *IPTables) stateValue(s ConnState) string {
names := s.Strings()
for i, n := range names {
names[i] = strings.ToUpper(n)
}
return strings.Join(names, ",")
}
// marshalMatches builds the iptables-save match tokens for a rule (everything
// up to but not including the `-j <target>`), including any rate/connection
// limit and the identifying comment. MarshalRule and the LOG-line encoder share
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
// A TCPUDP rule has no single-line iptables form; it must be fanned out into a
// tcp row and a udp row before reaching this row-level marshaller. Assert the
// caller already expanded it rather than emit an invalid `-p tcpudp` line.
if err := r.CheckExpandedProtocol(); err != nil {
return nil, err
}
if err := iptablesRuleValid(r); err != nil {
return nil, err
}
// Start with the APPEND command and the chain (INPUT, OUTPUT or FORWARD).
parts := []string{}
switch r.Direction {
case DirOutput:
parts = append(parts, "-A", "OUTPUT")
case DirForward:
parts = append(parts, "-A", "FORWARD")
default:
parts = append(parts, "-A", "INPUT")
}
// Add source and destination. A non-address token names an ipset, matched with
// `-m set --match-set` rather than `-s`/`-d`.
if r.Source != "" {
parts = append(parts, f.addrArgs(r.Source, "src")...)
}
if r.Destination != "" {
parts = append(parts, f.addrArgs(r.Destination, "dst")...)
}
// Interface match. `-i` is only valid on INPUT and `-o` only on OUTPUT; the
// FORWARD chain sees both an ingress and an egress interface, so it accepts
// either. Reject only the pairings the chain cannot express.
if r.IsOutput() && r.InInterface != "" {
return nil, fmt.Errorf("an input interface cannot be matched on an output rule")
}
if r.IsInput() && r.OutInterface != "" {
return nil, fmt.Errorf("an output interface cannot be matched on an input rule")
}
if r.InInterface != "" {
parts = append(parts, "-i", r.InInterface)
}
if r.OutInterface != "" {
parts = append(parts, "-o", r.OutInterface)
}
// Append protocol.
if r.Proto != ProtocolAny {
parts = append(parts, "-p", r.Proto.String())
}
// An ICMP type match uses the icmp/icmp6 match module.
if r.Proto.IsICMP() && r.ICMPType != nil {
if r.Proto == ICMPv6 {
parts = append(parts, "-m", "icmp6", "--icmpv6-type", strconv.Itoa(int(*r.ICMPType)))
} else {
parts = append(parts, "-m", "icmp", "--icmp-type", strconv.Itoa(int(*r.ICMPType)))
}
}
srcSpecs := r.SourcePortSpecs()
dstSpecs := r.PortSpecs()
// If source port(s) defined, add them. A concrete protocol is guaranteed above.
if len(srcSpecs) == 1 && srcSpecs[0].Start == srcSpecs[0].End {
parts = append(parts, "-m", r.Proto.String(), "--sport", strconv.FormatUint(uint64(srcSpecs[0].Start), 10))
} else if len(srcSpecs) > 0 {
parts = append(parts, "-m", "multiport", "--sports", iptMultiportValue(srcSpecs))
}
// If destination port(s) defined, add them.
if len(dstSpecs) == 1 && dstSpecs[0].Start == dstSpecs[0].End {
parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(dstSpecs[0].Start), 10))
} else if len(dstSpecs) > 0 {
parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(dstSpecs))
}
// Connection-tracking state match.
if r.State != 0 {
parts = append(parts, "-m", "conntrack", "--ctstate", f.stateValue(r.State))
}
// Rate limit: `-m limit` matches only while under the configured rate.
if r.RateLimit != nil {
parts = append(parts, "-m", "limit", "--limit", r.RateLimit.String())
if r.RateLimit.Burst > 0 {
parts = append(parts, "--limit-burst", strconv.FormatUint(uint64(r.RateLimit.Burst), 10))
}
}
// Connection limit: `-m connlimit` matches while the tracked count is over
// the limit. The default mask counts per source; a mask of 0 counts globally.
if r.ConnLimit != nil {
parts = append(parts, "-m", "connlimit", "--connlimit-above", strconv.FormatUint(uint64(r.ConnLimit.Count), 10))
if !r.ConnLimit.PerSource {
parts = append(parts, "--connlimit-mask", "0")
}
}
// Attach a comment. A user-supplied Comment is carried alongside the
// configured prefix (prefix + " " + comment) so rules this library creates
// stay identifiable; with no user comment the prefix alone tags the rule.
// The comment is not part of the rule identity, so it is ignored when
// comparing rules.
comment := combineComment(f.rulePrefix, r.Comment)
if comment != "" {
quoted, err := f.quoteCommentToken(comment)
if err != nil {
return nil, err
}
parts = append(parts, "-m", "comment", "--comment", quoted)
}
return parts, nil
}
// MarshalRule encodes a rule as a single iptables-save rulespec ending in its
// action target.
func (f *IPTables) MarshalRule(r *Rule) (string, error) {
parts, err := f.marshalMatches(r)
if err != nil {
return "", err
}
parts = append(parts, "-j", strings.ToUpper(r.Action.String()))
return strings.Join(parts, " "), nil
}
// marshalLogLine encodes the LOG half of a logged rule: the same matches ending
// in a non-terminal LOG target carrying the optional prefix.
func (f *IPTables) marshalLogLine(r *Rule) (string, error) {
parts, err := f.marshalMatches(r)
if err != nil {
return "", err
}
parts = append(parts, "-j", "LOG")
if r.LogPrefix != "" {
quoted, err := f.quoteCommentToken(r.LogPrefix)
if err != nil {
return "", err
}
parts = append(parts, "--log-prefix", quoted)
}
return strings.Join(parts, " "), nil
}
// marshalRuleLines returns the save-file lines representing r: a LOG line
// followed by the action line when r.Log is set (iptables cannot both log and
// take a terminal action in one rule), otherwise just the action line.
func (f *IPTables) marshalRuleLines(r *Rule) ([]string, error) {
action, err := f.MarshalRule(r)
if err != nil {
return nil, err
}
if !r.Log {
return []string{action}, nil
}
logLine, err := f.marshalLogLine(r)
if err != nil {
return nil, err
}
return []string{logLine, action}, nil
}
// readAllLines reads every line of an iptables-save file.
func (f *IPTables) readAllLines(path string) ([]string, error) {
fd, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
var lines []string
scanner := bufio.NewScanner(fd)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
// prepareInsertRuleFile is like prepareAddRuleFile but inserts the rule at the
// given 1-based position within its chain.
func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) {
if position <= 0 {
position = 1
}
existing, err := f.parseFilterFile(filePath, FamilyAny)
if err != nil {
return nil, err
}
for _, e := range existing {
if e.EqualBase(r, true) {
return nil, nil
}
}
ruleLines, err := f.marshalRuleLines(r)
if err != nil {
return nil, err
}
fd, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
af, err := newAtomicFile(filePath, 0644)
if err != nil {
return nil, err
}
// Match the target chain by an exact chain-name compare, not a prefix: a
// foreign chain whose name merely starts with INPUT/OUTPUT (e.g. a firewalld
// "INPUT_direct" chain) must not be counted, or the 1-based position would
// diverge from the per-direction numbering GetRules reports.
expectedChain := iptChainForDirection(r.Direction)
// Precompute the 1-based Number each in-chain line begins, mirroring GetRules'
// numbering (a LOG+action pair is one logical rule, an orphan LOG line is none).
// Indexing this as the write pass scans keeps the insert aligned with the
// position GetRules reports and never splits a logged rule's two lines or a
// fanned-out tcp/udp pair.
allLines, err := f.readAllLines(filePath)
if err != nil {
af.Abort()
return nil, err
}
chainStarts := f.chainStarts(allLines, expectedChain)
chainIdx := 0
scanner := bufio.NewScanner(fd)
filterFound := false
writtenRule := false
writeRule := func() {
for _, l := range ruleLines {
_, _ = fmt.Fprintln(af, l)
}
writtenRule = true
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !filterFound {
if line == "*filter" {
filterFound = true
}
_, _ = fmt.Fprintln(af, line)
continue
}
if !writtenRule && f.chainOf(f.ruleLineBody(line)) == expectedChain {
pos := 0
if chainIdx < len(chainStarts) {
pos = chainStarts[chainIdx]
}
chainIdx++
if pos == position {
writeRule()
}
}
if !writtenRule && line == "COMMIT" {
writeRule()
}
_, _ = fmt.Fprintln(af, line)
}
// A read error means the staged file is truncated; discard it rather than
// installing a partial ruleset.
if err := scanner.Err(); err != nil {
af.Abort()
return nil, err
}
if !writtenRule {
af.Abort()
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
}
return af, nil
}
// InsertRule inserts rule before the given 1-based position in the iptables save
// file. A non-positive position is treated as 1; a position larger than the
// current rule count appends the rule.
func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) {
return f.prepareInsertRuleFile(path, r, position)
})
}
// extractRuleLines returns the raw save-file lines belonging to the first rule
// equal to r, the index where they start, or a negative index when the rule is
// not present. It coalesces LOG+action lines for logged rules. Family is not
// compared: a save file holds exactly one family, so the caller has already scoped
// the search by choosing which file to read.
func (f *IPTables) extractRuleLines(lines []string, r *Rule) ([]string, int, error) {
var result []string
var resultIdx int
filterFound := false
var pendingLog string
var pendingIdx int
var pendingRule *Rule
flushPending := func() {
pendingRule = nil
}
for i, raw := range lines {
line := strings.TrimSpace(raw)
if !filterFound {
if line == "*filter" {
filterFound = true
}
continue
}
// Leave filter scope at the table's COMMIT or the next table header. The save
// file is a full iptables-save dump, so *nat/*mangle also carry INPUT/OUTPUT
// chains; without this a foreign nat/mangle rule that parses would be extracted
// (and later removed from its real table and spliced into *filter) as if it
// were a filter rule. Mirrors prepareRemoveRuleFile's scoping.
if strings.HasPrefix(line, "*") || line == "COMMIT" {
flushPending()
if line != "*filter" {
filterFound = false
}
continue
}
if f.IgnoreLine(line) {
flushPending()
continue
}
rule, err := f.UnmarshalRule(line, FamilyAny)
if err != nil {
flushPending()
continue
}
if rule.Action == ActionInvalid && rule.Log {
flushPending()
pendingLog = raw
pendingIdx = i
pendingRule = rule
continue
}
logical := rule
coalesced := logPartner(pendingRule, rule)
if coalesced {
logical = mergeLogPair(pendingRule, rule)
}
if logical.EqualBase(r, true) {
// Only bundle the held LOG line when it is actually this rule's LOG
// partner (it coalesced). A standalone LOG line that did not coalesce is
// unrelated and must stay where it is, not be dragged with the rule.
if coalesced {
result = []string{pendingLog, raw}
resultIdx = pendingIdx
} else {
result = []string{raw}
resultIdx = i
}
return result, resultIdx, nil
}
flushPending()
}
flushPending()
return nil, -1, nil
}
// stageLines writes lines to a fresh atomicFile for path and returns it
// uncommitted, so a caller staging several family files can commit them together
// once all have been prepared.
func (f *IPTables) stageLines(path string, lines []string) (*atomicFile, error) {
af, err := newAtomicFile(path, 0644)
if err != nil {
return nil, err
}
w := bufio.NewWriter(af)
for _, l := range lines {
_, _ = fmt.Fprintln(w, l)
}
if err := w.Flush(); err != nil {
af.Abort()
return nil, err
}
return af, nil
}
// prepareMoveRuleFile removes the first matching rule and re-inserts it at the
// given 1-based position within its chain.
func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) {
if position <= 0 {
position = 1
}
lines, err := f.readAllLines(filePath)
if err != nil {
return nil, err
}
// Exact chain-name compare so a foreign chain whose name starts with
// INPUT/OUTPUT is not counted (see prepareInsertRuleFile).
expectedChain := iptChainForDirection(r.Direction)
extracted, removedIdx, err := f.extractRuleLines(lines, r)
if err != nil {
return nil, err
}
if removedIdx < 0 {
return nil, nil
}
without := make([]string, 0, len(lines)-len(extracted))
for i, l := range lines {
if i >= removedIdx && i < removedIdx+len(extracted) {
continue
}
without = append(without, l)
}
// Re-insert at the requested 1-based position. A position past the last rule in
// the chain falls through to the COMMIT branch below, which appends after the
// chain's last rule — so no explicit rule count or clamp is needed here (see
// prepareInsertRuleFile, which relies on the same COMMIT fallback).
// Precompute the 1-based Number each in-chain line begins over the post-removal
// lines, mirroring GetRules' numbering so the re-inserted rule
// lands at the requested position and never between a LOG line and its action
// or between a fanned-out tcp/udp pair.
chainStarts := f.chainStarts(without, expectedChain)
chainIdx := 0
out := make([]string, 0, len(without)+len(extracted))
filterFound := false
inserted := false
for _, raw := range without {
line := strings.TrimSpace(raw)
if !filterFound {
if line == "*filter" {
filterFound = true
}
out = append(out, raw)
continue
}
if !inserted && f.chainOf(f.ruleLineBody(line)) == expectedChain {
pos := 0
if chainIdx < len(chainStarts) {
pos = chainStarts[chainIdx]
}
chainIdx++
if pos == position {
out = append(out, extracted...)
inserted = true
}
}
if !inserted && line == "COMMIT" {
out = append(out, extracted...)
inserted = true
}
out = append(out, raw)
}
if !inserted {
return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file")
}
return f.stageLines(filePath, out)
}
// MoveRule moves an existing rule to the given 1-based position within its chain.
func (f *IPTables) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) {
return f.prepareMoveRuleFile(path, r, position)
})
}
// RemoveRule removes a rule from the zone.
func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
return f.applyRuleFiles(r, f.prepareRemoveRuleFile)
}
// parseNATTarget parses an iptables NAT target ("addr", "addr:port" or
// "[v6]:port") into its address and port.
func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) {
if strings.HasPrefix(tok, "[") {
if end := strings.Index(tok, "]"); end >= 0 {
addr = tok[1:end]
rest := tok[end+1:]
if strings.HasPrefix(rest, ":") {
if p, err := strconv.ParseUint(rest[1:], 10, 16); err == nil {
port = uint16(p)
}
}
return addr, port
}
}
if strings.Count(tok, ":") == 1 {
host, ps, _ := strings.Cut(tok, ":")
if p, err := strconv.ParseUint(ps, 10, 16); err == nil {
return host, uint16(p)
}
}
return tok, 0
}
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) {
tokens, err := shlex.Split(spec, true)
if err != nil {
return nil, err
}
// An iptables-save line may carry a leading [pkts:bytes] counter prefix
// (iptables-save -c). NATRule has no counter fields, so just strip it before
// parsing — mirroring the filter parser so a counter-annotated save file's
// NAT rules are not silently dropped.
if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") {
tokens = tokens[1:]
}
if len(tokens) < 2 {
return nil, fmt.Errorf("unexpected token length")
}
r := &NATRule{Family: family}
switch tokens[1] {
case "PREROUTING", "POSTROUTING":
default:
// The NATRule model derives its chain from Kind (DNAT/Redirect => PREROUTING,
// SNAT/Masquerade => POSTROUTING) and has no direction field, so an OUTPUT-chain
// nat rule (locally-generated DNAT) cannot be represented distinctly — surfacing
// it would make MarshalNATRule relocate it to PREROUTING on Restore. Treat the
// OUTPUT chain (and any other) as foreign: skip it on read so it is left in place
// verbatim rather than moved (see managedNATChain).
return nil, fmt.Errorf("not a managed nat chain: %s", tokens[1])
}
i := 2
switch tokens[0] {
case "-A", "--append":
case "-I", "--insert":
if i < len(tokens) {
if _, err := strconv.Atoi(tokens[i]); err == nil {
i++
}
}
default:
return nil, fmt.Errorf("unsupported command provided")
}
not := false
for ; i < len(tokens); i++ {
switch tokens[i] {
case "!":
not = true
continue
case "-s", "--source":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid source parameter")
}
if not {
r.Source = "!" + tokens[i]
} else {
r.Source = tokens[i]
}
case "-d", "--destination":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid destination parameter")
}
if not {
r.Destination = "!" + tokens[i]
} else {
r.Destination = tokens[i]
}
case "-i", "--in-interface", "-o", "--out-interface":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid interface parameter")
}
r.Interface = tokens[i]
case "-p", "--protocol":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid protocol parameter")
}
r.Proto = GetProtocol(tokens[i])
case "--dport", "--destination-port":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid dport parameter")
}
pr, perr := ParsePortRange(tokens[i])
if perr != nil {
return nil, perr
}
if pr.Start == pr.End {
r.Port = pr.Start
} else {
r.Ports = []PortRange{pr}
}
case "-m", "--match":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid match parameter")
}
switch tokens[i] {
case "set":
// -m set [!] --match-set <name> src|dst names an ipset in place of an
// address (see the filter parser).
i++
setNot := false
if i < len(tokens) && tokens[i] == "!" {
setNot = true
i++
}
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
return nil, fmt.Errorf("unsupported set match")
}
name := tokens[i+1]
dir := tokens[i+2]
i += 2
if setNot {
name = "!" + name
}
switch dir {
case "src":
r.Source = name
case "dst":
r.Destination = name
default:
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
}
case "comment":
if i+2 >= len(tokens) || tokens[i+1] != "--comment" {
return nil, fmt.Errorf("invalid match parameter")
}
i += 2
// A NAT rule carries no user comment, only the prefix tag; its
// presence marks the rule as one this library tagged.
if _, hasPrefix := prefixedComment(f.rulePrefix, tokens[i]); hasPrefix {
r.HasPrefix = true
}
case "tcp", "udp", "sctp":
if i+2 < len(tokens) && (tokens[i+1] == "--dport" || tokens[i+1] == "--destination-port") {
i += 2
pr, perr := ParsePortRange(tokens[i])
if perr != nil {
return nil, perr
}
if pr.Start == pr.End {
r.Port = pr.Start
} else {
r.Ports = []PortRange{pr}
}
}
case "multiport":
if i+2 >= len(tokens) {
return nil, fmt.Errorf("invalid multiport match")
}
switch tokens[i+1] {
case "--dports", "--dport", "--ports", "--port":
default:
return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1])
}
i += 2
specs, perr := iptParsePorts(tokens[i])
if perr != nil {
return nil, perr
}
if len(specs) == 1 && specs[0].Start == specs[0].End {
r.Port = specs[0].Start
} else {
r.Ports = specs
}
default:
return nil, fmt.Errorf("unsupported match option: %s", tokens[i])
}
case "-j", "--jump":
i++
if i >= len(tokens) {
return nil, fmt.Errorf("invalid jump parameter")
}
switch tokens[i] {
case "DNAT":
r.Kind = DNAT
if i+2 < len(tokens) && tokens[i+1] == "--to-destination" {
i += 2
r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i])
}
case "REDIRECT":
r.Kind = Redirect
if i+2 < len(tokens) && tokens[i+1] == "--to-ports" {
i += 2
p, perr := strconv.ParseUint(tokens[i], 10, 16)
if perr != nil {
return nil, fmt.Errorf("invalid redirect port %q", tokens[i])
}
r.ToPort = uint16(p)
}
case "SNAT":
r.Kind = SNAT
if i+2 < len(tokens) && tokens[i+1] == "--to-source" {
i += 2
r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i])
}
case "MASQUERADE":
r.Kind = Masquerade
default:
return nil, fmt.Errorf("unsupported nat target: %s", tokens[i])
}
default:
return nil, fmt.Errorf("unsupported option: %s", tokens[i])
}
not = false
}
if r.Kind == NATInvalid {
return nil, fmt.Errorf("no nat action was provided")
}
if r.Family == FamilyAny {
r.Family = r.impliedFamily()
}
return r, nil
}
// fileFamily returns the IP family of one of this backend's save files.
func (f *IPTables) fileFamily(path string) Family {
if path == f.IP6Path {
return IPv6
}
return IPv4
}
// natRulesInFile parses the nat-table rules from a save file.
func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) {
lines, err := f.readAllLines(path)
if err != nil {
return nil, err
}
family := f.fileFamily(path)
var rules []*NATRule
inNat := false
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "*nat" {
inNat = true
continue
}
if !inNat {
continue
}
if line == "COMMIT" {
inNat = false
continue
}
if f.IgnoreLine(line) {
continue
}
nr, err := f.UnmarshalNATRule(line, family)
if err != nil {
continue
}
rules = append(rules, nr)
}
// Return the rules; GetNATRules assigns Number per family (the other callers use
// the result only for dedup and never read Number).
return rules, nil
}
// GetNATRules returns the existing NAT rules from the zone.
func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
// Each save-file line is its own NAT rule, pinned to the family of the file it
// lives in. Number each family's nat chains independently, as GetRules does for
// the filter chains, so a rule's Number matches the InsertNATRule position within
// the chain it actually lives in.
v4, err := f.natRulesInFile(f.IP4Path)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
}
v6, err := f.natRulesInFile(f.IP6Path)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
}
numberNATByChain(v4)
numberNATByChain(v6)
rules = append(rules, v4...)
rules = append(rules, v6...)
return rules, nil
}
// natChain returns the nat-table chain a NAT rule belongs in.
func (f *IPTables) natChain(r *NATRule) string {
if r.Kind.isSource() {
return "POSTROUTING"
}
return "PREROUTING"
}
// natTarget renders an iptables NAT translation target "addr" or "addr:port",
// bracketing an IPv6 address when a port is present.
func natTarget(fam Family, addr string, port uint16) string {
if port == 0 {
return addr
}
if fam == IPv6 || familyOfAddr(addr) == IPv6 {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// MarshalNATRule encodes a NAT rule as an iptables-save rulespec for the nat
// table (e.g. `-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination ...`).
func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) {
if err := r.validate(); err != nil {
return "", err
}
fam := r.impliedFamily()
parts := []string{"-A", f.natChain(r)}
if r.Source != "" {
parts = append(parts, f.addrArgs(r.Source, "src")...)
}
if r.Destination != "" {
parts = append(parts, f.addrArgs(r.Destination, "dst")...)
}
// Interface, bound to the translation direction.
if r.Interface != "" {
if r.Kind.isSource() {
parts = append(parts, "-o", r.Interface)
} else {
parts = append(parts, "-i", r.Interface)
}
}
if r.Proto != ProtocolAny {
parts = append(parts, "-p", r.Proto.String())
}
specs := r.PortSpecs()
if len(specs) == 1 && specs[0].Start == specs[0].End {
parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(specs[0].Start), 10))
} else if len(specs) > 0 {
parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(specs))
}
if f.rulePrefix != "" {
quoted, err := f.quoteCommentToken(f.rulePrefix)
if err != nil {
return "", err
}
parts = append(parts, "-m", "comment", "--comment", quoted)
}
switch r.Kind {
case DNAT:
parts = append(parts, "-j", "DNAT", "--to-destination", natTarget(fam, r.ToAddress, r.ToPort))
case Redirect:
parts = append(parts, "-j", "REDIRECT", "--to-ports", strconv.FormatUint(uint64(r.ToPort), 10))
case SNAT:
parts = append(parts, "-j", "SNAT", "--to-source", natTarget(fam, r.ToAddress, r.ToPort))
case Masquerade:
parts = append(parts, "-j", "MASQUERADE")
default:
return "", fmt.Errorf("invalid nat kind")
}
return strings.Join(parts, " "), nil
}
// writeAllLines atomically replaces path with the provided lines, preserving
// the existing file's mode and ownership.
func (f *IPTables) writeAllLines(path string, lines []string) error {
af, err := newAtomicFile(path, 0644)
if err != nil {
return err
}
defer af.Abort()
w := bufio.NewWriter(af)
for _, l := range lines {
_, _ = fmt.Fprintln(w, l)
}
if err := w.Flush(); err != nil {
return err
}
return af.Commit()
}
// editNATFile inserts or removes a NAT rule line within a save file's nat table,
// creating the table section when adding to a file that lacks one.
func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) error {
lines, err := f.readAllLines(path)
if err != nil {
return err
}
// Locate the nat section and its COMMIT.
natStart, commitIdx := -1, -1
inNat := false
for i, raw := range lines {
t := strings.TrimSpace(raw)
if t == "*nat" {
natStart = i
inNat = true
continue
}
if inNat && t == "COMMIT" {
commitIdx = i
break
}
}
if add {
// Skip if an equivalent rule already exists.
existing, err := f.natRulesInFile(path)
if err != nil {
return err
}
for _, e := range existing {
if e.EqualBase(r) {
return nil
}
}
if natStart == -1 || commitIdx == -1 {
// No nat table yet; append a fresh one carrying the rule.
section := make([]string, 0, len(defaultNATSection)+1)
section = append(section, defaultNATSection[:len(defaultNATSection)-1]...)
section = append(section, line, "COMMIT")
lines = append(lines, section...)
return f.writeAllLines(path, lines)
}
// Insert before COMMIT.
updated := make([]string, 0, len(lines)+1)
updated = append(updated, lines[:commitIdx]...)
updated = append(updated, line)
updated = append(updated, lines[commitIdx:]...)
return f.writeAllLines(path, updated)
}
// Removal: drop the matching nat rule line.
if natStart == -1 || commitIdx == -1 {
return nil
}
updated := make([]string, 0, len(lines))
removed := false
inNat = false
for _, raw := range lines {
t := strings.TrimSpace(raw)
if t == "*nat" {
inNat = true
updated = append(updated, raw)
continue
}
if inNat && t == "COMMIT" {
inNat = false
updated = append(updated, raw)
continue
}
if inNat && !removed && !f.IgnoreLine(t) {
if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) {
removed = true
continue
}
}
updated = append(updated, raw)
}
if !removed {
return nil
}
return f.writeAllLines(path, updated)
}
// natPaths returns the save files a NAT rule applies to, per its family.
func (f *IPTables) natPaths(r *NATRule) []string {
fam := r.impliedFamily()
var paths []string
if fam == IPv4 || fam == FamilyAny {
paths = append(paths, f.IP4Path)
}
if fam == IPv6 || fam == FamilyAny {
paths = append(paths, f.IP6Path)
}
return paths
}
// defaultNATSection is the nat table scaffold written when a save file has none.
var defaultNATSection = []string{
"*nat",
":PREROUTING ACCEPT [0:0]",
":INPUT ACCEPT [0:0]",
":OUTPUT ACCEPT [0:0]",
":POSTROUTING ACCEPT [0:0]",
"COMMIT",
}
// AddNATRule adds a NAT rule to the zone.
func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
for _, path := range f.natPaths(r) {
if err := f.editNATFile(path, r, line, true); err != nil {
return err
}
}
return nil
}
// insertNATFile inserts a NAT rule line at the given 1-based position within its
// nat chain, creating the table section when the file lacks one. Position counts
// only lines in the rule's own chain (PREROUTING or POSTROUTING); a non-positive
// position is treated as 1 and a position past the chain's end appends after the
// chain's last rule.
func (f *IPTables) insertNATFile(path string, r *NATRule, line string, position int) error {
if position <= 0 {
position = 1
}
// Skip if an equivalent rule already exists.
existing, err := f.natRulesInFile(path)
if err != nil {
return err
}
for _, e := range existing {
if e.EqualBase(r) {
return nil
}
}
lines, err := f.readAllLines(path)
if err != nil {
return err
}
// Locate the nat section and its COMMIT.
natStart, commitIdx := -1, -1
inNat := false
for i, raw := range lines {
t := strings.TrimSpace(raw)
if t == "*nat" {
natStart = i
inNat = true
continue
}
if inNat && t == "COMMIT" {
commitIdx = i
break
}
}
if natStart == -1 || commitIdx == -1 {
// No nat table yet; append a fresh one carrying the rule.
section := make([]string, 0, len(defaultNATSection)+1)
section = append(section, defaultNATSection[:len(defaultNATSection)-1]...)
section = append(section, line, "COMMIT")
lines = append(lines, section...)
return f.writeAllLines(path, lines)
}
// Find the insertion index: before the position-th rule in the rule's chain,
// or after the chain's last rule when position runs past the end. Default to
// COMMIT so a chain with no existing rules still lands inside the nat table.
// Exact chain-name compare so a foreign chain whose name starts with the
// target chain (e.g. "PREROUTING_direct") is not counted (see
// prepareInsertRuleFile).
chainName := f.natChain(r)
insertAt := commitIdx
lastChainLine := -1
pos := 0
for i := natStart + 1; i < commitIdx; i++ {
t := strings.TrimSpace(lines[i])
if f.chainOf(f.ruleLineBody(t)) == chainName {
lastChainLine = i
pos++
if pos == position {
insertAt = i
break
}
}
}
if pos < position && lastChainLine >= 0 {
insertAt = lastChainLine + 1
}
updated := make([]string, 0, len(lines)+1)
updated = append(updated, lines[:insertAt]...)
updated = append(updated, line)
updated = append(updated, lines[insertAt:]...)
return f.writeAllLines(path, updated)
}
// InsertNATRule inserts a NAT rule at the given 1-based position within its nat
// chain. A non-positive position is treated as 1; a position larger than the
// chain's current rule count appends the rule.
func (f *IPTables) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
line, err := f.MarshalNATRule(r)
if err != nil {
return err
}
for _, path := range f.natPaths(r) {
if err := f.insertNATFile(path, r, line, position); err != nil {
return err
}
}
return nil
}
// RemoveNATRule removes a NAT rule from the zone.
func (f *IPTables) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
for _, path := range f.natPaths(r) {
if err := f.editNATFile(path, r, "", false); err != nil {
return err
}
}
return nil
}
// parsePolicyLine decodes a `:CHAIN POLICY [counters]` chain declaration.
func (f *IPTables) parsePolicyLine(line string) (chain string, action Action, ok bool) {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, ":") {
return "", 0, false
}
fields := strings.Fields(t)
if len(fields) < 2 {
return "", 0, false
}
switch fields[1] {
case "ACCEPT":
action = Accept
case "DROP":
action = Drop
default:
return "", 0, false
}
return strings.TrimPrefix(fields[0], ":"), action, true
}
// policyFromFile reads the INPUT/OUTPUT/FORWARD chain policies from an
// iptables-save file. A direction whose chain line is absent is reported as
// ActionInvalid.
func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) {
lines, err := f.readAllLines(path)
if err != nil {
return nil, err
}
p := &DefaultPolicy{}
// Only the *filter table carries the input/output/forward policy. The other
// tables (*nat, *mangle, *raw, ...) declare their own :INPUT/:OUTPUT built-in
// chains — *nat's is always ACCEPT (iptables rejects any other policy there),
// while *mangle/*raw can carry any policy but are not filtering tables — and
// iptables-save emits them after *filter, so scanning table-agnostically would
// let one of those chains shadow a hardened filter policy (e.g. report
// Input=Accept when filter INPUT is DROP). Track the table.
inFilter := false
for _, raw := range lines {
if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") {
inFilter = t == "*filter"
continue
}
if !inFilter {
continue
}
chain, action, ok := f.parsePolicyLine(raw)
if !ok {
continue
}
switch chain {
case "INPUT":
p.Input = action
case "OUTPUT":
p.Output = action
case "FORWARD":
p.Forward = action
}
}
return p, nil
}
// GetDefaultPolicy returns the default action applied to packets that match no rule.
func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
v4, err := f.policyFromFile(f.IP4Path)
if err != nil {
return nil, err
}
v6, err := f.policyFromFile(f.IP6Path)
if err != nil {
return nil, err
}
// SetDefaultPolicy writes both families identically, so on a host this library
// manages they always agree. A divergence means the IPv4 and IPv6 chain
// policies were set out of band and there is no single policy to report.
if *v4 != *v6 {
return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6)
}
return v4, nil
}
// setPolicyFile rewrites the chain declaration lines in an iptables-save
// file for the directions named in policy, preserving the counter slots.
func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error {
lines, err := f.readAllLines(path)
if err != nil {
return err
}
updated := make([]string, len(lines))
// Only rewrite policy lines inside the *filter table; the other tables
// (nat/mangle/raw/...) declare their own built-in chains — nat's must stay
// ACCEPT (iptables rejects any other policy there), and mangle/raw are not
// filtering tables regardless — so leave them all untouched.
inFilter := false
for i, raw := range lines {
if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") {
inFilter = t == "*filter"
updated[i] = raw
continue
}
chain, _, ok := f.parsePolicyLine(raw)
if !ok || !inFilter {
updated[i] = raw
continue
}
var action Action
switch chain {
case "INPUT":
action = policy.Input
case "OUTPUT":
action = policy.Output
case "FORWARD":
action = policy.Forward
default:
updated[i] = raw
continue
}
if action == ActionInvalid {
updated[i] = raw
continue
}
fields := strings.Fields(raw)
counters := "[0:0]"
if len(fields) >= 3 {
counters = fields[2]
}
updated[i] = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters)
}
return f.writeAllLines(path, updated)
}
// SetDefaultPolicy sets the default action for the directions named in policy.
func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
if policy == nil {
return fmt.Errorf("policy cannot be nil")
}
for _, action := range []Action{policy.Input, policy.Output, policy.Forward} {
if action == Reject {
return fmt.Errorf("iptables chain policy may only be accept or drop")
}
}
for _, path := range []string{f.IP4Path, f.IP6Path} {
if err := f.setPolicyFile(path, policy); err != nil {
return err
}
}
return nil
}
// --- address sets (ipset) ---------------------------------------------------
// ipsetParseType reads the family and type out of an ipset `create` line's
// trailing options.
func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) {
family := IPv4
t := SetHashIP
for i := 2; i < len(fields); i++ {
switch fields[i] {
case "hash:net":
t = SetHashNet
case "hash:ip":
t = SetHashIP
case "family":
if i+1 < len(fields) && fields[i+1] == "inet6" {
family = IPv6
}
}
}
return family, t
}
// GetAddressSets returns the address sets managed by this backend.
func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
out, err := runCommand(ctx, "ipset", "save")
if err != nil {
// ipset not installed, or no sets: nothing to report.
return nil, nil
}
sets := map[string]*AddressSet{}
var names []string
for _, line := range out {
fields := strings.Fields(line)
if len(fields) >= 3 && fields[0] == "create" {
family, t := f.ipsetParseType(fields)
sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t}
names = append(names, fields[1])
}
}
for _, line := range out {
fields := strings.Fields(line)
if len(fields) == 3 && fields[0] == "add" {
if set, ok := sets[fields[1]]; ok {
set.Entries = append(set.Entries, fields[2])
}
}
}
result := make([]*AddressSet, 0, len(names))
for _, n := range names {
result = append(result, sets[n])
}
return result, nil
}
// GetAddressSet returns a single address set by name, or an error if it does not exist.
func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
sets, err := f.GetAddressSets(ctx)
if err != nil {
return nil, err
}
for _, s := range sets {
if s.Name == name {
return s, nil
}
}
return nil, fmt.Errorf("address set %q not found", name)
}
// ipsetTypeSpec renders the ipset type keyword and family option for a set.
func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string {
spec := t.String()
fam := "inet"
if family == IPv6 {
fam = "inet6"
}
return spec + " family " + fam
}
// persistIPSets writes the live ipsets into the layout's save file so a reboot
// restores them before the iptables rules that reference them. When the restore
// unit is installed but not enabled it is enabled first, so it runs on boot.
// When no persistence mechanism is present the sets are left live-only and a
// warning is logged rather than returning an error — the set itself was already
// created live, and the caller asked to add a set, not to guarantee reboot
// persistence the host cannot provide.
func (f *IPTables) persistIPSets(ctx context.Context) error {
if f.IPSetPath == "" {
log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot")
return nil
}
// Ensure the restore unit runs on boot before the rules unit loads.
if f.IPSetService != "" {
if err := enableService(ctx, f.IPSetService); err != nil {
return err
}
}
// Save every live set (foreign sets included: the library persists the actual
// firewall state) into the file the restore unit reads on boot.
out, err := runCommand(ctx, "ipset", "save")
if err != nil {
return err
}
data := strings.Join(out, "\n")
if data != "" {
data += "\n"
}
return writeConfigFile(f.IPSetPath, []byte(data), 0600)
}
// AddAddressSet creates an address set; adding a set that already exists by name is a no-op.
func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error {
if set == nil || set.Name == "" {
return fmt.Errorf("an address set requires a name")
}
// -exist makes create idempotent (re-create over an existing set).
family := set.Family
if family == FamilyAny {
family = IPv4
}
args := []string{"create", set.Name}
args = append(args, strings.Fields(f.ipsetTypeSpec(family, set.Type))...)
args = append(args, "-exist")
if _, err := runCommand(ctx, "ipset", args...); err != nil {
return err
}
for _, entry := range set.Entries {
if _, err := runCommand(ctx, "ipset", "add", set.Name, entry, "-exist"); err != nil {
return err
}
}
return f.persistIPSets(ctx)
}
// RemoveAddressSet removes an address set by name.
func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error {
if _, err := runCommand(ctx, "ipset", "flush", name); err != nil {
// A missing set is a no-op; any other flush failure (permission denied,
// set busy) is real and must be surfaced rather than silently proceeding
// to destroy.
if !strings.Contains(err.Error(), "does not exist") {
return err
}
}
_, err := runCommand(ctx, "ipset", "destroy", name)
// A set that was already gone makes removal idempotent. Every other failure —
// notably "Set cannot be destroyed: it is in use by a kernel component" when a
// live rule still references the set — is real and must be surfaced rather than
// reported as success while the set remains.
if err != nil && !strings.Contains(err.Error(), "does not exist") {
return err
}
return f.persistIPSets(ctx)
}
// AddAddressSetEntry adds an entry to the named set.
func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error {
if _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist"); err != nil {
return err
}
return f.persistIPSets(ctx)
}
// RemoveAddressSetEntry removes an entry from the named set.
func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
_, err := runCommand(ctx, "ipset", "del", name, entry, "-exist")
// A missing entry (or missing set) makes removal idempotent; any other failure
// is real. Persist the resulting set state on success or a no-op removal.
if err != nil && !strings.Contains(err.Error(), "does not exist") {
return err
}
return f.persistIPSets(ctx)
}
// Backup captures the current filter and NAT rules managed by this backend.
func (f *IPTables) Backup(ctx context.Context, zoneName string) (*Backup, error) {
rules, err := f.GetRules(ctx, zoneName)
if err != nil {
return nil, err
}
natRules, err := f.GetNATRules(ctx, zoneName)
if err != nil {
return nil, err
}
// Backup captures the INPUT/OUTPUT/FORWARD filter rules, the nat rules, the
// filter chain default policies and the managed ipsets; Restore replaces exactly
// those on replay, leaving user-defined chains and other tables (which Backup
// does not capture) intact.
backup := &Backup{Rules: rules, NATRules: natRules}
if err := captureBackupState(ctx, f, zoneName, backup); err != nil {
return nil, err
}
return backup, nil
}
// modeledFilterChain reports whether a *filter chain name is one the library
// models as a Rule direction (INPUT, OUTPUT or FORWARD). The file-rewrite paths
// use it to tell a managed rule from a rule in a chain the library does not model
// (a user-defined chain), which must be preserved verbatim.
func (f *IPTables) modeledFilterChain(ch string) bool {
switch ch {
case "INPUT", "OUTPUT", "FORWARD":
return true
}
return false
}
// preservedFilterLines returns the indices of modeled-chain (INPUT/OUTPUT/
// FORWARD) -A lines a rewrite must keep verbatim because GetRules cannot represent
// them as a modeled Rule, so they never appear in the desired set and would
// otherwise be dropped. Two kinds qualify, on the same principle the library
// already applies to a foreign chain (a user-defined chain): a rule the library
// does not model, so it must not be deleted just because it is invisible.
// - A line the rule parser rejects outright — a foreign rule using a match this
// library does not model (e.g. -m recent, -m owner, --tcp-flags).
// - A standalone LOG rule — a non-terminal `-j LOG` line with no action partner
// immediately after it. GetRules coalesces a LOG line with its following
// action line into one logged rule and drops an unpaired one, so it too is
// unmodeled. The pairing mirrors coalesceLoggedRules over the same INPUT/OUTPUT
// sequence parseFilterFile builds.
//
// Rules in other chains are excluded here; the caller preserves those verbatim by
// its own chain check.
func (f *IPTables) preservedFilterLines(lines []string) map[int]bool {
type parsed struct {
idx int
rule *Rule // nil when the line does not parse as a modeled rule.
}
var seq []parsed
inFilter := false
for i, line := range lines {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "*") {
inFilter = t == "*filter"
continue
}
body := f.ruleLineBody(t)
if !inFilter || !strings.HasPrefix(body, "-A ") {
continue
}
// Only modeled-chain lines are decided here; the caller keeps every other
// chain verbatim, so recording them would double-preserve.
if !f.modeledFilterChain(f.chainOf(body)) {
continue
}
r, err := f.UnmarshalRule(t, FamilyAny)
if err != nil {
r = nil // Unmodeled foreign rule: preserve it.
}
seq = append(seq, parsed{i, r})
}
preserved := map[int]bool{}
for k, p := range seq {
// A line the parser rejects is a foreign rule the desired set cannot
// reproduce; keep it.
if p.rule == nil {
preserved[p.idx] = true
continue
}
if p.rule.Action != ActionInvalid || !p.rule.Log {
continue
}
// A LOG line paired with the action line that follows it is a coalesced
// logged rule the desired set reproduces, so it is not preserved here.
var next *Rule
if k+1 < len(seq) {
next = seq[k+1].rule
}
if logPartner(p.rule, next) {
continue
}
preserved[p.idx] = true
}
return preserved
}
// rewriteFilterRules atomically rewrites path so that the *filter table's rule
// (-A) lines are exactly ruleLines, leaving the chain-policy lines, any *nat
// table and all other content untouched. A file with no *filter table gains one.
func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error {
lines, err := f.readAllLines(path)
if err != nil {
return err
}
preserved := f.preservedFilterLines(lines)
out := make([]string, 0, len(lines)+len(ruleLines))
inFilter := false
inserted := false
for idx, line := range lines {
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(t, "*"):
inFilter = t == "*filter"
out = append(out, line)
case inFilter && t == "COMMIT":
if !inserted {
out = append(out, ruleLines...)
inserted = true
}
out = append(out, line)
inFilter = false
case inFilter && strings.HasPrefix(f.ruleLineBody(t), "-A "):
// The library models the INPUT, OUTPUT and FORWARD chains, so the desired
// set can only ever contain those. Drop an existing modeled rule line
// (counter-annotated or not) — the desired set replaces it — but preserve
// a rule in any other chain (a user-defined chain) verbatim, since
// parseFilterFile never captures those chains and dropping them would
// silently delete rules the library does not manage. A modeled-chain line
// the library cannot model (an unmodeled match or a standalone LOG rule)
// is likewise invisible to GetRules and preserved.
if !f.modeledFilterChain(f.chainOf(f.ruleLineBody(t))) {
out = append(out, line)
} else if preserved[idx] {
out = append(out, line)
}
default:
out = append(out, line)
}
}
if !inserted {
out = append(out, "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]")
out = append(out, ruleLines...)
out = append(out, "COMMIT")
}
return f.writeAllLines(path, out)
}
// managedNATChain reports whether a nat-table chain is one this backend reads
// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these
// chains and preserves every other nat chain verbatim — including OUTPUT, whose
// locally-generated DNAT the NATRule model cannot represent distinctly (see
func (f *IPTables) managedNATChain(chain string) bool {
switch chain {
case "PREROUTING", "POSTROUTING":
return true
}
return false
}
// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in
// the managed chains are exactly natLines, leaving the chain-policy lines, any
// user-defined nat chain, the *filter table and all other content untouched. A
// file with no *nat table gains one. It is the nat counterpart of
func (f *IPTables) rewriteNATRules(path string, natLines []string) error {
lines, err := f.readAllLines(path)
if err != nil {
return err
}
out := make([]string, 0, len(lines)+len(natLines))
inNat := false
inserted := false
for _, line := range lines {
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(t, "*"):
inNat = t == "*nat"
out = append(out, line)
case inNat && t == "COMMIT":
if !inserted {
out = append(out, natLines...)
inserted = true
}
out = append(out, line)
inNat = false
case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "):
// Drop an existing rule in a managed chain — the desired set replaces it —
// but preserve a rule in a user-defined nat chain verbatim, mirroring how
// rewriteFilterRules preserves FORWARD/custom-chain rules.
if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) {
out = append(out, line)
}
default:
out = append(out, line)
}
}
if !inserted {
out = append(out, defaultNATSection[:len(defaultNATSection)-1]...)
out = append(out, natLines...)
out = append(out, "COMMIT")
}
return f.writeAllLines(path, out)
}
// Restore replaces the managed INPUT/OUTPUT/FORWARD filter rules and the nat rules
// with the contents of a Backup, splicing them into each family's existing save
// file, and re-asserts the captured filter chain policies and ipsets. User-defined
// chains and the *mangle/*raw tables — none of which Backup captures — are left
// untouched.
func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
// Recreate the ipsets first so a set-referencing rule (@set) resolves when the
// save files are loaded below. The old rules are still loaded at this point, so
// the sets cannot be removed out from under them; AddAddressSet (ipset -exist)
// creates or repopulates each set idempotently.
if err := restoreBackupSets(ctx, f, backup, false); err != nil {
return err
}
// Group rules by family.
groupRules := func() map[Family][]*Rule {
m := map[Family][]*Rule{}
for _, r := range backup.Rules {
fam := r.impliedFamily()
if fam == FamilyAny {
m[IPv4] = append(m[IPv4], r)
m[IPv6] = append(m[IPv6], r)
} else {
m[fam] = append(m[fam], r)
}
}
return m
}
groupNAT := func() map[Family][]*NATRule {
m := map[Family][]*NATRule{}
for _, r := range backup.NATRules {
fam := r.impliedFamily()
if fam == FamilyAny {
m[IPv4] = append(m[IPv4], r)
m[IPv6] = append(m[IPv6], r)
} else {
m[fam] = append(m[fam], r)
}
}
return m
}
for _, fam := range []Family{IPv4, IPv6} {
path := f.IP4Path
if fam == IPv6 {
path = f.IP6Path
}
// Marshal only the rule (-A) lines; rewriteFilterRules/rewriteNATRules splice
// them into the existing save file, replacing the managed chains' rules while
// preserving chain-policy lines, user-defined chains, and the *mangle/*raw
// tables that Backup never captures. A from-scratch scaffold would silently
// reset a DROP policy to ACCEPT and delete every unmanaged rule.
var ruleLines []string
for _, r := range groupRules()[fam] {
c := *r
if c.Family == FamilyAny {
c.Family = fam
}
// A TCPUDP rule has no single-line iptables form; fan it out into a tcp
// row and a udp row before marshalling.
for _, sub := range expandProtocols(&c) {
rl, err := f.marshalRuleLines(sub)
if err != nil {
return err
}
ruleLines = append(ruleLines, rl...)
}
}
var natLines []string
for _, r := range groupNAT()[fam] {
c := *r
if c.Family == FamilyAny {
c.Family = fam
}
rl, err := f.MarshalNATRule(&c)
if err != nil {
return err
}
natLines = append(natLines, rl)
}
if err := f.rewriteFilterRules(path, ruleLines); err != nil {
return err
}
if err := f.rewriteNATRules(path, natLines); err != nil {
return err
}
}
// Re-assert the captured filter chain policies last, so a restore onto a host
// whose default policy differs (e.g. a fresh ACCEPT host) reproduces the backed-
// up policy rather than silently inheriting the current one.
return applyBackupPolicy(ctx, f, zoneName, backup)
}
// Reload restarts the restore service(s) to activate new rules.
func (f *IPTables) Reload(ctx context.Context) error {
if err := restartService(ctx, f.IP4Service); err != nil {
return err
}
// The Debian layout restores both families from one service
// (netfilter-persistent); restarting it twice is redundant.
if f.IP6Service == f.IP4Service {
return nil
}
return restartService(ctx, f.IP6Service)
}
// Close releases manager resources.
func (f *IPTables) Close(ctx context.Context) error {
return nil
}
// applyRulesBatch rewrites the *filter table of each family file so it holds the
// requested rules. When replace is false the existing filter rules are kept and
// the new rules appended (skipping duplicates); when true the filter rules are
// replaced outright. The nat table and chain-policy lines are preserved.
func (f *IPTables) applyRulesBatch(rules []*Rule, replace bool) error {
// Fan each DirAny rule out into an input row plus its swapped output row, and
// each TCPUDP rule into a tcp row plus a udp row, before the per-family loop so
// each half marshals into its own chain line. Directions expand first, then
// protocols, so a DirAny+TCPUDP rule yields four concrete rows.
var expanded []*Rule
for _, r := range rules {
for _, d := range expandDirections(r) {
expanded = append(expanded, expandProtocols(d)...)
}
}
rules = expanded
for _, fam := range []Family{IPv4, IPv6} {
path := f.IP4Path
if fam == IPv6 {
path = f.IP6Path
}
// Assemble the desired rule set for this family.
var desired []*Rule
if !replace {
existing, err := f.parseFilterFile(path, fam)
if err != nil {
return err
}
desired = append(desired, existing...)
}
for _, r := range rules {
rf := r.impliedFamily()
if rf != FamilyAny && rf != fam {
continue
}
c := *r
if c.Family == FamilyAny {
c.Family = fam
}
dup := false
for _, e := range desired {
if e.EqualBase(&c, true) {
dup = true
break
}
}
if dup {
continue
}
desired = append(desired, &c)
}
// Marshal the desired rules to save-file lines.
var ruleLines []string
for _, r := range desired {
rl, err := f.marshalRuleLines(r)
if err != nil {
return err
}
ruleLines = append(ruleLines, rl...)
}
if err := f.rewriteFilterRules(path, ruleLines); err != nil {
return err
}
}
return nil
}
// AddRulesBatch adds every rule in a single rewrite of each family's save file,
// rather than one read-modify-write per rule. It implements RuleBatcher.
func (f *IPTables) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
return f.applyRulesBatch(rules, false)
}
// ReplaceRulesBatch rewrites each family's filter table to hold exactly rules,
// preserving the nat table and chain policies. It implements RuleBatcher.
func (f *IPTables) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error {
return f.applyRulesBatch(rules, true)
}
// detectIPSetLayout reports the ipset save file and restore service to persist
// sets with, or empty strings when the packaging's persistence mechanism is not
// installed. The Debian layout restores sets through a netfilter-persistent
// plugin (proven by ipsetPlugin's presence); the RHEL layout uses a dedicated
// ipset service (proven by its unit or init.d script existing).
func (f *IPTables) detectIPSetLayout(ctx context.Context, layout iptLayout) (path, service string) {
if layout.ipsetPath == "" {
return "", ""
}
if layout.ipsetPlugin != "" {
if matches, _ := filepath.Glob(layout.ipsetPlugin); len(matches) == 0 {
return "", ""
}
return layout.ipsetPath, layout.ipsetService
}
if !serviceInstalled(ctx, layout.ipsetService) {
return "", ""
}
return layout.ipsetPath, layout.ipsetService
}
// prepareAddRuleFile writes an updated copy of filePath, with r inserted, to a
// staged atomicFile and returns it uncommitted. It returns a nil handle (and nil
// error) when no change is needed because the rule already exists. On error the
// staged file is cleaned up. The caller is responsible for committing a returned
// handle (or aborting it).
func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, error) {
// Skip if an equivalent logical rule already exists (LOG+action lines are
// coalesced, so a logged rule is compared as one unit).
existing, err := f.parseFilterFile(filePath, FamilyAny)
if err != nil {
return nil, err
}
for _, e := range existing {
if e.EqualBase(r, true) {
return nil, nil
}
}
// Encode the rule's line(s): a logged rule is a LOG line plus an action line.
ruleLines, err := f.marshalRuleLines(r)
if err != nil {
return nil, err
}
fd, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer func() { _ = fd.Close() }()
// Stage the rewrite, preserving the save file's mode and ownership.
af, err := newAtomicFile(filePath, 0644)
if err != nil {
return nil, err
}
// Scan each line to find where we should insert our rule.
scanner := bufio.NewScanner(fd)
writtenRule := false
filterFound := false
writeRule := func() {
for _, l := range ruleLines {
_, _ = fmt.Fprintln(af, l)
}
writtenRule = true
}
for scanner.Scan() {
// Trim line and check if we skip the line.
line := strings.TrimSpace(scanner.Text())
// Look for the filter, and skip if not reached.
if !filterFound {
if line == "*filter" {
filterFound = true
}
_, _ = fmt.Fprintln(af, line)
continue
}
// Insert the new rule before the first existing rule of any chain (or
// before COMMIT when the table has no rules yet), so AddRule places it at
// the top of the chain.
if !writtenRule {
if line == "COMMIT" {
writeRule()
} else if strings.HasPrefix(f.ruleLineBody(line), "-A ") {
writeRule()
}
}
// Write the original line back to the new file.
_, _ = fmt.Fprintln(af, line)
}
// A read error means the staged file is truncated; discard it rather than
// installing a partial ruleset.
if err := scanner.Err(); err != nil {
af.Abort()
return nil, err
}
// A rule that was never written means the filter table was malformed.
if !writtenRule {
af.Abort()
return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file")
}
return af, nil
}
// prepareRemoveRuleFile writes a copy of filePath, with r removed, to a staged
// atomicFile and returns it uncommitted. It returns a nil handle (and nil error)
// when the rule was not present so no change is needed. On error the staged file
// is cleaned up. The caller is responsible for committing a returned handle (or
// aborting it).
//
// It shares its rule-location logic with prepareMoveRuleFile: both locate a rule
// equal to r (and its LOG partner, if any) via extractRuleLines and splice those
// lines out, so the LOG+action pairing and the *nat/*mangle scoping it depends on
// are defined in exactly one place. A removal clears every line the target covers,
// so a chain holding the same rule twice comes back clean in one pass; the target
// reaching here is already one concrete family/transport/direction cell, since
// applyRuleFiles fanned out the merged axes before calling.
func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) {
lines, err := f.readAllLines(filePath)
if err != nil {
return nil, err
}
found := false
for {
extracted, removedIdx, err := f.extractRuleLines(lines, r)
if err != nil {
return nil, err
}
if removedIdx < 0 {
break
}
found = true
without := make([]string, 0, len(lines)-len(extracted))
for i, l := range lines {
if i >= removedIdx && i < removedIdx+len(extracted) {
continue
}
without = append(without, l)
}
lines = without
}
if !found {
// The rule was not present; no change is needed.
return nil, nil
}
return f.stageLines(filePath, lines)
}