3449 lines
114 KiB
Go
3449 lines
114 KiB
Go
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 <name> src|dst names an ipset in place of an
|
|
// address; the optional `!` negates it. A combined `src,dst` flag
|
|
// matches on both, which the Rule model (one set per direction) cannot
|
|
// represent, so it is rejected.
|
|
i++
|
|
setNot := false
|
|
if i < len(tokens) && tokens[i] == "!" {
|
|
setNot = true
|
|
i++
|
|
}
|
|
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
|
|
return nil, fmt.Errorf("unsupported set match")
|
|
}
|
|
name := tokens[i+1]
|
|
dir := tokens[i+2]
|
|
i += 2
|
|
if setNot {
|
|
name = "!" + name
|
|
}
|
|
switch dir {
|
|
case "src":
|
|
r.Source = name
|
|
case "dst":
|
|
r.Destination = name
|
|
default:
|
|
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
|
|
}
|
|
case "comment":
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
if tokens[i+1] != "--comment" {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
i += 2
|
|
// Capture the comment text. The caller strips it when it is the
|
|
// configured prefix rather than a user-supplied label.
|
|
r.Comment = tokens[i]
|
|
case "conntrack":
|
|
// Only the conntrack state option is modeled.
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--ctstate" {
|
|
return nil, fmt.Errorf("unsupported conntrack match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(tokens[i])
|
|
if serr != nil {
|
|
return nil, serr
|
|
}
|
|
r.State = state
|
|
case "state":
|
|
// Legacy state match: -m state --state NEW,ESTABLISHED.
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--state" {
|
|
return nil, fmt.Errorf("unsupported state match")
|
|
}
|
|
i += 2
|
|
state, serr := ParseConnState(tokens[i])
|
|
if serr != nil {
|
|
return nil, serr
|
|
}
|
|
r.State = state
|
|
case "limit":
|
|
// -m limit --limit N/unit [--limit-burst B]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--limit" {
|
|
return nil, fmt.Errorf("unsupported limit match")
|
|
}
|
|
i += 2
|
|
rate, unit, rerr := parseRateToken(tokens[i])
|
|
if rerr != nil {
|
|
return nil, rerr
|
|
}
|
|
rl := &RateLimit{Rate: rate, Unit: unit}
|
|
if i+2 < len(tokens) && tokens[i+1] == "--limit-burst" {
|
|
b, berr := strconv.ParseUint(tokens[i+2], 10, 32)
|
|
if berr != nil {
|
|
return nil, fmt.Errorf("invalid limit burst %q", tokens[i+2])
|
|
}
|
|
rl.Burst = uint(b)
|
|
i += 2
|
|
}
|
|
// Legacy (xtables) iptables prints a default --limit-burst of 5 on
|
|
// every -m limit match; treat it as unset so a Burst-0 rule still
|
|
// matches on a legacy host (an explicit 5 collapses to 0 too).
|
|
if rl.Burst == 5 {
|
|
rl.Burst = 0
|
|
}
|
|
r.RateLimit = rl
|
|
case "connlimit":
|
|
// -m connlimit --connlimit-above N [--connlimit-mask M]
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--connlimit-above" {
|
|
return nil, fmt.Errorf("unsupported connlimit match")
|
|
}
|
|
i += 2
|
|
n, nerr := strconv.ParseUint(tokens[i], 10, 32)
|
|
if nerr != nil {
|
|
return nil, fmt.Errorf("invalid connlimit %q", tokens[i])
|
|
}
|
|
// The default mask (32/128) counts per source; an explicit mask
|
|
// of 0 counts globally.
|
|
cl := &ConnLimit{Count: uint(n), PerSource: true}
|
|
if i+2 < len(tokens) && tokens[i+1] == "--connlimit-mask" {
|
|
if tokens[i+2] == "0" {
|
|
cl.PerSource = false
|
|
}
|
|
i += 2
|
|
}
|
|
// iptables-save always appends the counting key (--connlimit-saddr
|
|
// by default, or --connlimit-daddr) after the match; consume it so
|
|
// the trailing flag does not fail the parse and drop the whole rule.
|
|
if i+1 < len(tokens) && (tokens[i+1] == "--connlimit-saddr" || tokens[i+1] == "--connlimit-daddr") {
|
|
i++
|
|
}
|
|
r.ConnLimit = cl
|
|
case "icmp", "icmp6":
|
|
// -m icmp --icmp-type N / -m icmp6 --icmpv6-type N. The type
|
|
// qualifier is optional; a bare match just selects the module.
|
|
v6 := tokens[i] == "icmp6"
|
|
typeFlag := "--icmp-type"
|
|
if v6 {
|
|
typeFlag = "--icmpv6-type"
|
|
}
|
|
if i+2 < len(tokens) && tokens[i+1] == typeFlag {
|
|
i += 2
|
|
// iptables-save spells a type-with-code as `type/code` (e.g.
|
|
// `3/1`); the Rule model carries only the type, so drop a trailing
|
|
// `/code` before resolving rather than failing the whole rule.
|
|
typeTok := tokens[i]
|
|
if slash := strings.IndexByte(typeTok, '/'); slash >= 0 {
|
|
typeTok = typeTok[:slash]
|
|
}
|
|
n, ok := parseICMPTypeFamily(typeTok, v6)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid icmp type %q", tokens[i])
|
|
}
|
|
r.ICMPType = Ptr(n)
|
|
}
|
|
case "multiport":
|
|
// -m multiport --dports/--sports 80,443,1000:2000. `--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 <name> src|dst names an ipset in place of an
|
|
// address (see the filter parser).
|
|
i++
|
|
setNot := false
|
|
if i < len(tokens) && tokens[i] == "!" {
|
|
setNot = true
|
|
i++
|
|
}
|
|
if i+2 >= len(tokens) || tokens[i] != "--match-set" {
|
|
return nil, fmt.Errorf("unsupported set match")
|
|
}
|
|
name := tokens[i+1]
|
|
dir := tokens[i+2]
|
|
i += 2
|
|
if setNot {
|
|
name = "!" + name
|
|
}
|
|
switch dir {
|
|
case "src":
|
|
r.Source = name
|
|
case "dst":
|
|
r.Destination = name
|
|
default:
|
|
return nil, fmt.Errorf("unsupported set match direction: %s", dir)
|
|
}
|
|
case "comment":
|
|
if i+2 >= len(tokens) || tokens[i+1] != "--comment" {
|
|
return nil, fmt.Errorf("invalid match parameter")
|
|
}
|
|
i += 2
|
|
// A NAT rule carries no user comment, only the prefix tag; its
|
|
// presence marks the rule as one this library tagged.
|
|
if _, hasPrefix := prefixedComment(f.rulePrefix, tokens[i]); hasPrefix {
|
|
r.HasPrefix = true
|
|
}
|
|
case "tcp", "udp", "sctp":
|
|
if i+2 < len(tokens) && (tokens[i+1] == "--dport" || tokens[i+1] == "--destination-port") {
|
|
i += 2
|
|
pr, perr := ParsePortRange(tokens[i])
|
|
if perr != nil {
|
|
return nil, perr
|
|
}
|
|
if pr.Start == pr.End {
|
|
r.Port = pr.Start
|
|
} else {
|
|
r.Ports = []PortRange{pr}
|
|
}
|
|
}
|
|
case "multiport":
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("invalid multiport match")
|
|
}
|
|
switch tokens[i+1] {
|
|
case "--dports", "--dport":
|
|
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 <name> <dir>`. A leading "!" negation is emitted before the
|
|
// match in both cases.
|
|
func (f *IPTables) addrArgs(addr, dir string) []string {
|
|
neg, bare := splitAddrNeg(addr)
|
|
if isSetRef(addr) {
|
|
// The set match negates internally: `-m set ! --match-set name dir`.
|
|
out := []string{"-m", "set"}
|
|
if neg {
|
|
out = append(out, "!")
|
|
}
|
|
return append(out, "--match-set", bare, dir)
|
|
}
|
|
// An address negates with a leading `!`: `! -s addr`.
|
|
var out []string
|
|
if neg {
|
|
out = append(out, "!")
|
|
}
|
|
flag := "-s"
|
|
if dir == "dst" {
|
|
flag = "-d"
|
|
}
|
|
return append(out, flag, bare)
|
|
}
|
|
|
|
// 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 <target>`), 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
|
|
}
|