go-firewall/iptables_linux.go
2026-07-08 15:54:48 -05:00

3162 lines
95 KiB
Go

package firewall
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/anmitsu/go-shlex"
dbus "github.com/coreos/go-systemd/dbus"
)
const (
// IPTablesType is the manager type string for the iptables backend.
IPTablesType = "iptables"
// 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 their systemd units.
type IPTables struct {
Conn *dbus.Conn
IP4Service string
IP4Path string
IP6Path string
IP6Service 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 systemd units a supported iptables
// packaging uses. The Debian layout carries the same unit for both families
// (netfilter-persistent.service restores both rules.v4 and rules.v6), so
// ip4Service and ip6Service are equal there.
type iptLayout struct {
ip4Path, ip6Path string
ip4Service, ip6Service string
}
// probeRHELLayout reports the RHEL/iptables-services layout
// (/etc/sysconfig/iptables, iptables.service/ip6tables.service) if its save
// files are both present under root. It does not check root itself, so a
// 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.service", ip6Service: "ip6tables.service",
}
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
}
// 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.service", ip6Service: "netfilter-persistent.service",
}
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 systemd units 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
// Connect to systemd dbus interface.
conn, err := dbus.NewWithContext(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to systemd: %s", err)
}
ipt.Conn = conn
// Find the systemd service for loading iptables rules and confirm it was loaded.
ipt.IP4Service = layout.ip4Service
if err := ipt.requireUnitEnabled(ctx, ipt.IP4Service); err != nil {
ipt.Conn.Close()
return nil, err
}
// 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 unit already confirmed enabled above (the
// Debian layout uses one unit for both families).
ipt.IP6Service = layout.ip6Service
if ipt.IP6Service != ipt.IP4Service {
if err := ipt.requireUnitEnabled(ctx, ipt.IP6Service); err != nil {
ipt.Conn.Close()
return nil, err
}
}
return ipt, nil
}
// requireUnitEnabled returns IPTablesNoService unless service's UnitFileState
// is "enabled".
func (f *IPTables) requireUnitEnabled(ctx context.Context, service string) error {
prop, err := f.Conn.GetUnitPropertyContext(ctx, service, "UnitFileState")
if err != nil {
return fmt.Errorf("error getting service %s property: %s", service, err)
}
if prop.Value.Value() != "enabled" {
return errors.New(IPTablesNoService)
}
return nil
}
// Type returns the manager type.
func (f *IPTables) Type() string {
return IPTablesType
}
// 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
}
// 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
}
// 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
}
// 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, ",")
}
// 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, ",")
}
// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi")
// into PortRange values.
func iptParsePorts(val string) ([]PortRange, error) {
return ParsePortRanges(val, ",")
}
// 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
}
// 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
}
// 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
}
// 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)
}
// 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
// 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"
}
// 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
}
// it so a logged rule's two lines carry identical matches.
func (f *IPTables) marshalMatches(r *Rule) ([]string, error) {
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
}
// 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)
}
// 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
}
// 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 look up a physical line's position as they scan.
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
}
// 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,
// preservedFilterLines and logicalStarts, which walk the same LOG+action
// pairing over different sequence shapes (parsed rules vs. save-file lines).
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
}
// 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
}
// 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 after merging families
// across the two save files (the other callers use the result only for
// EqualBase 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) {
v4, err := f.parseFilterFile(f.IP4Path, IPv4)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
}
rules = append(rules, v4...)
v6, err := f.parseFilterFile(f.IP6Path, IPv6)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
}
rules = append(rules, v6...)
// Merge rules across families, number per direction (before the direction
// merge, so surviving output rows keep the physical position of their still-
// present twin), then collapse each input/output twin into one DirAny rule.
rules = mergeFamilies(rules)
numberByDirection(rules)
rules = mergeDirections(rules)
return
}
// 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
}
// 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)
}
// 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)
})
}
// 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 logical position 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.
allLines, err := f.readAllLines(filePath)
if err != nil {
af.Abort()
return nil, err
}
chainStarts := f.logicalStarts(f.chainRules(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
}
// 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)
})
}
// 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 logical position 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.
chainStarts := f.logicalStarts(f.chainRules(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)
}
// 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.
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
}
// 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 the
// first 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.
func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) {
lines, err := f.readAllLines(filePath)
if err != nil {
return nil, err
}
extracted, removedIdx, err := f.extractRuleLines(lines, r)
if err != nil {
return nil, err
}
if removedIdx < 0 {
// The rule was not present; no change is needed.
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)
}
return f.stageLines(filePath, without)
}
// 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)
}
// 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)
}
// 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 before
// the per-family loop, so each half marshals into its own chain.
var expanded []*Rule
for _, r := range rules {
expanded = append(expanded, expandDirections(r)...)
}
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
}
// 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
}
// 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 ""
}
// 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
// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING.
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
// rewriteFilterRules and is what lets Restore replace managed NAT rules without
// clobbering nat policies or foreign nat rules in other chains.
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)
}
// 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
}
// 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
}
rl, err := f.marshalRuleLines(&c)
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)
}
// 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
}
// 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
}
// 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
}
// Reload reloads the manager to activate new rules.
func (f *IPTables) Reload(ctx context.Context) error {
if err := f.restartUnit(ctx, f.IP4Service); err != nil {
return err
}
// The Debian layout restores both families from one unit
// (netfilter-persistent.service); restarting it twice is redundant.
if f.IP6Service == f.IP4Service {
return nil
}
return f.restartUnit(ctx, f.IP6Service)
}
// restartUnit restarts a systemd service and waits for the job to complete.
// The result channel is buffered so the D-Bus goroutine can always deliver
// the job result even if an early return means we never read it.
func (f *IPTables) restartUnit(ctx context.Context, service string) error {
reschan := make(chan string, 1)
_, err := f.Conn.RestartUnitContext(ctx, service, "replace", reschan)
if err != nil {
return fmt.Errorf("failed to restart %s: %s", service, err)
}
if job := <-reschan; job != "done" {
return fmt.Errorf("failed to restart %s: %s", service, "job is not done")
}
return nil
}
// Close closes the connection to the manager.
func (f *IPTables) Close(ctx context.Context) error {
f.Conn.Close()
return 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()
}
// 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()
}
// 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
}
// 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)
}
// 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
}
// 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"
}
// 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
}
// UnmarshalNATRule decodes an iptables-save nat-table rulespec into a NATRule.
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
}
// 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 after merging families across the
// two save files (the other callers use the result only for EqualBase dedup).
return rules, nil
}
// 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",
}
// 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)
}
// GetNATRules returns the existing NAT rules from the zone.
func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) {
v4, err := f.natRulesInFile(f.IP4Path)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err)
}
rules = append(rules, v4...)
v6, err := f.natRulesInFile(f.IP6Path)
if err != nil {
return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err)
}
rules = append(rules, v6...)
// Merge families, then renumber per nat chain so a collapsed v4/v6 pair leaves
// no gap in the derived Number sequence.
merged := mergeNATFamilies(rules)
numberNATByChain(merged)
return merged, nil
}
// 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
}
// 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
}
// 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)
}
// 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
}
// 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,
}
}
// 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
}
// 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
}
// 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)
}
// 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
}
// 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) ---------------------------------------------------
// 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
}
// 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)
}
// 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 nil
}
// 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 nil
}
return err
}
// AddAddressSetEntry adds an entry to the named set.
func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error {
_, err := runCommand(ctx, "ipset", "add", name, entry, "-exist")
return err
}
// 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")
if err != nil && strings.Contains(err.Error(), "does not exist") {
return nil
}
return err
}