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