package firewall import ( "bufio" "context" "errors" "fmt" "log" "net" "os" "slices" "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 { IP4Path, IP6Path string // IP6Path and IP6Service are empty on a host whose packaging ships no // ip6tables save file — a system built without IPv6, or one where the // ip6tables package was never installed — and on one whose IPv6 restore // service is not enabled, since nothing would replay the file at boot. Such a // host is managed IPv4-only; see managesIPv6 for what that changes. IP4Service, IP6Service string // IPSetPath and IPSetService describe the optional ipset persistence // mechanism detected for this host. IPSetPath is the file address sets are // staged in — the authority for the sets this backend manages, loaded into the // kernel by Reload and by IPSetService at boot, before the rules that // reference them. Both are empty when no mechanism is installed, in which case // address sets are created live and do not survive a reboot. IPSetPath, IPSetService string // pendingSetRemovals names the sets dropped from the staging file this session // whose kernel-side destroy is still owed. Reload performs them after the rules // services restart, so nothing references the set by then; a set that will not // destroy stays queued. The queue is session state, so a removal staged by a // process that exits before Reload leaves the set live until the next boot. pendingSetRemovals []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 // ip6Path and ip6Service are cleared by probeIPTLayout when the packaging's // IPv6 save file is absent, marking the host IPv4-only. NewIPTables clears // them the same way when the IPv6 restore service is not enabled. 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 } // iptLayouts lists the iptables packagings this backend understands, in probe // order. Debian precedes Arch because both keep their save files in // /etc/iptables under different names, so the order only decides which wins on // a host that somehow carries both. var iptLayouts = []iptLayout{ // RHEL/iptables-services: per-family save files and services, sets restored // by a dedicated ipset service. { ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables", ip4Service: "iptables", ip6Service: "ip6tables", ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset", }, // Debian/Ubuntu iptables-persistent: the single netfilter-persistent service // restores both families, and sets ride one of its plugins. { 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*", }, // Arch/Manjaro: the iptables package's own per-family units restore // /etc/iptables/*.rules, and the ipset package's unit restores // /etc/ipset.conf. { ip4Path: "/etc/iptables/iptables.rules", ip6Path: "/etc/iptables/ip6tables.rules", ip4Service: "iptables", ip6Service: "ip6tables", ipsetPath: "/etc/ipset.conf", ipsetService: "ipset", }, } // managesIPv6 reports whether this host has an ip6tables save file to manage and // a service to restore it. When it does not, the backend runs IPv4-only: reads // report IPv4 rows only, FamilyAny writes narrow to the IPv4 file, and a // concrete-IPv6 write is rejected through checkFamilyManaged. Removals are not // gated — with no v6 file there is nothing v6 to remove, so they are no-ops // rather than errors. func (f *IPTables) managesIPv6() bool { return f.IP6Path != "" } // probeIPTLayout reports the first layout in iptLayouts whose v4 save file is // present under root. The v4 file alone decides the match: a layout whose v6 // partner is absent is reported with its ip6Path and ip6Service cleared, marking // the host IPv4-only, since a system built without IPv6 still has an IPv4 // firewall worth managing. func probeIPTLayout(root string) (iptLayout, bool) { for _, l := range iptLayouts { if _, err := os.Stat(root + l.ip4Path); err != nil { continue } if _, err := os.Stat(root + l.ip6Path); err != nil { l.ip6Path, l.ip6Service = "", "" } return l, true } return iptLayout{}, false } // NewIPTables creates an iptables manager, detecting the save-file layout and // confirming its IPv4 restore service is enabled. An IPv6 half that has no save // file, or whose restore service is not enabled, is left unmanaged rather than // failing. func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { ipt := new(IPTables) ipt.rulePrefix = rulePrefix // Detect which packaging manages the save files on this host. layout, ok := probeIPTLayout("") if !ok { return nil, errors.New(IPTablesNoSave) } 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) } // With no v6 save file there is no v6 restore service to confirm; the host is // managed IPv4-only from here on. A save file whose restore service is not // enabled is the same situation: writes to it would never be applied at boot, // so IPv6 is left unmanaged rather than failing the whole backend, which still // has a working IPv4 firewall to manage. 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.managesIPv6() { log.Printf("firewall: iptables found no IPv6 save file alongside %s; managing IPv4 only", ipt.IP4Path) } else if ipt.IP6Service != ipt.IP4Service && !serviceEnabled(ctx, ipt.IP6Service) { log.Printf("firewall: iptables service %s is not enabled; managing IPv4 only", ipt.IP6Service) ipt.IP6Path, ipt.IP6Service = "", "" } // Detect the optional ipset persistence mechanism belonging to this packaging. // Unlike the rules save file, a missing mechanism is not fatal: address sets // still work live, they just are not staged and do not survive a reboot // (AddAddressSet warns when a set is added in that case). ipt.IPSetPath, ipt.IPSetService = ipsetLayoutInstalled(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, // IPv6 mirrors managesIPv6: with no managed ip6tables save file on this // host there is nowhere to write an IPv6 rule of any kind. IPv6: f.managesIPv6(), PortPair: true, ConnState: true, InterfaceMatch: true, Logging: true, RateLimit: true, ConnLimit: true, NAT: true, RuleOrdering: true, DefaultPolicy: true, RuleCounters: true, AddressSets: true, Comments: true, Negation: true, RejectAction: true, FamilyWithoutAddress: 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 } // 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": // Negation is unsupported 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 the address parses. _, _, 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 the address parses. _, _, 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": // Negation is unsupported 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 // The Rule model carries no reject type, so a `--reject-with` detail is // consumed and a rewrite re-emits a plain REJECT (iptables' default // icmp-port-unreachable). Rejecting the line instead would leave common // stock rules (RHEL's icmp-host-prohibited REJECT) foreign and // unmanageable, which costs more than the reject-code drift. 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": // Negation is unsupported 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 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. `--ports`/`--port` // means source OR destination port, which the model cannot hold — // mapping it onto one side would silently drop the other half on a // re-marshal — so such a line stays foreign. if i+2 >= len(tokens) { return nil, fmt.Errorf("invalid multiport match") } switch tokens[i+1] { case "--dports", "--dport", "--sports", "--sport": 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": // Reject an unknown protocol token. 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. // Reject an unknown protocol token. 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]) } // The token consumed any pending negation; clear it for the next one. 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 } // parseLiveRules decodes counter-annotated `iptables-save -c` output into the // rules the kernel's filter chains hold. iptables writes a rule as the same // rulespec it stores, so there is no product framing to undo here — the rulespec // parser takes each row as it stands, and rejects the chains this backend does // not report. A LOG line and its action line still pair up into the one logged // rule the save file reports (see decodeLiveRows). func (f *IPTables) parseLiveRules(out []string, fam Family) []*Rule { return decodeLiveRows(out, func(row liveRow) (*Rule, bool) { r, err := unmarshalIPTablesRule(row.line, fam) if err != nil { return nil, false } return r, true }) } // mergeLiveCounters copies the kernel's packet/byte counters onto the // file-parsed rules. The save files carry no counts, so a file edit not yet // activated by Reload leaves that rule's counters zero. func (f *IPTables) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) { // Each save file holds one family, so its rules are the ones that family's // live ruleset can account for. targets := countableRules(rules, fam) if len(targets) == 0 { return } applyLiveCounters(targets, f.parseLiveRules(liveSaveLines(ctx, fam), fam)) } // 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 decodes one iptables-save nat line (-A PREROUTING / -A // POSTROUTING ...) into a NATRule. A line the model cannot hold faithfully — a // negated interface/protocol/port match, an unknown protocol, an unsupported // jump, or another chain — is rejected so the raw line stays foreign and is // preserved verbatim; an OUTPUT-chain DNAT, for example, has no distinct model // here and would otherwise be relocated to PREROUTING on Restore. 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": // Interface, protocol and port carry no negated form in the model, so // a `!` here must reject the line — reading `! -o docker0` as a plain // interface match would invert the rule's meaning (Docker's stock // masquerade rule is exactly this shape). if not { return nil, fmt.Errorf("negated interface match is not modeled") } i++ if i >= len(tokens) { return nil, fmt.Errorf("invalid interface parameter") } r.Interface = tokens[i] case "-p", "--protocol": if not { return nil, fmt.Errorf("negated protocol match is not modeled") } i++ if i >= len(tokens) { return nil, fmt.Errorf("invalid protocol parameter") } proto := GetProtocol(tokens[i]) if proto == ProtocolAny { // An unknown protocol token must not silently widen to a // match-every-protocol rule a Restore would re-marshal without -p. return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) } r.Proto = proto case "--dport", "--destination-port": if not { return nil, fmt.Errorf("negated port match is not modeled") } 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": if not { return nil, fmt.Errorf("negated match is not modeled") } i++ if i >= len(tokens) { return nil, fmt.Errorf("invalid match parameter") } switch tokens[i] { case "set": // -m set [!] --match-set 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": default: // `--ports`/`--port` means source OR destination port; mapping it // onto the destination fields would silently drop the source half // on a re-marshal, so the line stays foreign. 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 } // 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 and next are the two physical lines that // iptables needs to express one logical "log and act" rule. The library models // logging as a flag on a rule that also has a terminal action (e.g. drop and // log inbound TCP :22), but iptables cannot: its LOG target is non-terminal — // the packet keeps traversing the chain after being logged — so the rule must // be written as two lines with identical match fields, a LOG line followed by // the action line: // // iptables -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j LOG --log-prefix "fw: " // iptables -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j DROP // // cur is the standalone LOG line (Log set, no terminal action) and next is its // action partner: same match fields ignoring the log flags and action, which // iptSameMatch verifies. When it matches, callers fold the pair back into the // single logged rule GetRules reports (see mergeLogPair). next may be nil when // cur is the last rule in the sequence, in which case the LOG line is an orphan // and this returns false. Callers must also confirm the two lines are physically // adjacent (no line between them): a LOG and an action separated by an unmodeled // foreign line are not one rule, and pairing them would synthesize a logical // rule no removal path could locate. It is the shared predicate behind // coalesceLoggedRules (native iptables-save reads) and hookScript.scanGroups // (CSF/APF hook reads), so the pairing behaves identically in both. 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. The // action line supplies the terminal action and match fields; only Log and // LogPrefix are carried over from the LOG line. Pass a pair logPartner accepted. func mergeLogPair(logLine, action *Rule) *Rule { merged := *action merged.Log = true merged.LogPrefix = logLine.LogPrefix return &merged } // 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 } // --- save-file scanning ------------------------------------------------------ // iptGroup is one logical span of an iptables-save file: a rule line together // with the rule it encodes — a LOG line and the action line under it count as // one group — or any other line on its own. It is the iptables counterpart of // the hook's hookGroup, and every read and rewrite path streams the file through // it, so table scoping, LOG-pair coalescing and the per-chain numbering GetRules // reports are each defined in exactly one place. type iptGroup struct { // raw preserves the original lines verbatim, so a rewrite copies user // formatting through and a removal drops a logged rule's two lines together. raw []string // table is the table the group's lines sit in ("filter", "nat", ...), or "" // outside any table. A table header belongs to the table it opens and a // COMMIT to the table it closes. table string // chain is the chain a rule line targets, "" for every other line. chain string // rule is the filter rule the group encodes, nil when it encodes none: a // line outside *filter, one the model cannot hold, one a container runtime // owns, an orphan LOG line, or a line that is not a rule at all. rule *Rule // nat is the nat rule the group encodes, on the same terms as rule. nat *NATRule // number is the 1-based position within its chain that rule or nat occupies, // mirroring the Number GetRules and GetNATRules report, or 0 when the group // begins no logical rule. number int // commit marks the group as its table's COMMIT line. commit bool } // 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 "" } // ruleLineChain returns the chain an iptables-save rule line targets, or "" when // the line is not a rule line. It gates chainOf on the commands iptables-save // emits (-A) and those a hand-edited file may carry (-I, -R), so a chain // declaration such as ":INPUT ACCEPT [0:0]" is not read as a rule in a chain // named ACCEPT. Every ignorable line — blank, comment, table header, chain // declaration, COMMIT — fails the same test, which is why the scanner needs no // separate ignorable-line check. func (f *IPTables) ruleLineChain(body string) string { switch { case strings.HasPrefix(body, "-A "), strings.HasPrefix(body, "-I "), strings.HasPrefix(body, "-R "): return f.chainOf(body) } return "" } // scanSaveGroups streams an iptables-save file to fn as logical groups in file // order, emitting every line exactly once so a caller can rewrite the file in a // single pass. A rule line is parsed only in the table that models it — *filter // into a Rule, *nat into a NATRule — so an INPUT rule sitting in *nat or *mangle // is never mistaken for a filter rule. // // A LOG line pairs with the action line PHYSICALLY under it and nothing else: // iptables writes a logged rule as two lines, and a pair separated by any other // line (a comment, a rule the model cannot hold, a rule in another chain) is not // one rule. Pairing across such a line would report through GetRules a logged // rule that no removal could ever locate, so the scan treats adjacency as the // whole test — the same rule hookScript.scanGroups applies in the hook. An // orphan LOG line is invisible to GetRules and so begins no logical rule; its // group still streams through with rule left nil. // // A nil fd scans as an empty file. An error from fn stops the scan. func (f *IPTables) scanSaveGroups(fd *os.File, family Family, fn func(g iptGroup) error) error { if fd == nil { return nil } table := "" // counts holds each chain's logical-rule count so far, keyed by table and // chain because the same built-in chain name exists in several tables. counts := map[string]int{} next := func(t, chain string) int { k := t + " " + chain counts[k]++ return counts[k] } // held buffers a parsed LOG line until the following line decides whether it // is that line's action partner; emitHeld flushes it as the orphan it turned // out to be, with its rule cleared. var held *iptGroup emitHeld := func() error { if held == nil { return nil } g := *held held = nil g.rule = nil return fn(g) } scanner := bufio.NewScanner(fd) for scanner.Scan() { raw := scanner.Text() line := strings.TrimSpace(raw) // A table header opens its table and a COMMIT closes it. Both end any // pending LOG pairing, since a partner must sit on the very next line. if strings.HasPrefix(line, "*") || line == "COMMIT" { if err := emitHeld(); err != nil { return err } g := iptGroup{raw: []string{raw}, table: table} if line == "COMMIT" { g.commit = true table = "" } else { table = strings.TrimPrefix(line, "*") g.table = table } if err := fn(g); err != nil { return err } continue } g := iptGroup{raw: []string{raw}, table: table, chain: f.ruleLineChain(f.ruleLineBody(line))} switch { case g.chain == "": // Not a rule line; it passes through and breaks any pending pairing. case table == "filter": rule, err := f.UnmarshalRule(line, family) // A line the model cannot hold, and one a container runtime owns, are // both invisible to GetRules: each keeps its physical slot but begins no // logical rule, so it takes no Number and no LOG line pairs across it. if err != nil || rule.isContainerRuntime() { break } // Fold a held LOG line together with the action line directly under it // into the one logged rule they encode, numbered at the LOG line. if held != nil && logPartner(held.rule, rule) { pair := *held held = nil pair.raw = append(pair.raw, raw) pair.rule = mergeLogPair(pair.rule, rule) pair.number = next(pair.table, pair.chain) if err := fn(pair); err != nil { return err } continue } // Not a partner, so any held LOG line is an orphan; flush it before // this line. if err := emitHeld(); err != nil { return err } // Buffer a bare LOG line (Log set, no terminal action) against the next // line; every other rule is complete on its own. if rule.Action == ActionInvalid && rule.Log { g.rule = rule held = &g continue } g.rule = rule g.number = next(table, g.chain) case table == "nat": nr, err := f.UnmarshalNATRule(line, family) // As in *filter, a line the model cannot hold and a container runtime's // own translation are both invisible to GetNATRules and take no Number. if err != nil || nr.isContainerRuntime() { break } g.nat = nr g.number = next(table, g.chain) } if err := emitHeld(); err != nil { return err } if err := fn(g); err != nil { return err } } if err := scanner.Err(); err != nil { return err } return emitHeld() } // scanSaveFile opens path and streams it through scanSaveGroups, for the read // paths that have no rewrite to stage. A save file this host manages is expected // to exist, so an open error is reported rather than scanned as empty. func (f *IPTables) scanSaveFile(path string, family Family, fn func(g iptGroup) error) error { fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() return f.scanSaveGroups(fd, family, fn) } // 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 the model cannot hold — nat rules, custom chains, unmodeled // foreign matches — are skipped by the scan (see scanSaveGroups). func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) { // The scan already scopes to the table that models each rule and drops the // lines GetRules cannot report, so every filter group carrying a rule is one // to return. GetRules assigns Number once both save files are read; the other // callers use the result only for dedup and never read Number. var out []*Rule err := f.scanSaveFile(path, family, func(g iptGroup) error { if g.table == "filter" && g.rule != nil { out = append(out, g.rule) } return nil }) if err != nil { return nil, err } return out, 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) } // An IPv4-only host has no v6 file to read; it reports IPv4 rows alone. var v6 []*Rule if f.managesIPv6() { if v6, err = f.parseFilterFile(f.IP6Path, IPv6); 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) // The save files carry no packet/byte counters — the kernel does — so merge // them from the live ruleset (RuleCounters). f.mergeLiveCounters(ctx, v4, IPv4) f.mergeLiveCounters(ctx, v6, IPv6) rules = append(rules, v4...) rules = append(rules, v6...) return } // checkFamilyManaged rejects a write pinned to a family this host does not // manage. Only IPv6 can be unmanaged, and only on an IPv4-only host; the error // wraps ErrUnsupported so a caller can tell "this host cannot express it" from a // malformed rule. A FamilyAny write is not rejected: it narrows to the IPv4 file, // matching how the other backends behave when their IPv6 handling is off. func (f *IPTables) checkFamilyManaged(fam Family) error { if fam == IPv6 && !f.managesIPv6() { return fmt.Errorf("iptables does not manage IPv6 on this host, so it cannot manage IPv6 rules: %w", ErrUnsupported) } return nil } // editRuleFiles applies one logical-rule edit to each family save file the // rule touches. The rule is fanned out once into its concrete cells — the // DirAny direction split times the TCPUDP protocol split, each cell one // save-file logical rule — and op rewrites each file's parsed groups covering // every cell in a single pass. Each changed file is staged first and committed // only once every file has staged, so a rule spanning families, directions or // protocols is never left half-applied by a failure part-way through. func (f *IPTables) editRuleFiles(r *Rule, op func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error)) error { // Fan the merged axes out into concrete cells. Only the direction decides // which chain a cell's lines land in; a TCPUDP split lands a tcp and a udp // line at the same spot, and the family split selects whole files below. var cells []*Rule for _, sub := range expandDirections(r) { cells = append(cells, expandProtocols(sub)...) } // Resolve the family files, letting an ICMP/ICMPv6 protocol pin the family: // `-p icmp` belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 // file. An IPv4-only host contributes no v6 path, so a FamilyAny rule // narrows to the IPv4 file and a concrete-IPv6 rule resolves to no file at // all — the write entry points reject that shape up front through // checkFamilyManaged, leaving removals here as no-ops. family := r.impliedFamily() var paths []string if family == IPv4 || family == FamilyAny { paths = append(paths, f.IP4Path) } if (family == IPv6 || family == FamilyAny) && f.managesIPv6() { paths = append(paths, f.IP6Path) } // Rewrite and stage every file first, committing nothing until all have // staged; a failure part-way discards the staged temp files. var staged []*atomicFile abort := func() { for _, s := range staged { s.Abort() } } for _, path := range paths { var groups []iptGroup if err := f.scanSaveFile(path, FamilyAny, func(g iptGroup) error { groups = append(groups, g) return nil }); err != nil { abort() return err } out, changed, err := op(groups, cells) if err != nil { abort() return err } // An unchanged file needs no rewrite. if !changed { continue } af, err := newAtomicFile(path, 0644) if err != nil { abort() return err } for _, g := range out { for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } } 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 } // 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 `. 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) } // 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, ",") } // 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, ",") } // validateRule reports whether iptables can express the filter rule, applying // the universal Rule.validate and then the iptables-specific shape constraints, // so marshalMatches encodes a rule already known to be expressible. func (f *IPTables) validateRule(r *Rule) error { if err := r.validate(); err != nil { return err } // 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. Reaching here // with TCPUDP means that fan-out was skipped. if err := r.CheckExpandedProtocol(); err != nil { return err } return nil } // marshalMatches builds the iptables-save match tokens for a rule (everything // up to but not including the `-j `), including any rate/connection // limit and the identifying comment. MarshalRule and the LOG-line encoder share // it so a logged rule's two lines carry identical match tokens. It is a pure // encoder: callers run validateRule on the fanned-out cell first. func (f *IPTables) marshalMatches(r *Rule) ([]string, error) { // 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. validateRule has already rejected a wrong-side pairing. 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 } // insertRuleGroups returns groups with each cell's line(s) spliced in before // the first *filter group at reports true for, falling back to the table's // COMMIT so a placement past a chain's end appends. A cell an equal logical // rule already covers is left alone rather than duplicated (LOG+action lines // scan as one group, so a logged rule compares as one unit), which also fills // in a subset left by an earlier partial edit instead of re-adding whole. It // is the shared body of AddRule and InsertRule; each passes its own placement // predicate. func (f *IPTables) insertRuleGroups(groups []iptGroup, cells []*Rule, at func(g iptGroup, cell *Rule) bool) ([]iptGroup, bool, error) { // Validate and encode every cell's line(s) up front: a logged cell is a LOG // line plus an action line, and a rejection or marshalling error must change // nothing. The check runs per cell rather than on the caller's rule because // editRuleFiles has already fanned the merged axes out — a TCPUDP rule is // legitimate on the way in and only its concrete halves are expressible. lines := make([][]string, len(cells)) for i, cell := range cells { if err := f.validateRule(cell); err != nil { return nil, false, err } ls, err := f.marshalRuleLines(cell) if err != nil { return nil, false, err } lines[i] = ls } // Note the cells the file already holds. With the groups in hand this is a // pass over parsed slices, not a second read of the file. placed := make([]bool, len(cells)) for _, g := range groups { if g.table != "filter" || g.rule == nil { continue } for i, cell := range cells { if !placed[i] && g.rule.EqualBase(cell, true) { placed[i] = true } } } // Splice each missing cell in ahead of the first group its predicate (or // the filter COMMIT fallback) selects. out := make([]iptGroup, 0, len(groups)+len(cells)) changed := false for _, g := range groups { if g.table == "filter" { for i, cell := range cells { if !placed[i] && (at(g, cell) || g.commit) { out = append(out, iptGroup{raw: lines[i]}) placed[i] = true changed = true } } } out = append(out, g) } // A cell that found no placement point means the file carries no *filter // table to hold it. for i := range cells { if !placed[i] { return nil, false, fmt.Errorf("failed to write the new rule to the iptables-save file") } } return out, changed, nil } // AddRule adds a rule to the zone. A family-agnostic set-referencing rule is // pinned to its set's family first, so its line lands only in the save file the // single-family ipset can match. func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error { r, err := resolveSetRefRule(r, f.setRefFamily) if err != nil { return err } if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } // Insert before the first rule line of any chain — physically ahead of every // rule line in the table, unmodeled lines included — or before the filter // table's COMMIT when it holds none yet, so the rule lands at the top of its // chain. return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { return f.insertRuleGroups(groups, cells, func(g iptGroup, _ *Rule) bool { return g.chain != "" }) }) } // 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" } // 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. A family-agnostic set-referencing rule is // pinned to its set's family first, as in AddRule. func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { r, err := resolveSetRefRule(r, f.setRefFamily) if err != nil { return err } if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } if position <= 0 { position = 1 } // Match each cell's 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. // The scan numbered each group as GetRules does — a LOG+action pair is one // logical rule holding both its lines, an unmodeled or orphan line is none — // so the insert lands at the position GetRules reports and never splits a // logged rule's two lines. return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { return f.insertRuleGroups(groups, cells, func(g iptGroup, cell *Rule) bool { return g.chain == iptChainForDirection(cell.Direction) && g.number == position }) }) } // moveCellGroups returns groups with the first group matching cell lifted and // re-inserted at the 1-based position within its chain, reporting false when no // group matches. The lifted group holds every line the rule occupies, so a // logged rule's LOG and action lines move together and a standalone LOG line is // never dragged along with an unrelated rule. func (f *IPTables) moveCellGroups(groups []iptGroup, cell *Rule, position int) ([]iptGroup, bool, error) { // Lift the first matching rule. moved := -1 for i, g := range groups { if g.table == "filter" && g.rule != nil && g.rule.EqualBase(cell, true) { moved = i break } } if moved < 0 { return groups, false, nil } lifted := groups[moved] // The three-index slice forces a copy rather than shifting groups in place. rest := append(groups[:moved:moved], groups[moved+1:]...) // Re-insert at the requested 1-based position, renumbering the target chain // over the post-removal groups: lifting the rule shifted every rule below it // up by one, so the numbers the scan stored cannot be reused. Exact // chain-name compare, as in InsertRule. A position past the chain's last // rule falls through to the table's COMMIT, which appends after that last // rule, so no clamp is needed (insertRuleGroups relies on the same fallback). expectedChain := iptChainForDirection(cell.Direction) out := make([]iptGroup, 0, len(rest)+1) inserted := false pos := 0 for _, g := range rest { if !inserted && g.table == "filter" { if g.chain == expectedChain && g.rule != nil { pos++ if pos == position { out = append(out, lifted) inserted = true } } if !inserted && g.commit { out = append(out, lifted) inserted = true } } out = append(out, g) } if !inserted { return nil, false, fmt.Errorf("failed to move the rule in the iptables-save file") } return out, true, 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 { if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } if position <= 0 { position = 1 } // A merged rule moves cell by cell: each concrete half is lifted and // re-inserted at the position within its own chain. A cell the file does not // hold is skipped rather than failing the others. return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { changed := false for _, cell := range cells { out, moved, err := f.moveCellGroups(groups, cell, position) if err != nil { return nil, false, err } if moved { groups = out changed = true } } return groups, changed, nil }) } // removeRuleGroups returns groups with every *filter group matching one of the // rule's cells dropped, reporting whether any matched. The scan handed back // whole logical rules, so a logged rule's LOG and action lines are dropped // together while a standalone LOG line is left where it is, and the // *nat/*mangle scoping that keeps a foreign nat rule from being removed as if // it were a filter rule is the scanner's. Every group a cell matches goes in // the one pass, so a chain holding the same rule twice comes back clean. func (f *IPTables) removeRuleGroups(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { out := make([]iptGroup, 0, len(groups)) changed := false for _, g := range groups { matched := false if g.table == "filter" && g.rule != nil { for _, cell := range cells { if g.rule.EqualBase(cell, true) { matched = true break } } } if matched { changed = true continue } out = append(out, g) } return out, changed, nil } // RemoveRule removes a rule from the zone. A family-agnostic set-referencing // rule is deliberately not pinned here: leaving it FamilyAny sweeps both family // files, which also clears a stray wrong-family line and still works after the // referenced set is gone. func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { return f.editRuleFiles(r, f.removeRuleGroups) } // fileFamily returns the IP family of one of this backend's save files. func (f *IPTables) fileFamily(path string) Family { if f.managesIPv6() && path == f.IP6Path { return IPv6 } return IPv4 } // natRulesInFile parses the nat-table rules from a save file. The scan drops the // lines GetNATRules cannot report — a line the model cannot hold, and a // container runtime's own translation such as Docker's per-published-port // hairpin masquerade — so every nat group carrying a rule is one to return. // GetNATRules assigns Number per family; the other callers use the result only // for dedup and never read Number. func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) { var rules []*NATRule err := f.scanSaveFile(path, f.fileFamily(path), func(g iptGroup) error { if g.table == "nat" && g.nat != nil { rules = append(rules, g.nat) } return nil }) if err != nil { return nil, err } 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/MoveNATRule // 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) } // An IPv4-only host has no v6 file to read. var v6 []*NATRule if f.managesIPv6() { if v6, err = f.natRulesInFile(f.IP6Path); 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 ...`). // It is a pure encoder: callers run NATRule.validate first. iptables' nat targets // cover every modeled shape, so it takes no rejections of its own. func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) { 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") } return strings.Join(parts, " "), nil } // editNATFile inserts or removes a NAT rule line within a save file's nat table // in a single streamed pass, creating the table section when adding to a file // that lacks one. An add is a no-op when the table already holds an equivalent // rule; a removal drops every matching line, not just the first, so a chain // holding duplicate equivalent lines comes clean in one call — mirroring // removeRuleGroups. A container runtime's translation is invisible to the // scan, so it is never a removal target, as on the filter side. func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) error { fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() changed, duplicate, sawNAT := false, false, false err = f.scanSaveGroups(fd, f.fileFamily(path), func(g iptGroup) error { if g.table == "nat" { if g.commit { sawNAT = true } switch { case !add: if g.nat != nil && g.nat.EqualBase(r) { changed = true return nil } case g.nat != nil && g.nat.EqualBase(r): duplicate = true case g.commit && !changed && !duplicate: // Insert before the table's COMMIT. _, _ = fmt.Fprintln(af, line) changed = true } } for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } return nil }) // A read error means the staged file is truncated; discard it. if err != nil { return err } if add { if duplicate { return nil } // The file carries no usable nat table; append a fresh one holding the rule. if !sawNAT { for _, l := range defaultNATSection[:len(defaultNATSection)-1] { _, _ = fmt.Fprintln(af, l) } _, _ = fmt.Fprintln(af, line) _, _ = fmt.Fprintln(af, "COMMIT") changed = true } } if !changed { return nil } return af.Commit() } // natPaths returns the save files a NAT rule applies to, per its family. func (f *IPTables) natPaths(r *NATRule) []string { // As in editRuleFiles, an IPv4-only host contributes no v6 path. fam := r.impliedFamily() var paths []string if fam == IPv4 || fam == FamilyAny { paths = append(paths, f.IP4Path) } if (fam == IPv6 || fam == FamilyAny) && f.managesIPv6() { 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. A family-agnostic set-referencing // rule is pinned to its set's family first, as with AddRule. func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { if err := r.validate(); err != nil { return err } r, err := resolveSetRefNAT(r, f.setRefFamily) if err != nil { return err } if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } 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 } // Exact chain-name compare so a foreign chain whose name starts with the // target chain (e.g. "PREROUTING_direct") is not counted (see // InsertRule). The scan numbers each group as GetNATRules does, so // a line the model cannot hold takes no position with it. chainName := f.natChain(r) fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() written, duplicate, sawNAT, seenChain := false, false, false, false writeRule := func() { _, _ = fmt.Fprintln(af, line) written = true } err = f.scanSaveGroups(fd, f.fileFamily(path), func(g iptGroup) error { if g.table == "nat" { if g.commit { sawNAT = true } if g.nat != nil && g.nat.EqualBase(r) { duplicate = true } if !written && !duplicate { switch { case g.chain == chainName && g.number == position: writeRule() case seenChain && g.chain != "" && g.chain != chainName: // The position ran past the chain's last rule: append directly // after it rather than at the table's COMMIT, so the chain's lines // stay together instead of landing behind another chain's. writeRule() case g.commit: // A chain with no rules of its own still lands inside the table. writeRule() } } if g.chain == chainName { seenChain = true } } for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } return nil }) // A read error means the staged file is truncated; discard it. if err != nil { return err } // Skip when an equivalent rule already exists. if duplicate { return nil } // The file carries no usable nat table; append a fresh one holding the rule. // A table header with no COMMIT under it still lets the position and // chain-run branches above place the rule, so append only when nothing was // written — otherwise the rule would land twice. if !sawNAT && !written { for _, l := range defaultNATSection[:len(defaultNATSection)-1] { _, _ = fmt.Fprintln(af, l) } _, _ = fmt.Fprintln(af, line) _, _ = fmt.Fprintln(af, "COMMIT") return af.Commit() } if !written { return nil } return af.Commit() } // 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 { if err := r.validate(); err != nil { return err } if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } 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 } // moveNATFile relocates the first NAT rule equal to r within its chain to the // given 1-based position, mirroring moveCellGroups for the nat table. The // matched line is lifted and re-placed verbatim so its comment prefix and // formatting survive; only its position in the chain changes. A non-positive // position is treated as 1; a position past the chain's end appends. It is a // no-op (nil) when the rule is not present or the file has no nat table. func (f *IPTables) moveNATFile(path string, r *NATRule, position int) error { if position <= 0 { position = 1 } // Exact chain-name compare so a foreign chain whose name starts with the // target chain is not counted. chainName := f.natChain(r) // As on the filter side, a move is the one nat edit that cannot stream: the // rule's new slot may sit ahead of the one it was lifted from. Holding the // scanned groups keeps the file read and parsed exactly once. var groups []iptGroup if err := f.scanSaveFile(path, f.fileFamily(path), func(g iptGroup) error { groups = append(groups, g) return nil }); err != nil { return err } // Lift the first matching rule verbatim, so its comment prefix and formatting // survive; only its position in the chain changes. moved := -1 for i, g := range groups { if g.table == "nat" && g.nat != nil && g.nat.EqualBase(r) { moved = i break } } if moved < 0 { return nil } lifted := groups[moved] // The three-index slice forces a copy rather than shifting groups in place. groups = append(groups[:moved:moved], groups[moved+1:]...) af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() // Re-insert at the requested position within the rule's chain, renumbering // over the post-removal groups. A position past the chain's last rule appends // after it, and a chain with no surviving rules falls back to the table's // COMMIT, mirroring insertNATFile. written, seenChain := false, false pos := 0 writeLifted := func() { for _, l := range lifted.raw { _, _ = fmt.Fprintln(af, l) } written = true } for _, g := range groups { if g.table == "nat" && !written { if g.chain == chainName && g.nat != nil { pos++ } switch { case g.chain == chainName && g.nat != nil && pos == position: writeLifted() case seenChain && g.chain != "" && g.chain != chainName: writeLifted() case g.commit: writeLifted() } if g.chain == chainName { seenChain = true } } for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } } if !written { return nil } return af.Commit() } // MoveNATRule moves an existing NAT rule to 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 moves the rule to the end. func (f *IPTables) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { return err } for _, path := range f.natPaths(r) { if err := f.moveNATFile(path, r, 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) { 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). The scan supplies the table scope. err := f.scanSaveFile(path, FamilyAny, func(g iptGroup) error { if g.table != "filter" { return nil } chain, action, ok := f.parsePolicyLine(g.raw[0]) if !ok { return nil } switch chain { case "INPUT": p.Input = action case "OUTPUT": p.Output = action case "FORWARD": p.Forward = action } return nil }) if err != nil { return nil, err } 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 } // An IPv4-only host has only the one policy to report, so there is nothing to // reconcile against. if !f.managesIPv6() { return v4, nil } 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 } // savePaths returns the save files this host manages, IPv4 first. It is the // whole-file counterpart to editRuleFiles' per-rule family fan-out, for the // operations that touch every managed file regardless of any one rule's family. func (f *IPTables) savePaths() []string { if !f.managesIPv6() { return []string{f.IP4Path} } return []string{f.IP4Path, f.IP6Path} } // 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 { fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() // 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. err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { raw := g.raw[0] if g.table == "filter" { if chain, _, ok := f.parsePolicyLine(raw); ok { var action Action switch chain { case "INPUT": action = policy.Input case "OUTPUT": action = policy.Output case "FORWARD": action = policy.Forward } // A direction the caller left unset keeps the file's own policy. if action != ActionInvalid { fields := strings.Fields(raw) counters := "[0:0]" if len(fields) >= 3 { counters = fields[2] } raw = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters) } } } _, _ = fmt.Fprintln(af, raw) for _, l := range g.raw[1:] { _, _ = fmt.Fprintln(af, l) } return nil }) if err != nil { return err } return af.Commit() } // 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 f.savePaths() { if err := f.setPolicyFile(path, policy); err != nil { return err } } return nil } // --- address sets (ipset) --------------------------------------------------- // // Address sets follow the same staged model as this backend's rules: a mutation // edits the ipset staging file and nothing reaches the kernel until Reload, // which loads the file before restarting the rules services so a `-m set` rule // resolves. Reading and writing the file rather than the live kernel is what // keeps a set another tool created to be temporary — a fail2ban ban, a hand-made // test set — out of the boot configuration, and it lets a rule removal and the // removal of the set it referenced land in one Reload instead of failing on // "in use by a kernel component". // // A host with no persistence mechanism (IPSetPath empty) has no staging file to // edit, so sets there are created live immediately and warned about; see // setPersistedStaging. // // Everything that touches the kernel goes through the netlink primitives in // ipset_linux.go. The one exception is applyStagedSets, which shells out to // `ipset restore`: that tool owns the save format, parses the entries and picks // each type's revision, and reproducing all three over netlink to load a file we // wrote in its own format would be work for its own sake. // 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 } // ipsetSaveScanner decodes ipset save-format lines into address sets in // create-line order. The staging file is streamed through it straight off disk, // so a large blocklist is never held in memory twice — once as raw lines and // again as parsed entries. // // The decode is single-pass: `ipset save` emits each set's create line ahead of // its members, and `ipset restore` rejects any other order, so an add line whose // set has not been seen names no set this file declares and is dropped. type ipsetSaveScanner struct { sets map[string]*AddressSet order []string } // line folds one ipset save-format line into the sets decoded so far. func (s *ipsetSaveScanner) line(f *IPTables, line string) { fields := strings.Fields(line) if len(fields) < 3 { return } switch fields[0] { case "create": if s.sets == nil { s.sets = map[string]*AddressSet{} } if _, dup := s.sets[fields[1]]; dup { return } family, t := f.ipsetParseType(fields) s.sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t} s.order = append(s.order, fields[1]) case "add": // An add line may carry entry options (`timeout 600`, `comment "x"`); // the entry itself is still the third field. if set, ok := s.sets[fields[1]]; ok { set.Entries = append(set.Entries, fields[2]) } } } // result returns the decoded sets in create-line order. func (s *ipsetSaveScanner) result() []*AddressSet { out := make([]*AddressSet, 0, len(s.order)) for _, n := range s.order { out = append(out, s.sets[n]) } return out } // scanIPSetSave streams an ipset save-format file off disk into address sets. A // file that does not exist decodes as no sets: nothing has been staged yet. func (f *IPTables) scanIPSetSave(path string) ([]*AddressSet, error) { fd, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } defer func() { _ = fd.Close() }() var s ipsetSaveScanner scanner := bufio.NewScanner(fd) for scanner.Scan() { s.line(f, scanner.Text()) } if err := scanner.Err(); err != nil { return nil, err } return s.result(), nil } // stagedSets reads the sets staged in the layout's ipset file, the authority for // what this backend manages. Callers that can act on a damaged file take the // error; setRefFamily and other best-effort readers use persistedIPSetSets. func (f *IPTables) stagedSets() ([]*AddressSet, error) { if f.IPSetPath == "" { return nil, nil } return f.scanIPSetSave(f.IPSetPath) } // persistedIPSetSets reads the staged sets for family resolution. An // unconfigured layout or an unreadable file means no sets — resolution falls // through to the live kernel rather than failing the write. func (f *IPTables) persistedIPSetSets() []*AddressSet { sets, err := f.stagedSets() if err != nil { return nil } return sets } // setRefFamily resolves the single family of the ipset(s) a rule references // through the shared ipset resolver: the live kernel first, then the layout's // ipset persistence file for a set declared only in boot config. A set neither // knows is an error: writing the rule anyway would put a line no restore can // load into a save file. A caller that knows better sets the rule's Family, // which bypasses resolution entirely. func (f *IPTables) setRefFamily(source, destination string) (Family, error) { return ipsetRefFamily(source, destination, func() ([]*AddressSet, error) { return f.persistedIPSetSets(), nil }) } // setPersistedStaging reports whether this host has an ipset staging file to manage // sets through. When it does not, the address-set methods fall back to acting on // the live kernel directly. func (f *IPTables) setPersistedStaging() bool { return f.IPSetPath != "" } // GetAddressSets returns the address sets managed by this backend: the staged // sets, or the live kernel's on a host with no staging file. func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { if f.setPersistedStaging() { return f.stagedSets() } return ipsetLiveSets() } // 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 } // marshalIPSetSave renders sets in ipset save format, the format the staging // file keeps so the distro's own restore unit and `ipset save` tooling read it // unchanged. func (f *IPTables) marshalIPSetSave(sets []*AddressSet) []byte { var b strings.Builder for _, s := range sets { b.WriteString("create " + s.Name + " " + f.ipsetTypeSpec(s.Family, s.Type) + "\n") for _, e := range s.Entries { b.WriteString("add " + s.Name + " " + e + "\n") } } return []byte(b.String()) } // writeStagedSets replaces the staging file with sets and makes sure the restore // unit is enabled, so a reboot loads them before the rules service loads the // rules that reference them. func (f *IPTables) writeStagedSets(ctx context.Context, sets []*AddressSet) error { if f.IPSetService != "" { if err := enableService(ctx, f.IPSetService); err != nil { return err } } return writeConfigFile(f.IPSetPath, f.marshalIPSetSave(sets), 0600) } // setFamily resolves a set's stored family: a set is family-typed, and // FamilyAny is recorded as IPv4 so the staged file always names a concrete one. func (f *IPTables) setFamily(set *AddressSet) Family { if set.Family == FamilyAny { return IPv4 } return set.Family } // editStagedSets reads the staged sets, hands them to edit, and writes the // result back when edit reports a change. func (f *IPTables) editStagedSets(ctx context.Context, edit func(sets []*AddressSet) ([]*AddressSet, bool, error)) error { sets, err := f.stagedSets() if err != nil { return err } out, changed, err := edit(sets) if err != nil { return err } if !changed { return nil } return f.writeStagedSets(ctx, out) } // dropPendingRemoval cancels a staged removal for name, for a set added back // before the Reload that would have destroyed it. func (f *IPTables) dropPendingRemoval(name string) { for i, n := range f.pendingSetRemovals { if n == name { f.pendingSetRemovals = append(f.pendingSetRemovals[:i], f.pendingSetRemovals[i+1:]...) return } } } // liveAddAddressSet creates a set directly in the kernel, the fallback on a host // with no staging file to write. func (f *IPTables) liveAddAddressSet(ctx context.Context, set *AddressSet) error { if err := ipsetCreate(set.Name, f.setFamily(set), set.Type); err != nil { return err } for _, entry := range set.Entries { if err := ipsetAddEntry(set.Name, entry); err != nil { return err } } return nil } // AddAddressSet stages an address set; it is created in the kernel by Reload. // Adding a set that already exists by name merges its entries, matching what a // re-create with -exist does live; re-declaring one with a different family or // type is a conflict and is reported rather than silently rewriting the set. func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { if set == nil || set.Name == "" { return fmt.Errorf("an address set requires a name") } if !f.setPersistedStaging() { log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot") return f.liveAddAddressSet(ctx, set) } err := f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { family := f.setFamily(set) for _, s := range sets { if s.Name != set.Name { continue } if s.Family != family || s.Type != set.Type { return nil, false, fmt.Errorf("address set %q is already staged as %s %s, not %s %s", set.Name, s.Family, s.Type, family, set.Type) } // Merge the requested entries into the existing set. changed := false for _, e := range set.Entries { if !slices.Contains(s.Entries, e) { s.Entries = append(s.Entries, e) changed = true } } return sets, changed, nil } add := &AddressSet{Name: set.Name, Family: family, Type: set.Type} add.Entries = append(add.Entries, set.Entries...) return append(sets, add), true, nil }) if err != nil { return err } f.dropPendingRemoval(set.Name) return nil } // RemoveAddressSet removes an address set by name. The set is dropped from the // staging file now and destroyed in the kernel by Reload, once the rules that // referenced it are gone. Removing a set that is not staged is a no-op. func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { if !f.setPersistedStaging() { return f.liveRemoveAddressSet(ctx, name) } removed := false err := f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { out := make([]*AddressSet, 0, len(sets)) for _, s := range sets { if s.Name == name { removed = true continue } out = append(out, s) } return out, removed, nil }) if err != nil { return err } // Queue the kernel-side destroy for Reload. A set that was never staged has // nothing to destroy, so an already-gone set stays a clean no-op. if removed && !slices.Contains(f.pendingSetRemovals, name) { f.pendingSetRemovals = append(f.pendingSetRemovals, name) } return nil } // liveRemoveAddressSet destroys a set directly in the kernel, the fallback on a // host with no staging file, and the path Reload takes for a staged removal. func (f *IPTables) liveRemoveAddressSet(ctx context.Context, name string) error { // Empty the set before destroying it. A missing set is a no-op in both steps; // any other failure — notably the kernel refusing to destroy a set a loaded // rule still matches on — is real and must be surfaced rather than reported as // success while the set remains. if err := ipsetFlush(name); err != nil { return err } return ipsetDestroy(name) } // AddAddressSetEntry adds an entry to the named set. func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error { if !f.setPersistedStaging() { return ipsetAddEntry(name, entry) } return f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { for _, s := range sets { if s.Name != name { continue } if slices.Contains(s.Entries, entry) { return sets, false, nil } s.Entries = append(s.Entries, entry) return sets, true, nil } // Mirrors what `ipset add` reports against a set the kernel does not hold. return nil, false, fmt.Errorf("address set %q does not exist", name) }) } // RemoveAddressSetEntry removes an entry from the named set. A missing entry, or // a missing set, is a no-op. func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { if !f.setPersistedStaging() { return ipsetDelEntry(name, entry) } return f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { for _, s := range sets { if s.Name != name { continue } if i := slices.Index(s.Entries, entry); i >= 0 { s.Entries = append(s.Entries[:i], s.Entries[i+1:]...) return sets, true, nil } } return sets, false, nil }) } // applyStagedSets loads the staging file into the kernel. Each declared set is // created if missing, then flushed and refilled so it ends up matching the file // exactly; a set the file does not declare is left alone, since the kernel also // holds sets other tools own and reconcile (kube-proxy, Calico, fail2ban). func (f *IPTables) applyStagedSets(ctx context.Context) error { if !f.setPersistedStaging() { return nil } sets, err := f.stagedSets() if err != nil { return err } if len(sets) == 0 { return nil } var script strings.Builder for _, s := range sets { script.WriteString("create " + s.Name + " " + f.ipsetTypeSpec(s.Family, s.Type) + " -exist\n") script.WriteString("flush " + s.Name + "\n") for _, e := range s.Entries { script.WriteString("add " + s.Name + " " + e + "\n") } } _, err = runCommandStdin(ctx, script.String(), "ipset", "restore") return err } // applyPendingSetRemovals destroys the sets removed from the staging file this // session. It runs after the rules services restart, so the rules that // referenced them are already gone and the destroy is not refused as in-use. A // set that will not destroy stays queued for the next Reload. func (f *IPTables) applyPendingSetRemovals(ctx context.Context) error { if len(f.pendingSetRemovals) == 0 { return nil } var failed []string var firstErr error for _, name := range f.pendingSetRemovals { if err := f.liveRemoveAddressSet(ctx, name); err != nil { failed = append(failed, name) if firstErr == nil { firstErr = err } } } f.pendingSetRemovals = failed return firstErr } // 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 } // 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. // // A modeled-chain (INPUT/OUTPUT/FORWARD) line is dropped only when the scan // resolved it to a rule, because only then can the desired set reproduce it. // Every line the scan leaves unresolved is kept verbatim, on the same principle // the library already applies to a user-defined chain — a rule it does not model // must not be deleted just because it is invisible. Three kinds qualify: // - 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 line a container runtime owns, which GetRules never reports and whose // deletion would sever live container networking. // - 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. A LOG line that DID pair is part of its group's resolved rule // and is dropped with it. func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error { fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() inserted := false err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { if g.table == "filter" { switch { case g.commit: if !inserted { for _, l := range ruleLines { _, _ = fmt.Fprintln(af, l) } inserted = true } // The library models the INPUT, OUTPUT and FORWARD chains, so the // desired set can only ever contain those. Drop an existing modeled rule // (counter-annotated or not) — the desired set replaces it — but keep a // rule in any other chain verbatim, since parseFilterFile never captures // those chains and dropping them would silently delete rules the library // does not manage. case g.chain != "" && f.modeledFilterChain(g.chain) && g.rule != nil: return nil } } for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } return nil }) if err != nil { return err } if !inserted { for _, l := range []string{"*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]"} { _, _ = fmt.Fprintln(af, l) } for _, l := range ruleLines { _, _ = fmt.Fprintln(af, l) } _, _ = fmt.Fprintln(af, "COMMIT") } return af.Commit() } // 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, unmodeled managed-chain lines, the *filter table and // all other content untouched. A file with no *nat table gains one. It is the // nat counterpart of rewriteFilterRules, and preserves an unresolved // managed-chain line on the same grounds: an unsupported jump (-j DOCKER, -j // RETURN), an unmodeled or negated match, a source-port match, or a container // runtime's own translation is invisible to GetNATRules, so it never appears in // the desired set and dropping it would sever — for example — Docker's port // publishing on a Backup/Restore round trip. func (f *IPTables) rewriteNATRules(path string, natLines []string) error { fd, err := os.Open(path) if err != nil { return err } defer func() { _ = fd.Close() }() af, err := newAtomicFile(path, 0644) if err != nil { return err } defer af.Abort() inserted := false err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { if g.table == "nat" { switch { case g.commit: if !inserted { for _, l := range natLines { _, _ = fmt.Fprintln(af, l) } inserted = true } case g.chain != "" && f.managedNATChain(g.chain) && g.nat != nil: return nil } } for _, l := range g.raw { _, _ = fmt.Fprintln(af, l) } return nil }) if err != nil { return err } if !inserted { for _, l := range defaultNATSection[:len(defaultNATSection)-1] { _, _ = fmt.Fprintln(af, l) } for _, l := range natLines { _, _ = fmt.Fprintln(af, l) } _, _ = fmt.Fprintln(af, "COMMIT") } return af.Commit() } // 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") } // Stage the ipsets first so a set-referencing rule (@set) resolves against a // declared set while the save files below are rewritten, and so the caller's // Reload loads the sets ahead of the rules. AddAddressSet merges into an // already-staged set, making a restore over existing state idempotent. 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 } // A backup taken on a dual-stack host and replayed onto an IPv4-only one has // no v6 file to splice its IPv6 rows into. Report what is being dropped rather // than failing the whole restore: the IPv4 half is still worth applying, and // the host genuinely cannot hold the rest. if !f.managesIPv6() { // Count only rows pinned to IPv6: a family-agnostic row is not dropped, // it narrows to the IPv4 file like every other FamilyAny write. dropped := 0 for _, r := range backup.Rules { if r.impliedFamily() == IPv6 { dropped++ } } for _, r := range backup.NATRules { if r.impliedFamily() == IPv6 { dropped++ } } if dropped > 0 { log.Printf("firewall: iptables restore skipped %d IPv6 rule(s); this host does not manage IPv6", dropped) } } for _, path := range f.savePaths() { fam := f.fileFamily(path) // 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 } if err := c.validate(); err != nil { return err } 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 activates the staged state: the address sets first, so a rule matching // on one resolves when the save files load, then the restore service(s) for the // rules, then the kernel-side destroy of any set removed this session, which has // to wait until the rules that referenced it are gone. // // The sets are loaded here directly rather than by restarting IPSetService, // whose stop hook varies by packaging — some flush, some save the live state // back over the file — and would put this backend's staged file at the mercy of // it. The service still matters for boot, which is why writeStagedSets enables it. func (f *IPTables) Reload(ctx context.Context) error { if err := f.applyStagedSets(ctx); err != nil { return err } if err := restartService(ctx, f.IP4Service); err != nil { return err } // Nothing more to restart when the host has no v6 service, or when the Debian // layout's single service (netfilter-persistent) already restored both // families above. if f.IP6Service != "" && f.IP6Service != f.IP4Service { if err := restartService(ctx, f.IP6Service); err != nil { return err } } return f.applyPendingSetRemovals(ctx) } // Close releases manager resources. func (f *IPTables) Close(ctx context.Context) error { return nil } // 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 }